1571 lines
58 KiB
TypeScript
1571 lines
58 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react';
|
|
import { AppShell } from './components/layout/AppShell';
|
|
import { MessageStream } from './components/chat/MessageStream';
|
|
import { InputArea } from './components/chat/InputArea';
|
|
import { SlimTabBar } from './components/doc/SlimTabBar';
|
|
import { DeckDrawer } from './components/doc/DeckDrawer';
|
|
import { BlockRenderer } from './components/doc/BlockRenderer';
|
|
import { BlockEditorModal } from './components/doc/BlockEditorModal';
|
|
import { CommentSidePanel } from './components/doc/CommentSidePanel';
|
|
import { DocGovActionBar } from './components/doc/DocGovActionBar';
|
|
import { ProposalConfirmCard } from './components/doc/ProposalConfirmCard';
|
|
import { UserProjectRightsModal } from './components/user/UserProjectRightsModal';
|
|
import { UserSettingsModal } from './components/user/UserSettingsModal';
|
|
import { AdminSettingsModal } from './components/admin/AdminSettingsModal';
|
|
import { DocVisibilityModal } from './components/doc/DocVisibilityModal';
|
|
import { LoginView } from './components/auth/LoginView';
|
|
import { FirstLoginPasswordModal } from './components/auth/FirstLoginPasswordModal';
|
|
import { DevRoleSwitcher } from './components/dev/DevRoleSwitcher';
|
|
import { authClient } from './api/auth';
|
|
import { LoginResult, SystemAuthConfig } from './types/auth';
|
|
import { markdownToBlockAST } from './engine/markdownToBlock';
|
|
import { blockASTToMarkdown } from './engine/blockToMarkdown';
|
|
import { recomputeNumbering } from './engine/autoNumber';
|
|
import { DocumentAST, DocumentBlock, BlockComment } from './types/block';
|
|
import {
|
|
ProjectSession,
|
|
ChatMessage,
|
|
DeliverableItem,
|
|
DeliverableCategory,
|
|
InteractiveCardData,
|
|
} from './types/workbench';
|
|
import { UserRole, UserProfile, DocPermissionRole } from './types/docgov';
|
|
import { RoleDefinition, DocVisibilityRule } from './types/rbac';
|
|
import {
|
|
DEFAULT_ROLES,
|
|
hasPermission,
|
|
filterVisibleDeliverablesWithRBAC,
|
|
} from './engine/rbac';
|
|
import { MOCK_DOCUMENTS } from './data/mockDocuments';
|
|
import { exportDocumentToDocx } from './engine/docxRenderer';
|
|
import { exportDocumentToXlsx } from './engine/xlsxRenderer';
|
|
import {
|
|
findMatchingBlockForReview,
|
|
createRejectConfirmationCard,
|
|
isAffirmativeResponse,
|
|
findLastActiveCard,
|
|
decrementActiveCardsTtl,
|
|
applyConfirmRejectToDoc,
|
|
applyUndoRejectToDoc,
|
|
resolveCardInMessages,
|
|
cancelCardInMessages,
|
|
} from './engine/cardActionEngine';
|
|
import { createProjectTemplateDeliverables } from './engine/templateFactory';
|
|
import {
|
|
loadSessions,
|
|
saveSessions,
|
|
loadActiveSessionId,
|
|
saveActiveSessionId,
|
|
loadMessages,
|
|
saveMessages,
|
|
loadDeliverables,
|
|
saveDeliverables,
|
|
loadRoles,
|
|
saveRoles,
|
|
loadUsers,
|
|
saveUsers,
|
|
} from './data/storage';
|
|
|
|
export const INITIAL_DELIVERABLES: DeliverableItem[] = [
|
|
{
|
|
docId: 'doc-001',
|
|
projectId: 1,
|
|
taskKey: 'feasibility_reg',
|
|
title: '4K荧光内窥镜立项与可行性报告',
|
|
stage: 'V0.5',
|
|
category: 'workflow',
|
|
creatorId: 1,
|
|
creatorName: '张工',
|
|
isFixedWorkflow: true,
|
|
isTemplateOnly: false,
|
|
lifecycleState: 'drafting',
|
|
isClaimed: true,
|
|
author: '张工',
|
|
reviewer: '李主管',
|
|
approver: '王总监',
|
|
updatedAt: '2026-09-08 14:15',
|
|
summary: '针对4K荧光腹腔镜分类界定、临床评价路径与法规标准准入全景评估。',
|
|
},
|
|
{
|
|
docId: 'doc-002',
|
|
projectId: 1,
|
|
taskKey: 'research',
|
|
title: '微创外科全球竞品深度调研',
|
|
stage: 'V0.5',
|
|
category: 'workflow',
|
|
creatorId: 1,
|
|
creatorName: '张工',
|
|
isFixedWorkflow: true,
|
|
isTemplateOnly: false,
|
|
lifecycleState: 'reviewing',
|
|
isClaimed: true,
|
|
author: '张工',
|
|
reviewer: '李主管',
|
|
approver: '王总监',
|
|
updatedAt: '2026-09-08 11:30',
|
|
summary: '针对史赛克1688、卡尔史托斯Rubina荧光内窥镜性能全方位对比分析。',
|
|
},
|
|
{
|
|
docId: 'doc-003',
|
|
projectId: 1,
|
|
taskKey: 'prelim_design',
|
|
title: '光学分光与电路初步设计规格书',
|
|
stage: 'V0.5',
|
|
category: 'workflow',
|
|
creatorId: 1,
|
|
creatorName: '张工',
|
|
isFixedWorkflow: true,
|
|
isTemplateOnly: false,
|
|
lifecycleState: 'approving',
|
|
isClaimed: true,
|
|
author: '张工',
|
|
reviewer: '李主管',
|
|
approver: '王总监',
|
|
updatedAt: '2026-09-07 17:00',
|
|
summary: '4K荧光双路分光棱镜光路设计与近红外消色差物镜参数设计。',
|
|
},
|
|
{
|
|
docId: 'doc-004',
|
|
projectId: 1,
|
|
taskKey: 'cdo_quote',
|
|
title: 'CDO商业报价单与工时清单',
|
|
stage: 'V1.0',
|
|
category: 'workflow',
|
|
creatorId: 4,
|
|
creatorName: '王商务',
|
|
isFixedWorkflow: true,
|
|
isTemplateOnly: false,
|
|
lifecycleState: 'drafting',
|
|
isClaimed: true,
|
|
author: '王商务',
|
|
reviewer: '张工',
|
|
approver: '李总',
|
|
updatedAt: '2026-09-08 15:30',
|
|
summary: '基于WBS千工时分解与CDO成本模型的真实商业报价核算。',
|
|
},
|
|
{
|
|
docId: 'doc-shared-001',
|
|
projectId: 1,
|
|
taskKey: 'supply_chain_analysis',
|
|
title: '4K双路CMOS供应链与成本灵敏度分析',
|
|
stage: 'V0.5',
|
|
category: 'shared',
|
|
creatorId: 1,
|
|
creatorName: '张工',
|
|
sharedAt: '2026-09-08 15:20',
|
|
isFixedWorkflow: false,
|
|
isTemplateOnly: false,
|
|
lifecycleState: 'drafting',
|
|
isClaimed: true,
|
|
author: '张工',
|
|
reviewer: '李主管',
|
|
approver: '王总监',
|
|
updatedAt: '2026-09-08 15:20',
|
|
summary: '张工在与助手对话中推演的 Sony IMX485 与国产长光辰芯双路 CMOS 传感器成本灵敏度测算。',
|
|
},
|
|
{
|
|
docId: 'doc-private-001',
|
|
projectId: 1,
|
|
taskKey: 'negotiation_memo',
|
|
title: '微创医疗战略商务谈判底线与预付款让利推演备忘',
|
|
stage: 'V1.0',
|
|
category: 'private',
|
|
creatorId: 1,
|
|
creatorName: '张工',
|
|
isFixedWorkflow: false,
|
|
isTemplateOnly: false,
|
|
lifecycleState: 'drafting',
|
|
isClaimed: true,
|
|
author: '张工',
|
|
reviewer: '李主管',
|
|
approver: '王总监',
|
|
updatedAt: '2026-09-08 16:10',
|
|
summary: '张工个人与助手对话进行的商务谈判底线、毛利率红线测算草案,含个人思考,仅本人可见。',
|
|
},
|
|
{
|
|
docId: 'doc-private-002',
|
|
projectId: 1,
|
|
taskKey: 'lead_capacity_memo',
|
|
title: '团队绩效核算与阶段资源饱和度评估',
|
|
stage: 'V0.5',
|
|
category: 'private',
|
|
creatorId: 2,
|
|
creatorName: '李主管',
|
|
isFixedWorkflow: false,
|
|
isTemplateOnly: false,
|
|
lifecycleState: 'drafting',
|
|
isClaimed: true,
|
|
author: '李主管',
|
|
reviewer: '李主管',
|
|
approver: '王总监',
|
|
updatedAt: '2026-09-08 15:50',
|
|
summary: '李主管个人对话生成的团队工时饱和度与绩效预算底稿,仅李主管可见。',
|
|
},
|
|
];
|
|
|
|
// 为默认第二项目 (便携式超声探头,projectId: 2) 预置全套 7 份生命周期模板副本
|
|
const P2_INITIAL = createProjectTemplateDeliverables({
|
|
projectId: 2,
|
|
projectName: '便携式掌上无线超声诊断仪',
|
|
deviceType: '便携式超声探头',
|
|
creatorId: 1,
|
|
creatorName: '张工',
|
|
});
|
|
|
|
export const ALL_INITIAL_DELIVERABLES: DeliverableItem[] = [
|
|
...INITIAL_DELIVERABLES,
|
|
...P2_INITIAL.deliverables,
|
|
];
|
|
|
|
export const MOCK_USERS: UserProfile[] = [
|
|
{
|
|
id: 1,
|
|
name: '张工',
|
|
employeeId: 'SMDF-0821',
|
|
department: '机器人与微创内窥镜研发中心',
|
|
position: '资深医疗器械方案工程师',
|
|
roleId: 'role-engineer',
|
|
projectPermissions: {
|
|
'doc-001': 'author', // 编制人
|
|
'doc-002': 'reviewer', // 审核人
|
|
'doc-003': 'approver', // 批准人
|
|
'doc-004': 'viewer', // 查阅者
|
|
'doc-shared-001': 'author',
|
|
'doc-private-001': 'author',
|
|
},
|
|
},
|
|
{
|
|
id: 2,
|
|
name: '李主管',
|
|
employeeId: 'SMDF-0205',
|
|
department: '机器人与微创内窥镜研发中心',
|
|
position: '研发项目技术主管',
|
|
roleId: 'role-tech-lead',
|
|
projectPermissions: {
|
|
'doc-001': 'reviewer',
|
|
'doc-002': 'author',
|
|
'doc-003': 'reviewer',
|
|
'doc-004': 'viewer',
|
|
'doc-shared-001': 'viewer',
|
|
'doc-private-002': 'author',
|
|
},
|
|
},
|
|
{
|
|
id: 3,
|
|
name: '王总监',
|
|
employeeId: 'SMDF-0018',
|
|
department: '医疗器械研发事业部',
|
|
position: '微创器械研发总监',
|
|
roleId: 'role-director',
|
|
projectPermissions: {
|
|
'doc-001': 'approver',
|
|
'doc-002': 'approver',
|
|
'doc-003': 'author',
|
|
'doc-004': 'approver',
|
|
'doc-shared-001': 'approver',
|
|
},
|
|
},
|
|
{
|
|
id: 4,
|
|
name: '赵管理员',
|
|
employeeId: 'SMDF-0001',
|
|
department: '系统安全与技术委员会',
|
|
position: '系统全域管理总管',
|
|
roleId: 'role-admin',
|
|
projectPermissions: {
|
|
'doc-001': 'approver',
|
|
'doc-002': 'approver',
|
|
'doc-003': 'approver',
|
|
},
|
|
},
|
|
{
|
|
id: 5,
|
|
name: '钱审计',
|
|
employeeId: 'SMDF-9901',
|
|
department: '第三方合规审计与认证机构',
|
|
position: '外部合规审查专员',
|
|
roleId: 'role-guest',
|
|
projectPermissions: {},
|
|
},
|
|
];
|
|
|
|
export default function App() {
|
|
// 会话与用户状态 (支持 localStorage 刷新持久化)
|
|
const [sessions, setSessions] = useState<ProjectSession[]>(() => loadSessions());
|
|
const [activeSessionId, setActiveSessionId] = useState<string>(() => loadActiveSessionId('sess-001'));
|
|
|
|
// 自动持久化 sessions 与 activeSessionId
|
|
useEffect(() => {
|
|
saveSessions(sessions);
|
|
}, [sessions]);
|
|
|
|
useEffect(() => {
|
|
saveActiveSessionId(activeSessionId);
|
|
}, [activeSessionId]);
|
|
|
|
// 全局角色库与人员名单状态 (支持动态增删改与持久化)
|
|
const [roles, setRoles] = useState<RoleDefinition[]>(() => loadRoles(DEFAULT_ROLES));
|
|
const [allUsers, setAllUsers] = useState<UserProfile[]>(() => loadUsers(MOCK_USERS));
|
|
|
|
// 自动持久化 roles 与 allUsers
|
|
useEffect(() => {
|
|
saveRoles(roles);
|
|
}, [roles]);
|
|
|
|
useEffect(() => {
|
|
saveUsers(allUsers);
|
|
}, [allUsers]);
|
|
|
|
// 当前用户身份 (职位在全公司唯一不变,绑定全局角色,但在不同文档有不同法定职责)
|
|
const [currentUser, setCurrentUser] = useState<UserProfile>(() => allUsers[0] || MOCK_USERS[0]);
|
|
|
|
// 全生命周期完整交付物大盘清单 (含工作流受控、项目协作共享、个人私密生成三大分类,支持持久化)
|
|
const [deliverables, setDeliverables] = useState<DeliverableItem[]>(() =>
|
|
loadDeliverables(ALL_INITIAL_DELIVERABLES)
|
|
);
|
|
|
|
// 自动持久化 deliverables
|
|
useEffect(() => {
|
|
saveDeliverables(deliverables);
|
|
}, [deliverables]);
|
|
|
|
const [activeCategory, setActiveCategory] = useState<DeliverableCategory>('workflow');
|
|
const [activeDocId, setActiveDocId] = useState('doc-001');
|
|
const [deckDrawerOpen, setDeckDrawerOpen] = useState(false);
|
|
|
|
// 认证与会话状态
|
|
const [session, setSession] = useState<LoginResult | null>(() => authClient.loadSavedSession());
|
|
const [systemEnv, setSystemEnv] = useState<string>('dev');
|
|
const [systemAuthConfig, setSystemAuthConfig] = useState<SystemAuthConfig>({
|
|
allowRegistration: true,
|
|
requireApproval: false,
|
|
});
|
|
|
|
// 模态浮层控制
|
|
const [rightsModalOpen, setRightsModalOpen] = useState(false);
|
|
const [userSettingsOpen, setUserSettingsOpen] = useState(false);
|
|
const [adminSettingsOpen, setAdminSettingsOpen] = useState(false);
|
|
const [docVisibilityModalOpen, setDocVisibilityModalOpen] = useState(false);
|
|
const [targetDocForVisibility, setTargetDocForVisibility] = useState<DeliverableItem | null>(null);
|
|
|
|
useEffect(() => {
|
|
authClient.getSystemAuthConfig().then(setSystemAuthConfig).catch(() => {});
|
|
const saved = authClient.loadSavedSession();
|
|
if (saved) {
|
|
setSession(saved);
|
|
if (saved.env) setSystemEnv(saved.env);
|
|
const match = allUsers.find((u) => u.id === saved.user.id);
|
|
if (match) {
|
|
setCurrentUser(match);
|
|
} else {
|
|
setCurrentUser(saved.user);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const handleLogout = () => {
|
|
authClient.logout();
|
|
setSession(null);
|
|
};
|
|
|
|
// 核心功能权限计算
|
|
const canCreateProject = useMemo(() => {
|
|
return hasPermission(currentUser, roles, 'project:create');
|
|
}, [currentUser, roles]);
|
|
|
|
const canAssignLead = useMemo(() => {
|
|
return hasPermission(currentUser, roles, 'project:assign_lead');
|
|
}, [currentUser, roles]);
|
|
|
|
const canOverrideVisibility = useMemo(() => {
|
|
return (
|
|
hasPermission(currentUser, roles, 'doc:override_visibility') ||
|
|
hasPermission(currentUser, roles, 'perm:manage')
|
|
);
|
|
}, [currentUser, roles]);
|
|
|
|
// 获取当前激活会话的项目ID (默认为 1)
|
|
const currentProjectId = useMemo(() => {
|
|
const activeSess = sessions.find((s) => s.sessionId === activeSessionId);
|
|
return activeSess ? activeSess.projectId : 1;
|
|
}, [sessions, activeSessionId]);
|
|
|
|
// 严格按 projectId 隔离当前项目的交付物
|
|
const currentProjectDeliverables = useMemo(() => {
|
|
return deliverables.filter((doc) => doc.projectId === currentProjectId);
|
|
}, [deliverables, currentProjectId]);
|
|
|
|
// 基于当前登录用户与 RBAC 规则过滤可见交付物 (私密文档严格按 creatorId 隔离,单文档覆盖优先于工作流基线)
|
|
const visibleDeliverables = useMemo(() => {
|
|
return filterVisibleDeliverablesWithRBAC(currentProjectDeliverables, currentUser, roles);
|
|
}, [currentProjectDeliverables, currentUser, roles]);
|
|
|
|
// 单文档可见性覆盖保存处理
|
|
const handleSaveDocVisibilityRule = (docId: string, rule: DocVisibilityRule | undefined) => {
|
|
setDeliverables((prev) =>
|
|
prev.map((d) => (d.docId === docId ? { ...d, visibilityRule: rule } : d))
|
|
);
|
|
setDocCache((prev) => {
|
|
if (!prev[docId]) return prev;
|
|
return {
|
|
...prev,
|
|
[docId]: {
|
|
...prev[docId],
|
|
visibilityRule: rule,
|
|
},
|
|
};
|
|
});
|
|
};
|
|
|
|
// 当用户切换或可见列表变动时,自动校准激活文档
|
|
useEffect(() => {
|
|
const isCurrentVisible = visibleDeliverables.some((d) => d.docId === activeDocId);
|
|
if (!isCurrentVisible) {
|
|
const fallback =
|
|
visibleDeliverables.find((d) => d.category === activeCategory) ||
|
|
visibleDeliverables[0];
|
|
if (fallback) {
|
|
setActiveDocId(fallback.docId);
|
|
setActiveCategory(fallback.category);
|
|
}
|
|
}
|
|
}, [currentUser.id, visibleDeliverables, activeDocId, activeCategory]);
|
|
|
|
// 文档缓存与当前 AST
|
|
const [docCache, setDocCache] = useState<Record<string, DocumentAST>>(() => {
|
|
const initialCache: Record<string, DocumentAST> = { ...P2_INITIAL.docASTMap };
|
|
Object.entries(MOCK_DOCUMENTS).forEach(([id, meta]) => {
|
|
initialCache[id] = markdownToBlockAST(meta.markdown, {
|
|
docId: meta.docId,
|
|
title: meta.title,
|
|
taskKey: meta.taskKey,
|
|
version: meta.version,
|
|
category: meta.category,
|
|
creatorId: meta.creatorId,
|
|
creatorName: meta.creatorName,
|
|
sharedAt: meta.sharedAt,
|
|
lifecycleState: meta.lifecycleState,
|
|
isClaimed: meta.isClaimed,
|
|
author: meta.author,
|
|
reviewer: meta.reviewer,
|
|
approver: meta.approver,
|
|
});
|
|
});
|
|
return initialCache;
|
|
});
|
|
|
|
const docAST =
|
|
docCache[activeDocId] ||
|
|
(visibleDeliverables[0] && docCache[visibleDeliverables[0].docId]) ||
|
|
docCache['doc-001'];
|
|
|
|
// 根据当前激活文档与用户权限矩阵,计算当前生效角色
|
|
const currentDocRole: UserRole = (() => {
|
|
const perm = currentUser.projectPermissions?.[activeDocId];
|
|
if (perm === 'author' || perm === 'reviewer' || perm === 'approver') {
|
|
return perm;
|
|
}
|
|
// 降级回退
|
|
if (docAST.author === currentUser.name) return 'author';
|
|
if (docAST.reviewer === currentUser.name) return 'reviewer';
|
|
if (docAST.approver === currentUser.name) return 'approver';
|
|
return 'author'; // 默认
|
|
})();
|
|
|
|
const setDocAST = (newAst: DocumentAST) => {
|
|
setDocCache((prev) => ({
|
|
...prev,
|
|
[activeDocId]: newAst,
|
|
}));
|
|
};
|
|
|
|
// 切换文档
|
|
const handleSelectDoc = (id: string) => {
|
|
setActiveDocId(id);
|
|
const targetDoc = deliverables.find((d) => d.docId === id);
|
|
if (targetDoc) {
|
|
setActiveCategory(targetDoc.category);
|
|
}
|
|
if (!docCache[id] && MOCK_DOCUMENTS[id]) {
|
|
const meta = MOCK_DOCUMENTS[id];
|
|
const parsed = markdownToBlockAST(meta.markdown, {
|
|
docId: meta.docId,
|
|
title: meta.title,
|
|
taskKey: meta.taskKey,
|
|
version: meta.version,
|
|
category: meta.category,
|
|
creatorId: meta.creatorId,
|
|
creatorName: meta.creatorName,
|
|
sharedAt: meta.sharedAt,
|
|
lifecycleState: meta.lifecycleState,
|
|
isClaimed: meta.isClaimed,
|
|
author: meta.author,
|
|
reviewer: meta.reviewer,
|
|
approver: meta.approver,
|
|
});
|
|
setDocCache((prev) => ({ ...prev, [id]: parsed }));
|
|
}
|
|
};
|
|
|
|
// 一键将个人私密文档共享至项目协作参考库
|
|
const handleShareToProject = (docId: string) => {
|
|
const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16);
|
|
setDeliverables((prev) =>
|
|
prev.map((d) => (d.docId === docId ? { ...d, category: 'shared', sharedAt: nowStr } : d))
|
|
);
|
|
setDocCache((prev) => {
|
|
if (!prev[docId]) return prev;
|
|
return {
|
|
...prev,
|
|
[docId]: { ...prev[docId], category: 'shared', sharedAt: nowStr },
|
|
};
|
|
});
|
|
setActiveCategory('shared');
|
|
const targetDoc = deliverables.find((d) => d.docId === docId);
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: 'ast-share-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'assistant',
|
|
authorName: '水木东方协同参谋',
|
|
intentTag: '🌐 一键共享至项目参考库',
|
|
content: `已为您将私密推演文档《${targetDoc?.title || '文档'}》一键共享至【项目协作参考库】!项目全员均可作为方案参考进行查阅与批注。您作为创建者,可随时点击「取消共享」恢复为个人私密状态。`,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
},
|
|
]);
|
|
};
|
|
|
|
// 取消共享 (恢复为个人私有草稿)
|
|
const handleRevokeShare = (docId: string) => {
|
|
setDeliverables((prev) =>
|
|
prev.map((d) => (d.docId === docId ? { ...d, category: 'private', sharedAt: undefined } : d))
|
|
);
|
|
setDocCache((prev) => {
|
|
if (!prev[docId]) return prev;
|
|
return {
|
|
...prev,
|
|
[docId]: { ...prev[docId], category: 'private', sharedAt: undefined },
|
|
};
|
|
});
|
|
setActiveCategory('private');
|
|
const targetDoc = deliverables.find((d) => d.docId === docId);
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: 'ast-revoke-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'assistant',
|
|
authorName: '水木东方协同参谋',
|
|
intentTag: '🔒 撤回共享至个人私密',
|
|
content: `已撤回共享!《${targetDoc?.title || '文档'}》已从项目协作库移除并恢复为您的【个人私密文档】,其他团队成员在界面与大盘中已不可见。`,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
},
|
|
]);
|
|
};
|
|
|
|
// 删除私密文档
|
|
const handleDeleteDoc = (docId: string) => {
|
|
setDeliverables((prev) => prev.filter((d) => d.docId !== docId));
|
|
if (activeDocId === docId) {
|
|
setActiveDocId('doc-001');
|
|
setActiveCategory('workflow');
|
|
}
|
|
};
|
|
|
|
// 模态弹窗与侧栏
|
|
const [editingBlock, setEditingBlock] = useState<DocumentBlock | null>(null);
|
|
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
|
|
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(null);
|
|
const [pendingProposal, setPendingProposal] = useState<{
|
|
changes: string;
|
|
impact: string;
|
|
risk: string;
|
|
patchBlockId?: string;
|
|
} | null>(null);
|
|
|
|
// 消息流与思考脉冲 (支持 localStorage 持久化与项目会话严格隔离)
|
|
const [isThinking, setIsThinking] = useState(false);
|
|
const [messages, setMessages] = useState<ChatMessage[]>(() => loadMessages());
|
|
|
|
// 自动持久化消息流
|
|
useEffect(() => {
|
|
saveMessages(messages);
|
|
}, [messages]);
|
|
|
|
// 基于当前激活的项目会话严格隔离对话记录
|
|
const currentSessionMessages = useMemo(() => {
|
|
return messages.filter((m) => m.sessionId === activeSessionId);
|
|
}, [messages, activeSessionId]);
|
|
|
|
// 处理交互卡片动作流转 (驳回门禁确认、撤回、取消与终审联动)
|
|
const handleCardAction = (cardId: string, actionId: string, payload?: any) => {
|
|
// 查找对应的卡片
|
|
let targetCard: InteractiveCardData | undefined;
|
|
for (const m of messages) {
|
|
if (m.interactiveCard && m.interactiveCard.cardId === cardId) {
|
|
targetCard = m.interactiveCard;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const targetDocId =
|
|
payload?.targetDocId || payload?.docId || targetCard?.targetDocId || activeDocId;
|
|
const reason =
|
|
payload?.reason || '关键竞品性能指标与电气安全指标待补充,退回编制人重新修订。';
|
|
|
|
if (actionId === 'confirm_reject') {
|
|
// 1. 调用既有文档驳回逻辑 (将目标文档生命周期由 reviewing 回退为 drafting)
|
|
setDeliverables((prev) =>
|
|
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'drafting' } : d))
|
|
);
|
|
|
|
// 2. 扫描目标文档 AST,在匹配块上追加 type: 'issue' 批注
|
|
let targetBlockId: string | null = null;
|
|
let targetSectionName = '第3节';
|
|
setDocCache((prev) => {
|
|
let currentDoc = prev[targetDocId];
|
|
if (!currentDoc && MOCK_DOCUMENTS[targetDocId]) {
|
|
const meta = MOCK_DOCUMENTS[targetDocId];
|
|
currentDoc = markdownToBlockAST(meta.markdown, {
|
|
docId: meta.docId,
|
|
title: meta.title,
|
|
taskKey: meta.taskKey,
|
|
version: meta.version,
|
|
category: meta.category,
|
|
creatorId: meta.creatorId,
|
|
creatorName: meta.creatorName,
|
|
sharedAt: meta.sharedAt,
|
|
lifecycleState: meta.lifecycleState,
|
|
isClaimed: meta.isClaimed,
|
|
author: meta.author,
|
|
reviewer: meta.reviewer,
|
|
approver: meta.approver,
|
|
});
|
|
}
|
|
if (!currentDoc) return prev;
|
|
|
|
const { updatedDoc, matchingBlock } = applyConfirmRejectToDoc(
|
|
currentDoc,
|
|
cardId,
|
|
currentUser.name,
|
|
reason,
|
|
targetCard?.sectionTitle || payload?.targetSection
|
|
);
|
|
targetBlockId = matchingBlock.id;
|
|
if (matchingBlock.numbering) {
|
|
targetSectionName = `第${matchingBlock.numbering.replace('.', '')}节`;
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
[targetDocId]: updatedDoc,
|
|
};
|
|
});
|
|
|
|
// 3. 更新卡片状态为 resolved
|
|
const sectionLabel =
|
|
targetCard?.sectionTitle?.match(/第[0-9一二三四五六七八九]+节/)?.[0] || targetSectionName || '第3节';
|
|
const resolutionSummary = `已正式退回编制阶段,批注已挂载至${sectionLabel}`;
|
|
|
|
setMessages((prev) => {
|
|
const updatedMessages = resolveCardInMessages(
|
|
prev,
|
|
cardId,
|
|
resolutionSummary,
|
|
currentUser.name
|
|
);
|
|
return [
|
|
...updatedMessages,
|
|
{
|
|
id: 'sys-rej-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'system',
|
|
content: `⚠️ ${currentUser.name}(审核负责人)驳回了《${targetCard?.targetDocTitle || targetDocId}》,已退回编制阶段。驳回原因:${reason}`,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
},
|
|
];
|
|
});
|
|
|
|
// 4. 切换当前右侧激活文档为目标文档并高亮匹配块
|
|
setActiveDocId(targetDocId);
|
|
const targetDeliverable = deliverables.find((d) => d.docId === targetDocId);
|
|
if (targetDeliverable) {
|
|
setActiveCategory(targetDeliverable.category);
|
|
}
|
|
if (targetBlockId) {
|
|
setHighlightedBlockId(targetBlockId);
|
|
setTimeout(() => {
|
|
setHighlightedBlockId(null);
|
|
}, 2500);
|
|
}
|
|
} else if (actionId === 'cancel') {
|
|
// 标记卡片为 cancelled 并追加说明
|
|
setMessages((prev) => [
|
|
...cancelCardInMessages(prev, cardId),
|
|
{
|
|
id: 'ast-cancel-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'assistant',
|
|
authorName: '水木东方协同参谋',
|
|
intentTag: '🚫 取消操作',
|
|
content: '已取消驳回操作,文档保持待审核状态。',
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
},
|
|
]);
|
|
} else if (actionId === 'undo') {
|
|
// 撤销驳回:恢复为待审核状态 (reviewing)
|
|
setDeliverables((prev) =>
|
|
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'reviewing' } : d))
|
|
);
|
|
|
|
// 移除注入的批注并恢复 reviewing 状态
|
|
setDocCache((prev) => {
|
|
const currentDoc = prev[targetDocId];
|
|
if (!currentDoc) return prev;
|
|
const updatedDoc = applyUndoRejectToDoc(currentDoc, cardId);
|
|
return {
|
|
...prev,
|
|
[targetDocId]: updatedDoc,
|
|
};
|
|
});
|
|
|
|
// 标记卡片为 cancelled 并追加说明
|
|
setMessages((prev) => [
|
|
...cancelCardInMessages(prev, cardId),
|
|
{
|
|
id: 'ast-undo-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'assistant',
|
|
authorName: '水木东方协同参谋',
|
|
intentTag: '↩️ 撤回驳回',
|
|
content: '已在合法窗口期撤回驳回,文档已恢复为待审核状态。',
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
},
|
|
]);
|
|
} else if (actionId === 'confirm_final_approve') {
|
|
// 终审批准
|
|
setDeliverables((prev) =>
|
|
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'approved' } : d))
|
|
);
|
|
setDocCache((prev) => {
|
|
const currentDoc = prev[targetDocId];
|
|
if (!currentDoc) return prev;
|
|
return {
|
|
...prev,
|
|
[targetDocId]: { ...currentDoc, lifecycleState: 'approved' as const },
|
|
};
|
|
});
|
|
|
|
setMessages((prev) => [
|
|
...resolveCardInMessages(
|
|
prev,
|
|
cardId,
|
|
'已终审批准生效',
|
|
currentUser.name
|
|
),
|
|
{
|
|
id: 'appr-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'assistant',
|
|
authorName: '水木东方协同参谋',
|
|
intentTag: '🏆 终审批准生效',
|
|
content: `🎉 祝贺!${currentUser.name}(批准人)已终审批准《${targetCard?.targetDocTitle || targetDocId}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
recommendedCases: [
|
|
{
|
|
docId: targetDocId,
|
|
taskKey: targetCard?.targetDocId || 'research',
|
|
artifactTitle: targetCard?.targetDocTitle || '受控交付物',
|
|
sectionTitle: '终审已生效标杆案例',
|
|
similarityScore: 1.0,
|
|
keyRegulations: ['GB 9706.1-2020', 'YY 0569-2011', 'IEC 60601-1-2'],
|
|
summarySnippet: '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
}
|
|
};
|
|
|
|
// 发送消息与对话驱动双轨逻辑
|
|
const handleSendMessage = (text: string, attachments?: { name: string; size: string; type: string }[]) => {
|
|
const trimmed = text.trim();
|
|
const userMsg: ChatMessage = {
|
|
id: 'msg-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'user',
|
|
authorName: currentUser.name,
|
|
content: text,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
attachments,
|
|
};
|
|
|
|
// 检查是否为肯定回复(双轨同效判定:打字回复等同于点击卡片默认动作)
|
|
const isAffirmative = isAffirmativeResponse(trimmed);
|
|
const lastActive = findLastActiveCard(messages, activeSessionId);
|
|
|
|
if (isAffirmative && lastActive) {
|
|
const { card } = lastActive;
|
|
const defaultAction = card.actions.find((a) => a.isAffirmativeDefault) || card.actions[0];
|
|
if (defaultAction) {
|
|
setMessages((prev) => [...prev, userMsg]);
|
|
handleCardAction(card.cardId, defaultAction.actionId, defaultAction.payload);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 递减消息列表中尚未完结的 active 卡片 ttlTurns,若 <= 0 则标记为 expired
|
|
setMessages((prev) => [...decrementActiveCardsTtl(prev, activeSessionId), userMsg]);
|
|
setIsThinking(true);
|
|
|
|
setTimeout(() => {
|
|
setIsThinking(false);
|
|
|
|
// 1. 用户要求助手生成新文档/测算/推演/备忘 (默认自动归档至【我的私密生成】,保护隐私)
|
|
if (
|
|
text.includes('生成文档') ||
|
|
text.includes('起草文档') ||
|
|
text.includes('测算') ||
|
|
text.includes('推演') ||
|
|
text.includes('备忘') ||
|
|
text.includes('生成报告')
|
|
) {
|
|
const newDocId = `doc-private-${Date.now()}`;
|
|
const docTitle = text.includes('备忘')
|
|
? `${currentUser.name}专属:临床与商业推演备忘录`
|
|
: text.includes('测算')
|
|
? `${currentUser.name}专属:BOM器件成本与毛利灵敏度测算`
|
|
: `${currentUser.name}专属:AI协同专项推演草案`;
|
|
|
|
const newMeta: DeliverableItem = {
|
|
docId: newDocId,
|
|
projectId: sessions.find((s) => s.sessionId === activeSessionId)?.projectId || 1,
|
|
taskKey: 'custom_draft',
|
|
title: docTitle,
|
|
stage: 'V0.5',
|
|
category: 'private',
|
|
creatorId: currentUser.id,
|
|
creatorName: currentUser.name,
|
|
isFixedWorkflow: false,
|
|
isTemplateOnly: false,
|
|
lifecycleState: 'drafting',
|
|
isClaimed: true,
|
|
author: currentUser.name,
|
|
reviewer: '李主管',
|
|
approver: '王总监',
|
|
updatedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
|
|
summary: `根据对话中您的需求“${text.slice(0, 60)}...”即时生成的个人私密草稿。`,
|
|
};
|
|
|
|
const sampleMd = `# ${docTitle}\n\n## 1. 产生背景与对话诉求\n根据用户【${currentUser.name}】于 ${new Date().toLocaleTimeString()} 提出的协同要求:“${text}”,参谋小队即时推演生成本专属文档。\n\n> 🔒 [隐私受控标记] 本文档为用户个人私密生成物,仅 ${currentUser.name} 本人可见,不进入项目固定受控审签体系。若推演成熟,可随时点击下方「一键共享给项目组」转为协作参考资料。\n\n## 2. 核心分析与推演结论\n依据系统知识库与工程模型,对关键指标与风险进行分解:\n- 技术可行性评级:A 级(具备清晰工程落地路径)\n- 建议跟进措施:优化元器件批量采购配额,规避长周期交货风险。\n`;
|
|
|
|
const parsedAst = markdownToBlockAST(sampleMd, {
|
|
docId: newDocId,
|
|
title: docTitle,
|
|
taskKey: 'custom_draft',
|
|
version: 'V0.1',
|
|
category: 'private',
|
|
creatorId: currentUser.id,
|
|
creatorName: currentUser.name,
|
|
lifecycleState: 'drafting',
|
|
isClaimed: true,
|
|
author: currentUser.name,
|
|
});
|
|
|
|
setDeliverables((prev) => [newMeta, ...prev]);
|
|
setDocCache((prev) => ({ ...prev, [newDocId]: parsedAst }));
|
|
setActiveCategory('private');
|
|
setActiveDocId(newDocId);
|
|
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: 'ast-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'assistant',
|
|
authorName: '水木东方协同参谋',
|
|
intentTag: '🔒 个人私密文档已生成',
|
|
content: `收到您的要求,已为您即时生成专属推演文档《${docTitle}》!\n\n根据系统隐私保护机制,该文档已自动存入【我的私密】库,**仅您本人可见**。右侧工作台已为您自动切至该文档,您可以自由查看与编辑。推演成熟后,可随时点击底部「一键共享给项目组」作为团队协作参考资料。`,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
|
|
// 2. 用户发送驳回意图 (且存在处于 reviewing 的目标交付物)
|
|
if (text.includes('驳回') || text.includes('退回')) {
|
|
const targetReviewDoc =
|
|
docAST.lifecycleState === 'reviewing'
|
|
? docAST
|
|
: deliverables.find((d) => d.lifecycleState === 'reviewing')
|
|
? (docCache[deliverables.find((d) => d.lifecycleState === 'reviewing')!.docId] || docAST)
|
|
: null;
|
|
|
|
if (targetReviewDoc && targetReviewDoc.lifecycleState === 'reviewing') {
|
|
const matchingBlock = findMatchingBlockForReview(targetReviewDoc);
|
|
const card = createRejectConfirmationCard(targetReviewDoc, matchingBlock);
|
|
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: 'ast-reject-gate-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'assistant',
|
|
authorName: '水木东方协同参谋',
|
|
intentTag: '🛡️ 审核驳回门禁',
|
|
content: `检测到您对《${targetReviewDoc.title}》提出驳回审核意图。驳回将退回编制阶段并解除锁定,同时向编制人下达整改批注。请确认操作:`,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
interactiveCard: card,
|
|
},
|
|
]);
|
|
return;
|
|
} else {
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: 'ast-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'assistant',
|
|
authorName: '水木东方协同参谋',
|
|
intentTag: '⚠️ 状态校验提示',
|
|
content: `当前受控文档《${docAST.title}》处于【${docAST.lifecycleState}】阶段,非待审核状态,无需执行驳回。`,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (text.includes('修改') || text.includes('更新') || text.includes('调整')) {
|
|
if (docAST.isClaimed && currentDocRole === 'author') {
|
|
setPendingProposal({
|
|
changes: '拟在第2节「监管分类与强制性合规标准矩阵」中,强化补充高频电刀共模抑制比设计指标。',
|
|
impact: '将直接影响电路原理图物料选型与型式检验 EMC 滤波网络参数。',
|
|
risk: '低风险。符合 GB 9706.1-2020 强标,提升系统抗扰度,无侵权风险。',
|
|
});
|
|
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: 'ast-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'assistant',
|
|
authorName: '水木东方协同参谋',
|
|
intentTag: '🛡️ 文档认领保护机制拦截',
|
|
content: `检测到《${docAST.title}》已处于【编制人已认领】状态。根据医疗器械质量体系规范,智能体小队不能擅自修改,已为您生成【变更方案提议】。请您确认是否准许本次修改。`,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (text.includes('通过') || text.includes('同意')) {
|
|
if (docAST.lifecycleState === 'reviewing' && currentDocRole === 'reviewer') {
|
|
handlePassReview();
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: 'ast-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'assistant',
|
|
authorName: '水木东方协同参谋',
|
|
intentTag: '📋 审核通过流转',
|
|
content: `已确认审核通过!《${docAST.title}》状态已流转至【待批准 (approving)】,已呈报王总监终审。`,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
if (docAST.lifecycleState === 'approving' && currentDocRole === 'approver') {
|
|
handleApprove();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 3. 检查当前项目中是否存在待填报的模板副本
|
|
const matchedTemplate = currentProjectDeliverables.find((d) => {
|
|
if (!d.isTemplateOnly) return false;
|
|
if (text.includes('需求') || text.includes('摸底') || text.includes('用户')) {
|
|
return d.taskKey === 'intake' || d.taskKey === 'user_requirements';
|
|
}
|
|
if (text.includes('调研') || text.includes('竞品')) {
|
|
return d.taskKey === 'research';
|
|
}
|
|
if (text.includes('可行性') || text.includes('注册') || text.includes('报告')) {
|
|
return d.taskKey === 'feasibility_reg' || d.taskKey === 'feasibility_dev';
|
|
}
|
|
if (text.includes('设计') || text.includes('规格') || text.includes('架构')) {
|
|
return d.taskKey === 'prelim_design';
|
|
}
|
|
if (text.includes('报价') || text.includes('工时') || text.includes('预算')) {
|
|
return d.taskKey === 'cdo_quote';
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (matchedTemplate) {
|
|
const existingAst = docCache[matchedTemplate.docId];
|
|
const updatedAst: DocumentAST = {
|
|
...existingAst,
|
|
lifecycleState: 'drafting',
|
|
isClaimed: true,
|
|
summary: `智能体参谋小队已依据您的指令“${text.slice(0, 40)}...”完成深度填报与法规对齐。`,
|
|
updatedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
|
|
};
|
|
|
|
if (updatedAst.rootBlock && updatedAst.rootBlock.children) {
|
|
const firstP = updatedAst.rootBlock.children.find((b) => b.type === 'paragraph');
|
|
if (firstP) {
|
|
firstP.content += `\n\n> 🤖 **智能体填报生效**:参谋小队已结合当前项目基线与权威法规知识库完成编制,骨架模板已平滑升级为受控草稿。`;
|
|
}
|
|
}
|
|
|
|
setDeliverables((prev) =>
|
|
prev.map((d) =>
|
|
d.docId === matchedTemplate.docId
|
|
? {
|
|
...d,
|
|
isTemplateOnly: false,
|
|
lifecycleState: 'drafting',
|
|
isClaimed: true,
|
|
summary: updatedAst.summary,
|
|
updatedAt: '刚刚',
|
|
}
|
|
: d
|
|
)
|
|
);
|
|
setDocCache((prev) => ({ ...prev, [matchedTemplate.docId]: updatedAst }));
|
|
setActiveDocId(matchedTemplate.docId);
|
|
setActiveCategory('workflow');
|
|
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: 'ast-fill-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'assistant',
|
|
authorName: '水木东方协同参谋',
|
|
intentTag: '🎯 模板填报升级',
|
|
content: `收到!参谋小队已依据您的指令完成《${matchedTemplate.title}》的填报。\n\n该文档已成功从【📄 初始模板】平滑升格为【✍️ 编制中 (受控草稿)】,右侧工作台已为您实时呈现填报正文,请审阅。`,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: 'ast-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'assistant',
|
|
authorName: '水木东方协同参谋',
|
|
intentTag: '💡 协同参谋回复',
|
|
content: `收到您的要求:“${text}”。参谋小队已分析当前处于【${docAST.lifecycleState}】生命周期,右侧已实时同步最新受控文档结构。`,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
},
|
|
]);
|
|
}, 1000);
|
|
};
|
|
|
|
// 编制人接受变更提议
|
|
const handleAcceptProposal = () => {
|
|
setPendingProposal(null);
|
|
const children = docAST.rootBlock.children || [];
|
|
const pBlock = children.find((c) => c.type === 'paragraph');
|
|
if (pBlock) {
|
|
pBlock.content += '\n\n【工程师确认增补】前端光路采集板已加装定制高频共模扼流圈,强标抗扰度提升至最高等级。';
|
|
pBlock.metadata = {
|
|
lastModifiedBy: '智能体协同小队 (经张工确认签署)',
|
|
lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
|
|
};
|
|
setDocAST({ ...docAST });
|
|
}
|
|
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: 'ast-p-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'assistant',
|
|
authorName: '水木东方协同参谋',
|
|
intentTag: '✅ 协同变更已生效',
|
|
content: `变更提议已成功执行并落盘至《${docAST.title}》,受控文档已同步更新。`,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
},
|
|
]);
|
|
};
|
|
|
|
// 块操作
|
|
const handleSaveBlockEdit = (updated: DocumentBlock) => {
|
|
const children = docAST.rootBlock.children || [];
|
|
const idx = children.findIndex((c) => c.id === updated.id);
|
|
if (idx !== -1) {
|
|
children[idx] = updated;
|
|
recomputeNumbering(docAST.rootBlock);
|
|
setDocAST({ ...docAST });
|
|
}
|
|
setEditingBlock(null);
|
|
};
|
|
|
|
const handleInsertBlock = (index: number, position: 'above' | 'below') => {
|
|
const newBlock: DocumentBlock = {
|
|
id: 'blk_' + Math.random().toString(36).substring(2, 9),
|
|
type: 'paragraph',
|
|
content: '【新插入技术要求】:在此填写具体受控技术规范或临床评定指标...',
|
|
metadata: {
|
|
lastModifiedBy: `${currentUser.name} (${currentUser.position})`,
|
|
lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
|
|
},
|
|
};
|
|
const children = docAST.rootBlock.children || [];
|
|
const insertAt = position === 'above' ? index : index + 1;
|
|
children.splice(insertAt, 0, newBlock);
|
|
recomputeNumbering(docAST.rootBlock);
|
|
setDocAST({ ...docAST });
|
|
};
|
|
|
|
const handleMoveBlock = (index: number, direction: 'up' | 'down') => {
|
|
const children = docAST.rootBlock.children || [];
|
|
const targetIdx = direction === 'up' ? index - 1 : index + 1;
|
|
if (targetIdx >= 0 && targetIdx < children.length) {
|
|
const temp = children[index];
|
|
children[index] = children[targetIdx];
|
|
children[targetIdx] = temp;
|
|
recomputeNumbering(docAST.rootBlock);
|
|
setDocAST({ ...docAST });
|
|
}
|
|
};
|
|
|
|
const handleDeleteBlock = (index: number) => {
|
|
const children = docAST.rootBlock.children || [];
|
|
children.splice(index, 1);
|
|
recomputeNumbering(docAST.rootBlock);
|
|
setDocAST({ ...docAST });
|
|
};
|
|
|
|
// 批注操作
|
|
const handleAddComment = (comment: Omit<BlockComment, 'id' | 'createdAt'>) => {
|
|
if (!commentingBlock) return;
|
|
const newCmt: BlockComment = {
|
|
...comment,
|
|
id: 'cmt_' + Math.random().toString(36).substring(2, 8),
|
|
createdAt: new Date().toTimeString().substring(0, 5),
|
|
};
|
|
commentingBlock.comments = [...(commentingBlock.comments || []), newCmt];
|
|
setDocAST({ ...docAST });
|
|
};
|
|
|
|
// 法定生命周期流转
|
|
const handleClaim = () => {
|
|
docAST.isClaimed = true;
|
|
setDocAST({ ...docAST });
|
|
setDeliverables((prev) =>
|
|
prev.map((d) => (d.docId === activeDocId ? { ...d, isClaimed: true } : d))
|
|
);
|
|
};
|
|
|
|
const handleSubmitReview = () => {
|
|
docAST.lifecycleState = 'reviewing';
|
|
setDocAST({ ...docAST });
|
|
setDeliverables((prev) =>
|
|
prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'reviewing' } : d))
|
|
);
|
|
};
|
|
|
|
const handlePassReview = () => {
|
|
docAST.lifecycleState = 'approving';
|
|
setDocAST({ ...docAST });
|
|
setDeliverables((prev) =>
|
|
prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'approving' } : d))
|
|
);
|
|
};
|
|
|
|
const handleRejectReview = (reason: string) => {
|
|
docAST.lifecycleState = 'drafting';
|
|
setDocAST({ ...docAST });
|
|
setDeliverables((prev) =>
|
|
prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'drafting' } : d))
|
|
);
|
|
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: 'rej-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'system',
|
|
content: `⚠️ 李主管(审核负责人)驳回了《${docAST.title}》,已退回编制阶段。驳回原因:${reason}`,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
},
|
|
]);
|
|
};
|
|
|
|
const handleApprove = () => {
|
|
docAST.lifecycleState = 'approved';
|
|
setDocAST({ ...docAST });
|
|
setDeliverables((prev) =>
|
|
prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'approved' } : d))
|
|
);
|
|
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: 'appr-' + Date.now(),
|
|
sessionId: activeSessionId,
|
|
role: 'assistant',
|
|
authorName: '水木东方协同参谋',
|
|
intentTag: '🏆 终审批准生效',
|
|
content: `🎉 祝贺!王总监(批准人)已终审批准《${docAST.title}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`,
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
recommendedCases: [
|
|
{
|
|
docId: docAST.docId,
|
|
taskKey: docAST.taskKey,
|
|
artifactTitle: docAST.title,
|
|
sectionTitle: '终审已生效标杆案例',
|
|
similarityScore: 1.0,
|
|
keyRegulations: ['GB 9706.1-2020', 'YY 0569-2011', 'IEC 60601-1-2'],
|
|
summarySnippet: '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
};
|
|
|
|
// 导出 DOCX (Word 渲染器)
|
|
const handleExportDocx = async () => {
|
|
try {
|
|
await exportDocumentToDocx(docAST);
|
|
} catch (err) {
|
|
console.error('导出 DOCX 失败:', err);
|
|
}
|
|
};
|
|
|
|
// 导出 XLSX (Excel 渲染器)
|
|
const handleExportXlsx = async () => {
|
|
try {
|
|
await exportDocumentToXlsx(docAST);
|
|
} catch (err) {
|
|
console.error('导出 XLSX 失败:', err);
|
|
}
|
|
};
|
|
|
|
// 导出 Markdown 原文
|
|
const handleExportMarkdown = () => {
|
|
const md = blockASTToMarkdown(docAST);
|
|
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${docAST.title}_${docAST.version}_受控交付物.md`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
// 测试演练:动态修改当前用户在某个文档的权限角色
|
|
const handleRoleChangeForDoc = (docId: string, role: DocPermissionRole) => {
|
|
setCurrentUser((prev) => ({
|
|
...prev,
|
|
projectPermissions: {
|
|
...(prev.projectPermissions || {}),
|
|
[docId]: role,
|
|
},
|
|
}));
|
|
};
|
|
|
|
// 未登录拦截门禁:展示现代化医疗合规登录与注册页面
|
|
if (!session) {
|
|
return (
|
|
<LoginView
|
|
onLoginSuccess={(newSession) => {
|
|
setSession(newSession);
|
|
if (newSession.env) setSystemEnv(newSession.env);
|
|
const match = allUsers.find((u) => u.id === newSession.user.id);
|
|
if (match) {
|
|
setCurrentUser(match);
|
|
} else {
|
|
setCurrentUser(newSession.user);
|
|
}
|
|
}}
|
|
systemConfig={systemAuthConfig}
|
|
env={systemEnv}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<AppShell
|
|
sessions={sessions}
|
|
activeSessionId={activeSessionId}
|
|
onSelectSession={setActiveSessionId}
|
|
onNewSession={() => {
|
|
const newSessId = 'sess-' + Date.now();
|
|
const newProjId = Date.now();
|
|
const newProjectTitle = '新建医疗器械研发项目';
|
|
const newDeviceType = '通用医疗器械';
|
|
|
|
const newSess: ProjectSession = {
|
|
sessionId: newSessId,
|
|
projectId: newProjId,
|
|
title: newProjectTitle,
|
|
deviceType: newDeviceType,
|
|
stage: 'V0.1',
|
|
stageLabel: 'V0.1 需求摸底',
|
|
messageCount: 1,
|
|
updatedAt: '刚刚',
|
|
};
|
|
|
|
const initMsg: ChatMessage = {
|
|
id: 'm-init-' + Date.now(),
|
|
sessionId: newSessId,
|
|
role: 'system',
|
|
content: '水木东方协同参谋小队已就位,当前新项目进入【V0.1 需求摸底与产品定义】阶段。全套研发工作流与阶段文档模板已克隆就绪,请在右侧查阅或在此输入您的研发构想。',
|
|
timestamp: new Date().toTimeString().substring(0, 5),
|
|
};
|
|
|
|
// 即时克隆全生命周期 7 份受控模板副本
|
|
const { deliverables: clonedDocs, docASTMap: clonedAsts } = createProjectTemplateDeliverables({
|
|
projectId: newProjId,
|
|
projectName: newProjectTitle,
|
|
deviceType: newDeviceType,
|
|
creatorId: currentUser.id,
|
|
creatorName: currentUser.name,
|
|
});
|
|
|
|
setSessions((prev) => [newSess, ...prev]);
|
|
setMessages((prev) => [...prev, initMsg]);
|
|
setDeliverables((prev) => [...prev, ...clonedDocs]);
|
|
setDocCache((prev) => ({ ...prev, ...clonedAsts }));
|
|
setActiveSessionId(newSessId);
|
|
setActiveDocId(clonedDocs[0].docId);
|
|
setActiveCategory('workflow');
|
|
}}
|
|
canCreateProject={canCreateProject}
|
|
currentUser={currentUser}
|
|
currentRole={currentDocRole}
|
|
currentUserName={currentUser.name}
|
|
onChangeRole={(_, __) => {}}
|
|
onOpenRightsModal={() => setRightsModalOpen(true)}
|
|
onOpenUserSettings={() => setUserSettingsOpen(true)}
|
|
onOpenAdminSettings={() => setAdminSettingsOpen(true)}
|
|
chatContent={
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
<MessageStream
|
|
messages={currentSessionMessages}
|
|
onOpenCase={handleSelectDoc}
|
|
onCardAction={handleCardAction}
|
|
/>
|
|
{pendingProposal && (
|
|
<div style={{ padding: '0 16px' }}>
|
|
<ProposalConfirmCard
|
|
proposedChanges={pendingProposal.changes}
|
|
impactAnalysis={pendingProposal.impact}
|
|
potentialRisk={pendingProposal.risk}
|
|
onAccept={handleAcceptProposal}
|
|
onReject={() => setPendingProposal(null)}
|
|
/>
|
|
</div>
|
|
)}
|
|
<InputArea
|
|
onSendMessage={handleSendMessage}
|
|
isThinking={isThinking}
|
|
isStreaming={false}
|
|
/>
|
|
</div>
|
|
}
|
|
docContent={
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', position: 'relative' }}>
|
|
{/* 36px 极简 Tab 条 (含三分类分段切换与私密/共享识别) */}
|
|
<SlimTabBar
|
|
deliverables={visibleDeliverables}
|
|
activeDocId={activeDocId}
|
|
activeCategory={activeCategory}
|
|
onSelectCategory={setActiveCategory}
|
|
onSelectDoc={handleSelectDoc}
|
|
onToggleDeckDrawer={() => setDeckDrawerOpen(!deckDrawerOpen)}
|
|
onExportDocx={handleExportDocx}
|
|
onExportXlsx={handleExportXlsx}
|
|
onExportMarkdown={handleExportMarkdown}
|
|
/>
|
|
|
|
{/* 全景大盘抽屉 (支持受控/共享/私密三板块与一键流转操作) */}
|
|
<DeckDrawer
|
|
isOpen={deckDrawerOpen}
|
|
onClose={() => setDeckDrawerOpen(false)}
|
|
deliverables={visibleDeliverables}
|
|
activeDocId={activeDocId}
|
|
currentUserId={currentUser.id}
|
|
onSelectDoc={handleSelectDoc}
|
|
onShareToProject={handleShareToProject}
|
|
onRevokeShare={handleRevokeShare}
|
|
onDeleteDoc={handleDeleteDoc}
|
|
onOpenDocVisibility={(doc) => {
|
|
setTargetDocForVisibility(doc);
|
|
setDocVisibilityModalOpen(true);
|
|
}}
|
|
/>
|
|
|
|
{/* 块状文档渲染器 */}
|
|
<BlockRenderer
|
|
ast={docAST}
|
|
currentRole={currentDocRole}
|
|
onEditBlock={setEditingBlock}
|
|
onInsertBlock={handleInsertBlock}
|
|
onMoveBlock={handleMoveBlock}
|
|
onDeleteBlock={handleDeleteBlock}
|
|
onOpenComments={setCommentingBlock}
|
|
onAskAgent={(snippet) => {
|
|
handleSendMessage(
|
|
`针对受控文档《${docAST.title}》中的条款内容:\n\n“${snippet.slice(0, 150)}${snippet.length > 150 ? '...' : ''}”\n\n请帮我审查其合规性、技术可行性或提出修改建议。`
|
|
);
|
|
}}
|
|
highlightedBlockId={highlightedBlockId}
|
|
/>
|
|
|
|
{/* 底部法定流转动作条 (根据受控/共享/私密形态自适应展示) */}
|
|
<DocGovActionBar
|
|
ast={docAST}
|
|
currentRole={currentDocRole}
|
|
currentUserId={currentUser.id}
|
|
canOverrideVisibility={canOverrideVisibility}
|
|
onOpenVisibilityModal={() => {
|
|
const currentDoc = deliverables.find((d) => d.docId === activeDocId) || null;
|
|
setTargetDocForVisibility(currentDoc);
|
|
setDocVisibilityModalOpen(true);
|
|
}}
|
|
onClaim={handleClaim}
|
|
onSubmitReview={handleSubmitReview}
|
|
onPassReview={handlePassReview}
|
|
onRejectReview={handleRejectReview}
|
|
onApprove={handleApprove}
|
|
onRejectApprove={handleRejectReview}
|
|
onShareToProject={handleShareToProject}
|
|
onRevokeShare={handleRevokeShare}
|
|
/>
|
|
|
|
{/* 块就地编辑弹窗 (支持富文本工具栏、多维表格与未保存拦截) */}
|
|
{editingBlock && (
|
|
<BlockEditorModal
|
|
block={editingBlock}
|
|
onSave={handleSaveBlockEdit}
|
|
onClose={() => setEditingBlock(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* 审核批注侧栏 */}
|
|
{commentingBlock && (
|
|
<CommentSidePanel
|
|
block={commentingBlock}
|
|
currentRole={currentDocRole}
|
|
currentUserName={currentUser.name}
|
|
onAddComment={handleAddComment}
|
|
onClose={() => setCommentingBlock(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
{/* 用户在当前激活项目中的法定职责与权限矩阵弹窗 (支持快速模拟账号切换) */}
|
|
<UserProjectRightsModal
|
|
isOpen={rightsModalOpen}
|
|
onClose={() => setRightsModalOpen(false)}
|
|
currentUser={currentUser}
|
|
currentProjectTitle={sessions.find((s) => s.sessionId === activeSessionId)?.title || '当前项目'}
|
|
deliverables={visibleDeliverables}
|
|
activeDocId={activeDocId}
|
|
allUsers={allUsers}
|
|
canAssignLead={canAssignLead}
|
|
allowDevSwitch={systemEnv === 'dev'}
|
|
onSwitchUser={(u) => {
|
|
const fresh = allUsers.find((x) => x.id === u.id) || u;
|
|
setCurrentUser(fresh);
|
|
}}
|
|
onSelectDoc={handleSelectDoc}
|
|
onRoleChangeForDoc={handleRoleChangeForDoc}
|
|
/>
|
|
|
|
{/* 首次登录强制改密阻断弹窗 */}
|
|
<FirstLoginPasswordModal
|
|
isOpen={!!session?.mustChangePassword}
|
|
user={currentUser}
|
|
onPasswordChanged={() => {
|
|
const updated = { ...session, mustChangePassword: false };
|
|
setSession(updated);
|
|
authClient.saveSession(updated);
|
|
}}
|
|
onLogout={handleLogout}
|
|
/>
|
|
|
|
{/* 用户个人设置与偏好中心 (全员可用) */}
|
|
<UserSettingsModal
|
|
isOpen={userSettingsOpen}
|
|
onClose={() => setUserSettingsOpen(false)}
|
|
currentUser={currentUser}
|
|
roles={roles}
|
|
onUpdateProfile={(updatedProfile) => {
|
|
setAllUsers((prev) =>
|
|
prev.map((u) => (u.id === currentUser.id ? { ...u, ...updatedProfile } : u))
|
|
);
|
|
setCurrentUser((prev) => ({ ...prev, ...updatedProfile }));
|
|
}}
|
|
onLogout={handleLogout}
|
|
/>
|
|
|
|
{/* 系统管理员独占治理控制台 (仅 role-admin 可入) */}
|
|
<AdminSettingsModal
|
|
isOpen={adminSettingsOpen}
|
|
onClose={() => setAdminSettingsOpen(false)}
|
|
currentUser={currentUser}
|
|
roles={roles}
|
|
onSaveRoles={(newRoles) => setRoles(newRoles)}
|
|
allUsers={allUsers}
|
|
onUpdateUserRole={(userId, roleId) => {
|
|
const targetRole = roles.find((r) => r.id === roleId);
|
|
setAllUsers((prev) =>
|
|
prev.map((u) => (u.id === userId ? { ...u, roleId, position: targetRole?.name || u.position } : u))
|
|
);
|
|
if (currentUser.id === userId) {
|
|
setCurrentUser((prev) => ({ ...prev, roleId, position: targetRole?.name || prev.position }));
|
|
}
|
|
}}
|
|
systemConfig={systemAuthConfig}
|
|
onUpdateSystemConfig={(cfg) => setSystemAuthConfig((prev) => ({ ...prev, ...cfg }))}
|
|
onRefreshUsers={() => {
|
|
authClient
|
|
.getAdminUsers()
|
|
.then((users) => {
|
|
setAllUsers(users);
|
|
})
|
|
.catch(() => {});
|
|
}}
|
|
env={systemEnv}
|
|
/>
|
|
|
|
{/* 研发调试快捷身份切换浮窗 (仅在 DEV 环境生效,生产与测试环境彻底剥离) */}
|
|
<DevRoleSwitcher
|
|
env={systemEnv}
|
|
currentUser={currentUser}
|
|
allUsers={allUsers}
|
|
onSwitchUser={(newUser) => {
|
|
const fresh = allUsers.find((u) => u.id === newUser.id) || newUser;
|
|
setCurrentUser(fresh);
|
|
}}
|
|
/>
|
|
|
|
{/* 单文档可见性覆盖弹窗 */}
|
|
<DocVisibilityModal
|
|
isOpen={docVisibilityModalOpen}
|
|
onClose={() => setDocVisibilityModalOpen(false)}
|
|
doc={targetDocForVisibility}
|
|
roles={roles}
|
|
currentUserName={currentUser.name}
|
|
onSaveRule={handleSaveDocVisibilityRule}
|
|
/>
|
|
</>
|
|
);
|
|
}
|