feat(ui): 实现三分类Tab分段切换、全景大盘分区与一键共享/取消共享闭环

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-08 16:50:11 +08:00
co-authored by Cursor
parent 3206304a57
commit 8967f2b48b
5 changed files with 2374 additions and 623 deletions
+580 -235
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { AppShell } from './components/layout/AppShell';
import { MessageStream } from './components/chat/MessageStream';
import { InputArea } from './components/chat/InputArea';
@@ -9,43 +9,62 @@ 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 { SettingsModal } from './components/user/SettingsModal';
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 } from './types/workbench';
import { UserRole } from './types/docgov';
import { ProjectSession, ChatMessage, DeliverableItem, DeliverableCategory } from './types/workbench';
import { UserRole, UserProfile, DocPermissionRole } from './types/docgov';
import { MOCK_DOCUMENTS } from './data/mockDocuments';
const INITIAL_MD = `# 4K超高清荧光内窥镜系统立项与可行性报告
本产品主要用于微创腹腔手术中的组织成像与荧光导航,通过将近红外荧光技术与 4K 超高清摄像系统结合,实时显影胆道与血管解剖结构。
> **[合规依据]** 本项目需严格遵守 GB 9706.1-2020 医用电气安全通用要求与 YY 0569-2011 临床医用内窥镜专用要求。
## 关键技术指标与法规符合性
| 参数项 | 指标要求 | 检验依据 | 合规判定 |
| 光学中心分辨率 | 4K (3840x2160) @ 60fps | YY 0068.1-2008 | 符合 |
| 荧光激发波长 | 785nm / 805nm 近红外双波长 | 行业标准预研 | 待检 |
| 电气绝缘阻抗 | ≥ 100 MΩ (强化绝缘) | GB 9706.1-2020 | 符合 |
### 电气安全与电磁兼容风险评价
根据 IEC 60601-1-2 标准,系统需在高频电刀同时工作时保持抗扰度,抗静电放电需达到接触 8kV / 空气 15kV。
\`\`\`mermaid
graph TD
A[4K荧光内窥镜头端] --> B[双CMOS光学分光模组]
B --> C[FPGA实时图像融合算法]
C --> D[4K医用级超低延迟监视器]
\`\`\`
$$
SNR_{fluorescence} = \\frac{S_{emission}}{\\sqrt{\\sigma_{ambient}^2 + \\sigma_{dark}^2}}
$$
---
`;
export const MOCK_USERS: UserProfile[] = [
{
id: 1,
name: '张工',
employeeId: 'SMDF-0821',
department: '机器人与微创内窥镜研发中心',
position: '资深医疗器械方案工程师',
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: '研发项目技术主管',
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: '微创器械研发总监',
projectPermissions: {
'doc-001': 'approver',
'doc-002': 'approver',
'doc-003': 'author',
'doc-004': 'approver',
'doc-shared-001': 'approver',
},
},
];
export default function App() {
// 会话与用户状态
@@ -73,16 +92,20 @@ export default function App() {
]);
const [activeSessionId, setActiveSessionId] = useState('sess-001');
const [currentRole, setCurrentRole] = useState<UserRole>('author');
const [currentUserName, setCurrentUserName] = useState('张工');
// 交付物状态大盘
// 当前用户身份 (职位在全公司唯一不变,但在不同文档有不同法定职责)
const [currentUser, setCurrentUser] = useState<UserProfile>(MOCK_USERS[0]);
// 全生命周期完整交付物大盘清单 (含工作流受控、项目协作共享、个人私密生成三大分类)
const [deliverables, setDeliverables] = useState<DeliverableItem[]>([
{
docId: 'doc-001',
taskKey: 'feasibility_reg',
title: '注册路径可行性评估报告',
title: '4K荧光内窥镜立项与可行性报告',
stage: 'V0.5',
category: 'workflow',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: true,
lifecycleState: 'drafting',
isClaimed: true,
@@ -95,8 +118,11 @@ export default function App() {
{
docId: 'doc-002',
taskKey: 'research',
title: '微创腹腔镜全球竞品深度调研',
title: '微创外科全球竞品深度调研',
stage: 'V0.5',
category: 'workflow',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: true,
lifecycleState: 'reviewing',
isClaimed: true,
@@ -109,10 +135,13 @@ export default function App() {
{
docId: 'doc-003',
taskKey: 'prelim_design',
title: '光学系统初步设计规格书',
title: '光学分光与电路初步设计规格书',
stage: 'V0.5',
category: 'workflow',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: true,
lifecycleState: 'approved',
lifecycleState: 'approving',
isClaimed: true,
author: '张工',
reviewer: '李主管',
@@ -120,25 +149,247 @@ export default function App() {
updatedAt: '2026-09-07 17:00',
summary: '4K荧光双路分光棱镜光路设计与近红外消色差物镜参数设计。',
},
]);
const [activeDocId, setActiveDocId] = useState('doc-001');
const [deckDrawerOpen, setDeckDrawerOpen] = useState(false);
// 文档 AST
const [docAST, setDocAST] = useState<DocumentAST>(() =>
markdownToBlockAST(INITIAL_MD, {
docId: 'doc-001',
title: '4K超高清荧光内窥镜系统立项与可行性报告',
taskKey: 'feasibility_reg',
version: 'V1.0',
{
docId: 'doc-004',
taskKey: 'cdo_quote',
title: 'CDO商业报价单与工时清单',
stage: 'V1.0',
category: 'workflow',
creatorId: 4,
creatorName: '王商务',
isFixedWorkflow: true,
lifecycleState: 'drafting',
isClaimed: true,
author: '王商务',
reviewer: '张工',
approver: '李总',
updatedAt: '2026-09-08 15:30',
summary: '基于WBS千工时分解与CDO成本模型的真实商业报价核算。',
},
{
docId: 'doc-shared-001',
taskKey: 'supply_chain_analysis',
title: '4K双路CMOS供应链与成本灵敏度分析',
stage: 'V0.5',
category: 'shared',
creatorId: 1,
creatorName: '张工',
sharedAt: '2026-09-08 15:20',
isFixedWorkflow: false,
lifecycleState: 'drafting',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
})
);
updatedAt: '2026-09-08 15:20',
summary: '张工在与助手对话中推演的 Sony IMX485 与国产长光辰芯双路 CMOS 传感器成本灵敏度测算。',
},
{
docId: 'doc-private-001',
taskKey: 'negotiation_memo',
title: '微创医疗战略商务谈判底线与预付款让利推演备忘',
stage: 'V1.0',
category: 'private',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: false,
lifecycleState: 'drafting',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 16:10',
summary: '张工个人与助手对话进行的商务谈判底线、毛利率红线测算草案,含个人思考,仅本人可见。',
},
{
docId: 'doc-private-002',
taskKey: 'lead_capacity_memo',
title: '团队绩效核算与阶段资源饱和度评估',
stage: 'V0.5',
category: 'private',
creatorId: 2,
creatorName: '李主管',
isFixedWorkflow: false,
lifecycleState: 'drafting',
isClaimed: true,
author: '李主管',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 15:50',
summary: '李主管个人对话生成的团队工时饱和度与绩效预算底稿,仅李主管可见。',
},
]);
const [activeCategory, setActiveCategory] = useState<DeliverableCategory>('workflow');
const [activeDocId, setActiveDocId] = useState('doc-001');
const [deckDrawerOpen, setDeckDrawerOpen] = useState(false);
// 模态浮层控制
const [rightsModalOpen, setRightsModalOpen] = useState(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
// 基于当前登录用户过滤可见交付物 (私密文档严格按 creatorId 隔离,流程与共享文档全员可见)
const visibleDeliverables = useMemo(() => {
return deliverables.filter((doc) => {
if (doc.category === 'workflow' || doc.category === 'shared') {
return true;
}
return doc.creatorId === currentUser.id;
});
}, [deliverables, currentUser.id]);
// 当用户切换或可见列表变动时,自动校准激活文档
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> = {};
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] || 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);
@@ -196,86 +447,103 @@ export default function App() {
id: 'msg-' + Date.now(),
sessionId: activeSessionId,
role: 'user',
authorName: currentUserName,
authorName: currentUser.name,
content: text,
attachments,
timestamp: new Date().toTimeString().substring(0, 5),
attachments,
};
setMessages((prev) => [...prev, userMsg]);
setIsThinking(true);
// 智能体推演回复逻辑
setTimeout(() => {
setIsThinking(false);
// 对话驱动 1:审核人对话提出批注
if (currentRole === 'reviewer' && (text.includes('批注') || text.includes('意见') || text.includes('补充'))) {
const targetBlock = docAST.rootBlock.children?.[3] || docAST.rootBlock.children?.[1];
if (targetBlock) {
const newComment: BlockComment = {
id: 'cmt-' + Date.now(),
authorName: currentUserName,
authorRole: 'reviewer',
createdAt: new Date().toTimeString().substring(0, 5),
type: 'issue',
content: text,
resolved: false,
};
targetBlock.comments = [...(targetBlock.comments || []), newComment];
setDocAST({ ...docAST });
}
// 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,
taskKey: 'custom_draft',
title: docTitle,
stage: 'V0.5',
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
isFixedWorkflow: 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: 'reply-' + Date.now(),
id: 'ast-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
intentTag: '💡 评审批注挂载',
content: `李主管,已为您将审核意见记录并挂载在右侧受控文档【${targetBlock?.numbering || ''} ${targetBlock?.content.substring(0, 16)}...】章节旁,编制人随时可见。`,
authorName: '水木东方协同参谋',
intentTag: '🔒 个人私密文档已生成',
content: `收到您的要求,已为您即时生成专属推演文档《${docTitle}》!\n\n根据系统隐私保护机制,该文档已自动存入【我的私密】库,**仅您本人可见**。右侧工作台已为您自动切至该文档,您可以自由查看与编辑。推演成熟后,可随时点击底部「一键共享给项目组」作为团队协作参考资料。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
return;
}
// 对话驱动 2:审核人对话批准推进
if (currentRole === 'reviewer' && (text.includes('通过') || text.includes('同意'))) {
handlePassReview();
setMessages((prev) => [
...prev,
{
id: 'reply-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
intentTag: '🛡️ 审核通过流转',
content: '李主管,已为您执行【审核通过】指令,文档已锁定并正式流转至王总监(批准人)进行终审。',
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
return;
}
// 对话驱动 3:编制人认领保护拦截 (Change Proposal)
if (text.includes('修改') || text.includes('更新') || text.includes('改写') || text.includes('补充')) {
if (docAST.isClaimed) {
if (text.includes('修改') || text.includes('更新') || text.includes('调整')) {
if (docAST.isClaimed && currentDocRole === 'author') {
setPendingProposal({
changes: `根据您的指令【${text}】,拟将第二章关键技术指标补充 4K 荧光抗扰度测试参数,并在表格中增加检验依据条款。`,
impact: '将直接影响后续型检大纲覆盖率,增加 IEC 60601-1-2 第 8 节要求。',
risk: '低风险合规强化项,无需重跑临床验证。',
patchBlockId: docAST.rootBlock.children?.[4]?.id,
changes: '拟在第2节「监管分类与强制性合规标准矩阵」中,强化补充高频电刀共模抑制比设计指标。',
impact: '将直接影响电路原理图物料选型与型式检验 EMC 滤波网络参数。',
risk: '低风险。符合 GB 9706.1-2020 强标,提升系统抗扰度,无侵权风险。',
});
setMessages((prev) => [
...prev,
{
id: 'reply-' + Date.now(),
id: 'ast-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
intentTag: '🛡️ 认领变更拦截',
content:
'检测到本文档已被张工认领受控。依据认领保护机制,已为您生成《变更提案》。请在右侧下方或对话卡片中确认是否采纳本次变更。',
authorName: '水木东方协同参谋',
intentTag: '🛡️ 文档认领保护机制拦截',
content: `检测到《${docAST.title}》已处于【编制人已认领】状态。根据医疗器械质量体系规范,智能体小队不能擅自修改,已为您生成【变更方案提议】。请您确认是否准许本次修改。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
@@ -283,49 +551,76 @@ export default function App() {
}
}
// 常规参谋解答
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;
}
}
setMessages((prev) => [
...prev,
{
id: 'reply-' + Date.now(),
id: 'ast-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
intentTag: '💡 参谋解答',
content: `收到您的指令:“${text}”。参谋小队已依据医疗器械法规标准进行综合排查,相关指标已实时对齐。您可随时在右侧文档栏双击块进行微调。`,
authorName: '水木东方协同参谋',
intentTag: '💡 协同参谋回复',
content: `收到您的要求:“${text}”。参谋小队已分析当前处于【${docAST.lifecycleState}】生命周期,右侧已实时同步最新受控文档结构。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
}, 1200);
}, 1000);
};
// 采纳变更提案
// 编制人接受变更提议
const handleAcceptProposal = () => {
if (!pendingProposal) return;
const children = docAST.rootBlock.children || [];
const tableBlock = children.find((b) => b.type === 'table');
if (tableBlock && tableBlock.properties?.tableData) {
tableBlock.properties.tableData.push(['电磁兼容抗扰度', '80MHz~6GHz 3V/m 强化测试', 'IEC 60601-1-2', '符合']);
}
recomputeNumbering(docAST.rootBlock);
setDocAST({ ...docAST });
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: 'prop-acc-' + Date.now(),
id: 'ast-p-' + Date.now(),
sessionId: activeSessionId,
role: 'system',
content: '✅ 编制负责人已确认并采纳智能体变更提案,右侧受控文档已实时更新完成。',
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((b) => b.id === updated.id);
const idx = children.findIndex((c) => c.id === updated.id);
if (idx !== -1) {
children[idx] = updated;
recomputeNumbering(docAST.rootBlock);
@@ -334,16 +629,19 @@ export default function App() {
setEditingBlock(null);
};
// 块操作:增、删、移
const handleInsertBlock = (index: number, position: 'above' | 'below') => {
const children = docAST.rootBlock.children || [];
const newBlock: DocumentBlock = {
id: 'blk_' + Math.random().toString(36).substring(2, 9),
type: 'paragraph',
content: '新插入的受控文档段落文本,双击可就地编辑...',
content: '【新插入技术要求】:在此填写具体受控技术规范或临床评定指标...',
metadata: {
lastModifiedBy: `${currentUser.name} (${currentUser.position})`,
lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
},
};
const targetIdx = position === 'above' ? index : index + 1;
children.splice(targetIdx, 0, newBlock);
const children = docAST.rootBlock.children || [];
const insertAt = position === 'above' ? index : index + 1;
children.splice(insertAt, 0, newBlock);
recomputeNumbering(docAST.rootBlock);
setDocAST({ ...docAST });
};
@@ -467,116 +765,163 @@ export default function App() {
URL.revokeObjectURL(url);
};
// 测试演练:动态修改当前用户在某个文档的权限角色
const handleRoleChangeForDoc = (docId: string, role: DocPermissionRole) => {
setCurrentUser((prev) => ({
...prev,
projectPermissions: {
...(prev.projectPermissions || {}),
[docId]: role,
},
}));
};
return (
<AppShell
sessions={sessions}
activeSessionId={activeSessionId}
onSelectSession={setActiveSessionId}
onNewSession={() => {
const newSess: ProjectSession = {
sessionId: 'sess-' + Date.now(),
projectId: Date.now(),
title: '新建医疗器械研发项目',
deviceType: '通用医疗器械',
stage: 'V0.1',
stageLabel: 'V0.1 需求摸底',
messageCount: 0,
updatedAt: '刚刚',
};
setSessions([newSess, ...sessions]);
setActiveSessionId(newSess.sessionId);
}}
currentRole={currentRole}
currentUserName={currentUserName}
onChangeRole={(r, name) => {
setCurrentRole(r);
setCurrentUserName(name);
}}
chatContent={
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<MessageStream messages={messages} onOpenCase={setActiveDocId} />
{pendingProposal && (
<div style={{ padding: '0 16px' }}>
<ProposalConfirmCard
proposedChanges={pendingProposal.changes}
impactAnalysis={pendingProposal.impact}
potentialRisk={pendingProposal.risk}
onAccept={handleAcceptProposal}
onReject={() => setPendingProposal(null)}
<>
<AppShell
sessions={sessions}
activeSessionId={activeSessionId}
onSelectSession={setActiveSessionId}
onNewSession={() => {
const newSess: ProjectSession = {
sessionId: 'sess-' + Date.now(),
projectId: Date.now(),
title: '新建医疗器械研发项目',
deviceType: '通用医疗器械',
stage: 'V0.1',
stageLabel: 'V0.1 需求摸底',
messageCount: 0,
updatedAt: '刚刚',
};
setSessions([newSess, ...sessions]);
setActiveSessionId(newSess.sessionId);
}}
currentUser={currentUser}
currentRole={currentDocRole}
currentUserName={currentUser.name}
onChangeRole={(_, __) => {}}
onOpenRightsModal={() => setRightsModalOpen(true)}
onOpenSettingsModal={() => setSettingsModalOpen(true)}
chatContent={
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<MessageStream messages={messages} onOpenCase={handleSelectDoc} />
{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}
/>
{/* 全景大盘抽屉 (支持受控/共享/私密三板块与一键流转操作) */}
<DeckDrawer
isOpen={deckDrawerOpen}
onClose={() => setDeckDrawerOpen(false)}
deliverables={visibleDeliverables}
activeDocId={activeDocId}
currentUserId={currentUser.id}
onSelectDoc={handleSelectDoc}
onShareToProject={handleShareToProject}
onRevokeShare={handleRevokeShare}
onDeleteDoc={handleDeleteDoc}
/>
{/* 块状文档渲染器 */}
<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请帮我审查其合规性、技术可行性或提出修改建议。`
);
}}
/>
{/* 底部法定流转动作条 (根据受控/共享/私密形态自适应展示) */}
<DocGovActionBar
ast={docAST}
currentRole={currentDocRole}
currentUserId={currentUser.id}
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)}
/>
</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={deliverables}
activeDocId={activeDocId}
onSelectDoc={setActiveDocId}
onToggleDeckDrawer={() => setDeckDrawerOpen(!deckDrawerOpen)}
onExportDocx={handleExportDocx}
/>
)}
{/* 全景大盘抽屉 */}
<DeckDrawer
isOpen={deckDrawerOpen}
onClose={() => setDeckDrawerOpen(false)}
deliverables={deliverables}
activeDocId={activeDocId}
onSelectDoc={setActiveDocId}
/>
{/* 审核批注侧栏 */}
{commentingBlock && (
<CommentSidePanel
block={commentingBlock}
currentRole={currentDocRole}
currentUserName={currentUser.name}
onAddComment={handleAddComment}
onClose={() => setCommentingBlock(null)}
/>
)}
</div>
}
/>
{/* 块状文档渲染器 */}
<BlockRenderer
ast={docAST}
currentRole={currentRole}
onEditBlock={setEditingBlock}
onInsertBlock={handleInsertBlock}
onMoveBlock={handleMoveBlock}
onDeleteBlock={handleDeleteBlock}
onOpenComments={setCommentingBlock}
/>
{/* 用户在当前激活项目中的法定职责与权限矩阵弹窗 (支持快速模拟账号切换) */}
<UserProjectRightsModal
isOpen={rightsModalOpen}
onClose={() => setRightsModalOpen(false)}
currentUser={currentUser}
currentProjectTitle={sessions.find((s) => s.sessionId === activeSessionId)?.title || '当前项目'}
deliverables={visibleDeliverables}
activeDocId={activeDocId}
allUsers={MOCK_USERS}
onSwitchUser={(u) => setCurrentUser(u)}
onSelectDoc={handleSelectDoc}
onRoleChangeForDoc={handleRoleChangeForDoc}
/>
{/* 底部法定流转动作条 */}
<DocGovActionBar
ast={docAST}
currentRole={currentRole}
onClaim={handleClaim}
onSubmitReview={handleSubmitReview}
onPassReview={handlePassReview}
onRejectReview={handleRejectReview}
onApprove={handleApprove}
onRejectApprove={handleRejectReview}
/>
{/* 块就地编辑弹窗 */}
{editingBlock && (
<BlockEditorModal
block={editingBlock}
onSave={handleSaveBlockEdit}
onClose={() => setEditingBlock(null)}
/>
)}
{/* 审核批注侧栏 */}
{commentingBlock && (
<CommentSidePanel
block={commentingBlock}
currentRole={currentRole}
currentUserName={currentUserName}
onAddComment={handleAddComment}
onClose={() => setCommentingBlock(null)}
/>
)}
</div>
}
/>
{/* 预留系统设置与管理员后台弹窗 */}
<SettingsModal
isOpen={settingsModalOpen}
onClose={() => setSettingsModalOpen(false)}
/>
</>
);
}
+600 -127
View File
@@ -1,14 +1,32 @@
import React from 'react';
import { DeliverableItem } from '../../types/workbench';
import React, { useEffect, useState } from 'react';
import { DeliverableItem, DeliverableCategory } from '../../types/workbench';
import { DocLifecycleState } from '../../types/docgov';
import { X, CheckCircle2, Clock, ShieldCheck, UserCheck, AlertTriangle } from 'lucide-react';
import {
X,
CheckCircle2,
Clock,
ShieldCheck,
UserCheck,
AlertTriangle,
Lock,
Globe,
Share2,
Undo2,
Trash2,
Shield,
Layers,
} from 'lucide-react';
interface DeckDrawerProps {
isOpen: boolean;
onClose: () => void;
deliverables: DeliverableItem[];
activeDocId: string;
currentUserId: number;
onSelectDoc: (docId: string) => void;
onShareToProject?: (docId: string) => void;
onRevokeShare?: (docId: string) => void;
onDeleteDoc?: (docId: string) => void;
}
export const DeckDrawer: React.FC<DeckDrawerProps> = ({
@@ -16,8 +34,24 @@ export const DeckDrawer: React.FC<DeckDrawerProps> = ({
onClose,
deliverables,
activeDocId,
currentUserId,
onSelectDoc,
onShareToProject,
onRevokeShare,
onDeleteDoc,
}) => {
const [filterCategory, setFilterCategory] = useState<'all' | DeliverableCategory>('all');
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) {
return null;
}
@@ -26,28 +60,76 @@ export const DeckDrawer: React.FC<DeckDrawerProps> = ({
switch (state) {
case 'approved':
return (
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--success)', background: 'var(--success-bg)', padding: '2px 8px', borderRadius: 'var(--r-pill)', display: 'flex', alignItems: 'center', gap: '3px' }}>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--success)',
background: 'var(--success-bg)',
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<CheckCircle2 size={11} />
<span>终审已生效</span>
</span>
);
case 'reviewing':
return (
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--amber-600)', background: 'var(--amber-50)', padding: '2px 8px', borderRadius: 'var(--r-pill)', display: 'flex', alignItems: 'center', gap: '3px' }}>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--amber-600)',
background: 'var(--amber-50)',
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<Clock size={11} />
<span>审核中 (已锁定)</span>
</span>
);
case 'approving':
return (
<span style={{ fontSize: '11px', fontWeight: 600, color: '#9333EA', background: '#F3E8FF', padding: '2px 8px', borderRadius: 'var(--r-pill)', display: 'flex', alignItems: 'center', gap: '3px' }}>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: '#9333EA',
background: '#F3E8FF',
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<ShieldCheck size={11} />
<span>批准中</span>
</span>
);
case 'rejected':
return (
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--danger)', background: 'var(--danger-bg)', padding: '2px 8px', borderRadius: 'var(--r-pill)', display: 'flex', alignItems: 'center', gap: '3px' }}>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--danger)',
background: 'var(--danger-bg)',
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<AlertTriangle size={11} />
<span>被驳回需修改</span>
</span>
@@ -55,7 +137,19 @@ export const DeckDrawer: React.FC<DeckDrawerProps> = ({
case 'drafting':
default:
return (
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--brand-600)', background: 'var(--brand-50)', padding: '2px 8px', borderRadius: 'var(--r-pill)', display: 'flex', alignItems: 'center', gap: '3px' }}>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--brand-600)',
background: 'var(--brand-50)',
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<UserCheck size={11} />
<span>编制中 {isClaimed ? '(已认领)' : '(未认领)'}</span>
</span>
@@ -63,143 +157,522 @@ export const DeckDrawer: React.FC<DeckDrawerProps> = ({
}
};
// 按阶段分组
const stages: { stage: string; title: string }[] = [
{ stage: 'V0.1', title: 'V0.1 需求摸底与产品定义' },
{ stage: 'V0.5', title: 'V0.5 方案成型与法规对齐' },
{ stage: 'V1.0', title: 'V1.0 商务报价与WBS工时测算' },
];
const workflowDocs = deliverables.filter((d) => d.category === 'workflow');
const sharedDocs = deliverables.filter((d) => d.category === 'shared');
const privateDocs = deliverables.filter((d) => d.category === 'private');
return (
<div
style={{
position: 'absolute',
top: '36px',
left: 0,
right: 0,
maxHeight: '480px',
background: 'var(--bg-panel)',
borderBottom: '2px solid var(--brand-500)',
boxShadow: 'var(--shadow-lg)',
zIndex: 50,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
backdropFilter: 'blur(16px)',
}}
>
{/* 抽屉顶栏 */}
<>
{/* 外部遮罩 */}
<div
onClick={onClose}
style={{
padding: '10px 16px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--bg-subtle)',
position: 'fixed',
inset: 0,
zIndex: 45,
background: 'rgba(15, 23, 42, 0.35)',
backdropFilter: 'blur(3px)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-strong)' }}>
当前项目阶段交付物台账大盘
</span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
(共 {deliverables.length} 份受控交付物)
</span>
</div>
<button
onClick={onClose}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '26px',
height: '26px',
borderRadius: 'var(--r-sm)',
color: 'var(--text-muted)',
}}
title="关闭大盘"
>
<X size={15} />
</button>
</div>
/>
{/* 交付物卡片列表 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '14px 16px',
position: 'absolute',
top: '36px',
left: 0,
right: 0,
maxHeight: '560px',
background: 'var(--bg-panel)',
borderBottom: '2px solid var(--brand-500)',
boxShadow: 'var(--shadow-xl)',
zIndex: 50,
display: 'flex',
flexDirection: 'column',
gap: '14px',
overflow: 'hidden',
animation: 'fadeIn 0.15s ease-out',
}}
>
{stages.map((stg) => {
const docsInStage = deliverables.filter((d) => d.stage === stg.stage);
if (docsInStage.length === 0) return null;
return (
<div key={stg.stage} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontSize: '12px', fontWeight: 700, color: 'var(--brand-600)' }}>
{stg.title}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '10px' }}>
{docsInStage.map((doc) => {
const isActive = doc.docId === activeDocId;
return (
<div
key={doc.docId}
onClick={() => {
onSelectDoc(doc.docId);
onClose();
}}
style={{
padding: '10px 12px',
borderRadius: 'var(--r-md)',
background: isActive ? 'var(--bg-active)' : 'var(--bg-card)',
border: isActive ? '1.5px solid var(--brand-500)' : '1px solid var(--border)',
boxShadow: 'var(--shadow-xs)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: '6px',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.borderColor = 'var(--brand-100)';
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.borderColor = 'var(--border)';
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong)' }}>
{doc.title}
</span>
{renderBadge(doc.lifecycleState, doc.isClaimed)}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', display: 'flex', gap: '12px' }}>
<span>编制: <b>{doc.author}</b></span>
<span>审核: <b>{doc.reviewer}</b></span>
<span>批准: <b>{doc.approver}</b></span>
</div>
{doc.summary && (
<div style={{ fontSize: '11px', color: 'var(--text-faint)', lineHeight: 1.4 }}>
{doc.summary}
</div>
)}
</div>
);
})}
</div>
{/* 抽屉顶栏 */}
<div
style={{
padding: '10px 16px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--bg-subtle)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Layers size={16} color="var(--brand-500)" />
<span style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-strong)' }}>
交付物分类台账全景大盘
</span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
(共 {deliverables.length} 份可用交付物)
</span>
</div>
);
})}
{/* 大盘内分类筛选切换 */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '2px',
background: 'var(--bg-panel)',
padding: '2px',
borderRadius: '6px',
border: '1px solid var(--border)',
}}
>
<button
onClick={() => setFilterCategory('all')}
style={{
height: '22px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
fontSize: '11px',
cursor: 'pointer',
fontWeight: filterCategory === 'all' ? 600 : 500,
background: filterCategory === 'all' ? 'var(--brand-500)' : 'transparent',
color: filterCategory === 'all' ? '#ffffff' : 'var(--text-muted)',
}}
>
全部全景 ({deliverables.length})
</button>
<button
onClick={() => setFilterCategory('workflow')}
style={{
height: '22px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
fontSize: '11px',
cursor: 'pointer',
fontWeight: filterCategory === 'workflow' ? 600 : 500,
background: filterCategory === 'workflow' ? 'var(--brand-500)' : 'transparent',
color: filterCategory === 'workflow' ? '#ffffff' : 'var(--text-muted)',
}}
>
流程受控 ({workflowDocs.length})
</button>
<button
onClick={() => setFilterCategory('shared')}
style={{
height: '22px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
fontSize: '11px',
cursor: 'pointer',
fontWeight: filterCategory === 'shared' ? 600 : 500,
background: filterCategory === 'shared' ? 'var(--brand-500)' : 'transparent',
color: filterCategory === 'shared' ? '#ffffff' : 'var(--text-muted)',
}}
>
项目共享 ({sharedDocs.length})
</button>
<button
onClick={() => setFilterCategory('private')}
style={{
height: '22px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
fontSize: '11px',
cursor: 'pointer',
fontWeight: filterCategory === 'private' ? 600 : 500,
background: filterCategory === 'private' ? '#D97706' : 'transparent',
color: filterCategory === 'private' ? '#ffffff' : 'var(--text-muted)',
}}
>
我的私密 ({privateDocs.length})
</button>
</div>
</div>
<button
onClick={onClose}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '26px',
height: '26px',
borderRadius: 'var(--r-sm)',
border: 'none',
background: 'transparent',
color: 'var(--text-muted)',
cursor: 'pointer',
}}
title="关闭大盘"
>
<X size={15} />
</button>
</div>
{/* 交付物大盘内容区 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '20px',
}}
>
{/* 板块一:🏛️ 研发体系预设受控交付物 */}
{(filterCategory === 'all' || filterCategory === 'workflow') && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
paddingBottom: '6px',
borderBottom: '1px dashed var(--border)',
}}
>
<Shield size={14} color="var(--brand-500)" />
<span style={{ fontSize: '12px', fontWeight: 700, color: 'var(--text-strong)' }}>
研发体系预设受控交付物 (固定流程 · 严格审签)
</span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
共 {workflowDocs.length} 份受控文档
</span>
</div>
{stages.map((stg) => {
const docsInStage = workflowDocs.filter((d) => d.stage === stg.stage);
if (docsInStage.length === 0) return null;
return (
<div key={stg.stage} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--brand-600)', paddingLeft: '4px' }}>
{stg.title}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(290px, 1fr))', gap: '10px' }}>
{docsInStage.map((doc) => {
const isActive = doc.docId === activeDocId;
return (
<div
key={doc.docId}
onClick={() => {
onSelectDoc(doc.docId);
onClose();
}}
style={{
padding: '10px 12px',
borderRadius: 'var(--r-md)',
background: isActive ? 'var(--bg-active)' : 'var(--bg-card)',
border: isActive ? '1.5px solid var(--brand-500)' : '1px solid var(--border)',
boxShadow: 'var(--shadow-xs)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: '6px',
transition: 'all 0.15s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '12.5px', fontWeight: 600, color: 'var(--text-strong)' }}>
{doc.title}
</span>
{renderBadge(doc.lifecycleState, doc.isClaimed)}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', display: 'flex', gap: '12px' }}>
<span>编制: <b>{doc.author}</b></span>
<span>审核: <b>{doc.reviewer}</b></span>
<span>批准: <b>{doc.approver}</b></span>
</div>
{doc.summary && (
<div style={{ fontSize: '11px', color: 'var(--text-faint)', lineHeight: 1.4 }}>
{doc.summary}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
{/* 板块二:🌐 项目协作参考资料库 */}
{(filterCategory === 'all' || filterCategory === 'shared') && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
paddingBottom: '6px',
borderBottom: '1px dashed var(--border)',
}}
>
<Globe size={14} color="var(--brand-500)" />
<span style={{ fontSize: '12px', fontWeight: 700, color: 'var(--text-strong)' }}>
项目协作参考资料库 (团队共享 · 方案推演)
</span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
共 {sharedDocs.length} 份共享参考材料
</span>
</div>
{sharedDocs.length === 0 ? (
<div style={{ fontSize: '12px', color: 'var(--text-faint)', padding: '12px', textAlign: 'center' }}>
当前暂无团队成员共享的参考资料。个人私密生成物成熟后可一键共享至此处。
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(290px, 1fr))', gap: '10px' }}>
{sharedDocs.map((doc) => {
const isActive = doc.docId === activeDocId;
const isCreator = doc.creatorId === currentUserId;
return (
<div
key={doc.docId}
onClick={() => {
onSelectDoc(doc.docId);
onClose();
}}
style={{
padding: '10px 12px',
borderRadius: 'var(--r-md)',
background: isActive ? 'var(--bg-active)' : 'var(--bg-card)',
border: isActive ? '1.5px solid var(--brand-500)' : '1px solid var(--border)',
boxShadow: 'var(--shadow-xs)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: '6px',
transition: 'all 0.15s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '12.5px', fontWeight: 600, color: 'var(--text-strong)' }}>
{doc.title}
</span>
<span
style={{
fontSize: '10.5px',
fontWeight: 600,
color: 'var(--brand-600)',
background: 'var(--brand-50)',
padding: '2px 6px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<Globe size={10} />
<span>项目共享</span>
</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', display: 'flex', gap: '12px' }}>
<span>共享人: <b>{doc.creatorName}</b></span>
<span>共享时间: <b>{doc.sharedAt || doc.updatedAt}</b></span>
</div>
{doc.summary && (
<div style={{ fontSize: '11px', color: 'var(--text-faint)', lineHeight: 1.4 }}>
{doc.summary}
</div>
)}
{/* 共享人专属撤回操作 */}
{isCreator && onRevokeShare && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '4px' }}>
<button
onClick={(e) => {
e.stopPropagation();
onRevokeShare(doc.docId);
}}
style={{
fontSize: '11px',
padding: '3px 8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
color: 'var(--text)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
title="将该共享资料撤回为个人私密草稿 (仅自己可见)"
>
<Undo2 size={11} />
<span>取消共享 (恢复私有)</span>
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
{/* 板块三:🔒 我的私密生成档案 */}
{(filterCategory === 'all' || filterCategory === 'private') && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
paddingBottom: '6px',
borderBottom: '1px dashed var(--border)',
}}
>
<Lock size={14} color="#D97706" />
<span style={{ fontSize: '12px', fontWeight: 700, color: 'var(--text-strong)' }}>
我的私密生成档案 (仅您本人可见 · 商业与推演隐私)
</span>
<span style={{ fontSize: '11px', color: '#D97706', fontWeight: 600 }}>
共 {privateDocs.length} 份私有草稿
</span>
</div>
{privateDocs.length === 0 ? (
<div style={{ fontSize: '12px', color: 'var(--text-faint)', padding: '12px', textAlign: 'center' }}>
您当前未生成私密文档。在中间栏向智能体提出测算、草拟需求时,生成的文档将自动归档于此处。
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(290px, 1fr))', gap: '10px' }}>
{privateDocs.map((doc) => {
const isActive = doc.docId === activeDocId;
return (
<div
key={doc.docId}
onClick={() => {
onSelectDoc(doc.docId);
onClose();
}}
style={{
padding: '10px 12px',
borderRadius: 'var(--r-md)',
background: isActive ? 'var(--bg-active)' : 'var(--bg-card)',
border: isActive ? '1.5px solid #D97706' : '1px solid var(--border)',
boxShadow: 'var(--shadow-xs)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: '6px',
transition: 'all 0.15s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '12.5px', fontWeight: 600, color: 'var(--text-strong)' }}>
{doc.title}
</span>
<span
style={{
fontSize: '10.5px',
fontWeight: 600,
color: '#D97706',
background: '#FEF3C7',
padding: '2px 6px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<Lock size={10} />
<span>仅我可见</span>
</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', display: 'flex', gap: '12px' }}>
<span>创建人: <b>本人 ({doc.creatorName})</b></span>
<span>生成时间: <b>{doc.updatedAt}</b></span>
</div>
{doc.summary && (
<div style={{ fontSize: '11px', color: 'var(--text-faint)', lineHeight: 1.4 }}>
{doc.summary}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '6px', marginTop: '4px' }}>
{onShareToProject && (
<button
onClick={(e) => {
e.stopPropagation();
onShareToProject(doc.docId);
}}
style={{
fontSize: '11px',
padding: '3px 8px',
borderRadius: '4px',
border: '1px solid var(--brand-500)',
background: 'var(--brand-500)',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
title="将该私密文档一键共享给项目组成员作为协作参考资料"
>
<Share2 size={11} />
<span>一键共享给项目组</span>
</button>
)}
{onDeleteDoc && (
<button
onClick={(e) => {
e.stopPropagation();
onDeleteDoc(doc.docId);
}}
style={{
fontSize: '11px',
padding: '3px 6px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
color: 'var(--danger)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '2px',
}}
title="删除此私密草稿"
>
<Trash2 size={11} />
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
</div>
</div>
</>
);
};
+379 -210
View File
@@ -1,28 +1,45 @@
import React, { useState } from 'react';
import { DocumentAST } from '../../types/block';
import { UserRole } from '../../types/docgov';
import { Send, CheckCircle2, XCircle, Award, UserCheck, Lock } from 'lucide-react';
import {
Send,
CheckCircle2,
XCircle,
Award,
UserCheck,
Lock,
Globe,
Share2,
Undo2,
Shield,
} from 'lucide-react';
interface DocGovActionBarProps {
ast: DocumentAST;
currentRole: UserRole;
currentUserId?: number;
onClaim: () => void;
onSubmitReview: () => void;
onPassReview: () => void;
onRejectReview: (reason: string) => void;
onApprove: () => void;
onRejectApprove: (reason: string) => void;
onShareToProject?: (docId: string) => void;
onRevokeShare?: (docId: string) => void;
}
export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
ast,
currentRole,
currentUserId,
onClaim,
onSubmitReview,
onPassReview,
onRejectReview,
onApprove,
onRejectApprove,
onShareToProject,
onRevokeShare,
}) => {
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
@@ -44,6 +61,10 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
setRejectModalOpen(false);
};
const isPrivate = ast.category === 'private';
const isShared = ast.category === 'shared';
const isWorkflow = !isPrivate && !isShared;
return (
<div
style={{
@@ -109,7 +130,14 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
onClick={() => setRejectModalOpen(false)}
style={{ padding: '6px 12px', borderRadius: 'var(--r-sm)', background: 'var(--bg-subtle)', color: 'var(--text)' }}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
color: 'var(--text)',
cursor: 'pointer',
}}
>
取消
</button>
@@ -120,9 +148,11 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
padding: '6px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--danger)',
border: 'none',
color: '#fff',
fontWeight: 600,
opacity: rejectReason.trim() ? 1 : 0.5,
cursor: rejectReason.trim() ? 'pointer' : 'not-allowed',
}}
>
确认驳回退回编制
@@ -132,216 +162,355 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
</div>
)}
{/* 左侧状态标识 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
当前生命周期:
</span>
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
background:
ast.lifecycleState === 'approved'
? 'var(--success-bg)'
: ast.lifecycleState === 'reviewing'
? 'var(--amber-50)'
: ast.lifecycleState === 'approving'
? '#F3E8FF'
: 'var(--brand-50)',
color:
ast.lifecycleState === 'approved'
? 'var(--success)'
: ast.lifecycleState === 'reviewing'
? 'var(--amber-600)'
: ast.lifecycleState === 'approving'
? '#9333EA'
: 'var(--brand-600)',
}}
>
{ast.lifecycleState === 'approved'
? '已终审生效'
: ast.lifecycleState === 'reviewing'
? '审核中'
: ast.lifecycleState === 'approving'
? '批准中'
: '编制中'}
</span>
{ast.isClaimed && (
<span style={{ fontSize: '11px', color: 'var(--brand-500)', fontWeight: 500 }}>
• 已被编制人认领
</span>
)}
</div>
{/* 右侧动作流转按钮 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{/* 1. 编制阶段 */}
{ast.lifecycleState === 'drafting' && (
<>
{currentRole === 'author' ? (
<>
{!ast.isClaimed ? (
<button
onClick={onClaim}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
color: 'var(--text)',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '5px',
}}
>
<UserCheck size={13} style={{ color: 'var(--brand-500)' }} />
<span>认领此文档</span>
</button>
) : null}
<button
onClick={onSubmitReview}
style={{
padding: '6px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--brand-grad)',
color: '#fff',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '5px',
boxShadow: '0 2px 6px rgba(79, 107, 255, 0.25)',
}}
>
<Send size={13} />
<span>提交送审 (流转至审核人)</span>
</button>
</>
) : (
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
等待编制负责人({ast.author})完成初稿并提交送审
</span>
)}
</>
)}
{/* 2. 审核阶段 */}
{ast.lifecycleState === 'reviewing' && (
<>
<span style={{ fontSize: '11px', color: 'var(--text-faint)', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Lock size={12} /> 文档已锁定
{/* 场景 1: 个人私密文档专属条 */}
{isPrivate && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
background: '#FEF3C7',
color: '#D97706',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<Lock size={12} />
<span>个人私密草稿</span>
</span>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
由助手针对您的对话需求生成,<strong>仅您本人可见</strong>。不参与项目受控审签体系。
</span>
{currentRole === 'reviewer' ? (
<>
<button
onClick={() => openReject('review')}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--danger-bg)',
border: '1px solid var(--danger)',
color: 'var(--danger)',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<XCircle size={13} />
<span>驳回修改</span>
</button>
<button
onClick={onPassReview}
style={{
padding: '6px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--amber-500)',
color: '#fff',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '4px',
boxShadow: '0 2px 6px rgba(242, 169, 59, 0.3)',
}}
>
<CheckCircle2 size={13} />
<span>审核通过 (送呈批准人)</span>
</button>
</>
) : (
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
等待审核负责人({ast.reviewer})完成审核签署
</span>
)}
</>
)}
{/* 3. 批准阶段 */}
{ast.lifecycleState === 'approving' && (
<>
{currentRole === 'approver' ? (
<>
<button
onClick={() => openReject('approve')}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--danger-bg)',
border: '1px solid var(--danger)',
color: 'var(--danger)',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<XCircle size={13} />
<span>驳回修改</span>
</button>
<button
onClick={onApprove}
style={{
padding: '6px 16px',
borderRadius: 'var(--r-sm)',
background: 'var(--success)',
color: '#fff',
fontSize: '12.5px',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
gap: '5px',
boxShadow: '0 2px 8px rgba(33, 166, 106, 0.35)',
}}
>
<Award size={14} />
<span>终审批准生效 (自动入标杆库)</span>
</button>
</>
) : (
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
等待批准负责人({ast.approver})终审批准发布
</span>
)}
</>
)}
{/* 4. 已批准终态 */}
{ast.lifecycleState === 'approved' && (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--success)', fontSize: '12.5px', fontWeight: 600 }}>
<Award size={16} />
<span>受控归档交付物 (已沉淀为全局标杆经验)</span>
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{onShareToProject && (
<button
onClick={() => onShareToProject(ast.docId)}
style={{
padding: '6px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--brand-500)',
border: 'none',
color: '#fff',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '5px',
cursor: 'pointer',
boxShadow: '0 2px 6px rgba(79, 107, 255, 0.25)',
}}
title="将此推演草稿一键共享给项目组成员查阅与讨论"
>
<Share2 size={13} />
<span>一键共享给项目组</span>
</button>
)}
</div>
</>
)}
{/* 场景 2: 项目协作参考资料专属条 */}
{isShared && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
background: 'var(--brand-50)',
color: 'var(--brand-600)',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<Globe size={12} />
<span>项目协作参考资料</span>
</span>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
共享人: <b>{ast.creatorName || ast.author}</b>
{ast.sharedAt ? ` (${ast.sharedAt})` : ''} · 项目全员可查阅、引用与批注
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{/* 仅创建者本人有权取消共享 */}
{ast.creatorId === currentUserId && onRevokeShare && (
<button
onClick={() => onRevokeShare(ast.docId)}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
color: 'var(--text)',
fontSize: '12px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '5px',
cursor: 'pointer',
}}
title="撤回该文档的共享状态,恢复为您个人私有"
>
<Undo2 size={13} />
<span>取消共享 (恢复私有)</span>
</button>
)}
</div>
</>
)}
{/* 场景 3: 研发体系预设受控交付物 (严格三权分立审签) */}
{isWorkflow && (
<>
{/* 左侧状态标识 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Shield size={14} color="var(--brand-500)" />
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
受控状态:
</span>
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
background:
ast.lifecycleState === 'approved'
? 'var(--success-bg)'
: ast.lifecycleState === 'reviewing'
? 'var(--amber-50)'
: ast.lifecycleState === 'approving'
? '#F3E8FF'
: 'var(--brand-50)',
color:
ast.lifecycleState === 'approved'
? 'var(--success)'
: ast.lifecycleState === 'reviewing'
? 'var(--amber-600)'
: ast.lifecycleState === 'approving'
? '#9333EA'
: 'var(--brand-600)',
}}
>
{ast.lifecycleState === 'approved'
? '已终审生效'
: ast.lifecycleState === 'reviewing'
? '审核中'
: ast.lifecycleState === 'approving'
? '批准中'
: '编制中'}
</span>
{ast.isClaimed && (
<span style={{ fontSize: '11px', color: 'var(--brand-500)', fontWeight: 500 }}>
• 已被编制人认领
</span>
)}
</div>
{/* 右侧动作流转按钮 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{/* 1. 编制阶段 */}
{ast.lifecycleState === 'drafting' && (
<>
{currentRole === 'author' ? (
<>
{!ast.isClaimed ? (
<button
onClick={onClaim}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
color: 'var(--text)',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '5px',
cursor: 'pointer',
}}
>
<UserCheck size={13} style={{ color: 'var(--brand-500)' }} />
<span>认领此文档</span>
</button>
) : null}
<button
onClick={onSubmitReview}
style={{
padding: '6px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--brand-grad)',
border: 'none',
color: '#fff',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '5px',
cursor: 'pointer',
boxShadow: '0 2px 6px rgba(79, 107, 255, 0.25)',
}}
>
<Send size={13} />
<span>提交送审 (流转至审核人)</span>
</button>
</>
) : (
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
等待编制负责人({ast.author})完成初稿并提交送审
</span>
)}
</>
)}
{/* 2. 审核阶段 */}
{ast.lifecycleState === 'reviewing' && (
<>
<span
style={{
fontSize: '11px',
color: 'var(--text-faint)',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<Lock size={12} /> 文档已锁定
</span>
{currentRole === 'reviewer' ? (
<>
<button
onClick={() => openReject('review')}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--danger-bg)',
border: '1px solid var(--danger)',
color: 'var(--danger)',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: 'pointer',
}}
>
<XCircle size={13} />
<span>驳回修改</span>
</button>
<button
onClick={onPassReview}
style={{
padding: '6px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--amber-500)',
border: 'none',
color: '#fff',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: 'pointer',
boxShadow: '0 2px 6px rgba(242, 169, 59, 0.3)',
}}
>
<CheckCircle2 size={13} />
<span>审核通过 (送呈批准人)</span>
</button>
</>
) : (
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
等待审核负责人({ast.reviewer})完成审核签署
</span>
)}
</>
)}
{/* 3. 批准阶段 */}
{ast.lifecycleState === 'approving' && (
<>
{currentRole === 'approver' ? (
<>
<button
onClick={() => openReject('approve')}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--danger-bg)',
border: '1px solid var(--danger)',
color: 'var(--danger)',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: 'pointer',
}}
>
<XCircle size={13} />
<span>驳回修改</span>
</button>
<button
onClick={onApprove}
style={{
padding: '6px 16px',
borderRadius: 'var(--r-sm)',
background: 'var(--success)',
border: 'none',
color: '#fff',
fontSize: '12.5px',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
gap: '5px',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(33, 166, 106, 0.35)',
}}
>
<Award size={14} />
<span>终审批准生效 (自动入标杆库)</span>
</button>
</>
) : (
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
等待批准负责人({ast.approver})终审批准发布
</span>
)}
</>
)}
{/* 4. 已批准终态 */}
{ast.lifecycleState === 'approved' && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
color: 'var(--success)',
fontSize: '12.5px',
fontWeight: 600,
}}
>
<Award size={16} />
<span>受控归档交付物 (已沉淀为全局标杆经验)</span>
</div>
)}
</div>
</>
)}
</div>
);
};
+208 -51
View File
@@ -1,11 +1,13 @@
import React from 'react';
import { DeliverableItem } from '../../types/workbench';
import { DeliverableItem, DeliverableCategory } from '../../types/workbench';
import { DocLifecycleState } from '../../types/docgov';
import { FolderKanban, FileDown, Maximize2, Minimize2 } from 'lucide-react';
import { FolderKanban, FileDown, Maximize2, Minimize2, Lock, Globe, Shield } from 'lucide-react';
interface SlimTabBarProps {
deliverables: DeliverableItem[];
activeDocId: string;
activeCategory: DeliverableCategory;
onSelectCategory: (category: DeliverableCategory) => void;
onSelectDoc: (docId: string) => void;
onToggleDeckDrawer: () => void;
onExportDocx?: () => void;
@@ -16,6 +18,8 @@ interface SlimTabBarProps {
export const SlimTabBar: React.FC<SlimTabBarProps> = ({
deliverables,
activeDocId,
activeCategory,
onSelectCategory,
onSelectDoc,
onToggleDeckDrawer,
onExportDocx,
@@ -38,6 +42,20 @@ export const SlimTabBar: React.FC<SlimTabBarProps> = ({
}
};
const workflowDocs = deliverables.filter((d) => d.category === 'workflow');
const sharedDocs = deliverables.filter((d) => d.category === 'shared');
const privateDocs = deliverables.filter((d) => d.category === 'private');
const visibleTabs = deliverables.filter((d) => d.category === activeCategory);
const handleCategoryClick = (cat: DeliverableCategory) => {
onSelectCategory(cat);
const targetDocs = deliverables.filter((d) => d.category === cat);
if (targetDocs.length > 0 && !targetDocs.some((d) => d.docId === activeDocId)) {
onSelectDoc(targetDocs[0].docId);
}
};
return (
<div
style={{
@@ -47,62 +65,196 @@ export const SlimTabBar: React.FC<SlimTabBarProps> = ({
display: 'flex',
alignItems: 'center',
padding: '0 8px',
gap: '4px',
gap: '8px',
overflowX: 'auto',
flexShrink: 0,
userSelect: 'none',
}}
>
{/* 交付物 Tabs */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', flex: 1, overflowX: 'auto' }}>
{deliverables.map((doc) => {
const isActive = doc.docId === activeDocId;
const status = getStatusDot(doc.lifecycleState);
{/* 分类切换分段 Pill */}
<div
style={{
display: 'flex',
alignItems: 'center',
background: 'var(--bg-panel)',
padding: '2px',
borderRadius: '6px',
border: '1px solid var(--border)',
gap: '2px',
flexShrink: 0,
}}
>
<button
onClick={() => handleCategoryClick('workflow')}
style={{
height: '24px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
fontSize: '11px',
fontWeight: activeCategory === 'workflow' ? 600 : 500,
background: activeCategory === 'workflow' ? 'var(--brand-500)' : 'transparent',
color: activeCategory === 'workflow' ? '#ffffff' : 'var(--text-muted)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'all 0.15s ease',
}}
title="研发体系预设受控交付物 (全员可见 · 严格审签)"
>
<Shield size={12} />
<span>流程受控</span>
<span
style={{
fontSize: '10px',
padding: '0 4px',
borderRadius: '8px',
background: activeCategory === 'workflow' ? 'rgba(255,255,255,0.25)' : 'var(--bg-subtle)',
color: activeCategory === 'workflow' ? '#ffffff' : 'var(--text-faint)',
}}
>
{workflowDocs.length}
</span>
</button>
return (
<div
key={doc.docId}
onClick={() => onSelectDoc(doc.docId)}
style={{
height: '28px',
padding: '0 10px',
borderRadius: '6px 6px 0 0',
background: isActive ? 'var(--bg-panel)' : 'transparent',
border: isActive ? '1px solid var(--border)' : '1px solid transparent',
borderBottom: isActive ? '1px solid var(--bg-panel)' : '1px solid transparent',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '12px',
fontWeight: isActive ? 600 : 500,
color: isActive ? 'var(--text-strong)' : 'var(--text-muted)',
cursor: 'pointer',
whiteSpace: 'nowrap',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.color = 'var(--text)';
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.color = 'var(--text-muted)';
}}
title={`${doc.title} (${status.label})`}
>
<button
onClick={() => handleCategoryClick('shared')}
style={{
height: '24px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
fontSize: '11px',
fontWeight: activeCategory === 'shared' ? 600 : 500,
background: activeCategory === 'shared' ? 'var(--brand-500)' : 'transparent',
color: activeCategory === 'shared' ? '#ffffff' : 'var(--text-muted)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'all 0.15s ease',
}}
title="由成员对话推演并共享至项目的协作参考资料"
>
<Globe size={12} />
<span>项目共享</span>
<span
style={{
fontSize: '10px',
padding: '0 4px',
borderRadius: '8px',
background: activeCategory === 'shared' ? 'rgba(255,255,255,0.25)' : 'var(--bg-subtle)',
color: activeCategory === 'shared' ? '#ffffff' : 'var(--text-faint)',
}}
>
{sharedDocs.length}
</span>
</button>
<button
onClick={() => handleCategoryClick('private')}
style={{
height: '24px',
padding: '0 8px',
borderRadius: '4px',
border: 'none',
fontSize: '11px',
fontWeight: activeCategory === 'private' ? 600 : 500,
background: activeCategory === 'private' ? '#D97706' : 'transparent',
color: activeCategory === 'private' ? '#ffffff' : 'var(--text-muted)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'all 0.15s ease',
}}
title="您在对话中要求助手生成的私密文档 (仅您本人可见)"
>
<Lock size={12} />
<span>我的私密</span>
<span
style={{
fontSize: '10px',
padding: '0 4px',
borderRadius: '8px',
background: activeCategory === 'private' ? 'rgba(255,255,255,0.25)' : 'var(--bg-subtle)',
color: activeCategory === 'private' ? '#ffffff' : 'var(--text-faint)',
}}
>
{privateDocs.length}
</span>
</button>
</div>
<div style={{ width: '1px', height: '18px', background: 'var(--border)', flexShrink: 0 }} />
{/* 交付物 Tabs (当前分类下的文档) */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', flex: 1, overflowX: 'auto' }}>
{visibleTabs.length === 0 ? (
<div style={{ fontSize: '11px', color: 'var(--text-faint)', padding: '0 8px' }}>
{activeCategory === 'private'
? '暂无个人私密生成文档 (在对话中要求助手生成即可)'
: activeCategory === 'shared'
? '暂无项目共享参考资料'
: '暂无流程文档'}
</div>
) : (
visibleTabs.map((doc) => {
const isActive = doc.docId === activeDocId;
const status = getStatusDot(doc.lifecycleState);
return (
<div
key={doc.docId}
onClick={() => onSelectDoc(doc.docId)}
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: status.color,
boxShadow: `0 0 5px ${status.color}`,
height: '28px',
padding: '0 10px',
borderRadius: '6px 6px 0 0',
background: isActive ? 'var(--bg-panel)' : 'transparent',
border: isActive ? '1px solid var(--border)' : '1px solid transparent',
borderBottom: isActive ? '1px solid var(--bg-panel)' : '1px solid transparent',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '12px',
fontWeight: isActive ? 600 : 500,
color: isActive ? 'var(--text-strong)' : 'var(--text-muted)',
cursor: 'pointer',
whiteSpace: 'nowrap',
transition: 'all 0.15s ease',
}}
/>
<span style={{ maxWidth: '140px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{doc.title}
</span>
</div>
);
})}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.color = 'var(--text)';
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.color = 'var(--text-muted)';
}}
title={`${doc.title} (${doc.category === 'private' ? '仅我可见' : doc.category === 'shared' ? '项目共享' : status.label})`}
>
{doc.category === 'private' ? (
<Lock size={12} color="#D97706" />
) : doc.category === 'shared' ? (
<Globe size={12} color="var(--brand-500)" />
) : (
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: status.color,
boxShadow: `0 0 5px ${status.color}`,
}}
/>
)}
<span style={{ maxWidth: '140px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{doc.title}
</span>
</div>
);
})
)}
</div>
{/* 右侧全局操作区 */}
@@ -122,11 +274,12 @@ export const SlimTabBar: React.FC<SlimTabBarProps> = ({
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: 'pointer',
}}
title="展开当前项目各阶段全部交付物状态大盘"
>
<FolderKanban size={13} style={{ color: 'var(--brand-500)' }} />
<span>全部交付物 ({deliverables.length}) ▾</span>
<span>全景大盘 ({deliverables.length}) ▾</span>
</button>
{/* 导出受控 Word 按钮 */}
@@ -145,11 +298,12 @@ export const SlimTabBar: React.FC<SlimTabBarProps> = ({
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: 'pointer',
}}
title="导出为符合严肃合规模板的受控 DOCX 交付物"
>
<FileDown size={13} />
<span>导出受控 DOCX</span>
<span>导出 DOCX</span>
</button>
)}
@@ -161,10 +315,13 @@ export const SlimTabBar: React.FC<SlimTabBarProps> = ({
width: '26px',
height: '26px',
borderRadius: 'var(--r-sm)',
border: 'none',
background: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-muted)',
cursor: 'pointer',
}}
title={isFullscreen ? '退出全屏' : '全屏沉浸阅读与编辑'}
>
@@ -0,0 +1,607 @@
import { FC, useEffect } from 'react';
import {
X,
ShieldCheck,
FileText,
Briefcase,
ExternalLink,
Info,
} from 'lucide-react';
import { UserProfile, DocPermissionRole } from '../../types/docgov';
import { DeliverableItem } from '../../types/workbench';
interface UserProjectRightsModalProps {
isOpen: boolean;
onClose: () => void;
currentUser: UserProfile;
currentProjectTitle: string;
deliverables: DeliverableItem[];
activeDocId: string;
allUsers?: UserProfile[];
onSwitchUser?: (user: UserProfile) => void;
onSelectDoc: (docId: string) => void;
onRoleChangeForDoc?: (docId: string, role: DocPermissionRole) => void;
}
export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
isOpen,
onClose,
currentUser,
currentProjectTitle,
deliverables,
activeDocId,
allUsers,
onSwitchUser,
onSelectDoc,
onRoleChangeForDoc,
}) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
// Compute stats
const permissions = currentUser.projectPermissions || {};
let authorCount = 0;
let reviewerCount = 0;
let approverCount = 0;
let viewerCount = 0;
deliverables.forEach((item) => {
const role = permissions[item.docId] || (item.author === currentUser.name ? 'author' : 'viewer');
if (role === 'author') authorCount++;
else if (role === 'reviewer') reviewerCount++;
else if (role === 'approver') approverCount++;
else viewerCount++;
});
const getRoleBadge = (role: DocPermissionRole) => {
switch (role) {
case 'author':
return {
label: '✍️ 编制负责人',
color: '#3B82F6',
bg: 'rgba(59, 130, 246, 0.15)',
desc: '拥有正文就地编辑、向智能体发起修改、认领与送审权限',
};
case 'reviewer':
return {
label: '🔍 审核负责人',
color: '#F59E0B',
bg: 'rgba(245, 158, 11, 0.15)',
desc: '文档锁定只读,拥有行内批注、评审通过或驳回权限',
};
case 'approver':
return {
label: '👑 批准负责人',
color: '#A855F7',
bg: 'rgba(168, 85, 247, 0.15)',
desc: '终审决策人,拥有最终批准盖章或否决驳回权限',
};
default:
return {
label: '👁️ 查阅者 (只读)',
color: '#94A3B8',
bg: 'rgba(148, 163, 184, 0.15)',
desc: '无该文档编制或评审职责,拥有全局受控版本查阅权',
};
}
};
return (
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(11, 14, 20, 0.75)',
backdropFilter: 'blur(6px)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
style={{
width: '740px',
maxWidth: '95vw',
maxHeight: '90vh',
backgroundColor: 'var(--bg-panel, #131820)',
borderRadius: '12px',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
animation: 'scaleIn 0.18s ease-out',
}}
>
{/* Header */}
<div
style={{
padding: '16px 20px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: 'var(--bg-subtle, #1A212C)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '8px',
backgroundColor: 'rgba(79, 107, 255, 0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--brand-500)',
}}
>
<ShieldCheck size={20} />
</div>
<div>
<div style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-strong)' }}>
当前账号与项目职责权限矩阵
</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>
用户身份与职位全局固定 · 在不同项目与交付物中履行独立法定职责
</div>
</div>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-muted)',
padding: '6px',
borderRadius: '6px',
display: 'flex',
}}
>
<X size={18} />
</button>
</div>
{/* Content Body */}
<div
style={{
padding: '20px',
overflowY: 'auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '20px',
backgroundColor: 'var(--bg-panel, #131820)',
}}
>
{/* User Profile Card */}
<div
style={{
padding: '14px 18px',
borderRadius: '8px',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-subtle, #1A212C)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '16px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
<div
style={{
width: '46px',
height: '46px',
borderRadius: '50%',
backgroundColor: 'var(--brand-500)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
fontSize: '18px',
boxShadow: '0 2px 8px rgba(79, 107, 255, 0.3)',
}}
>
{currentUser.name.slice(0, 1)}
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '16px', fontWeight: 600, color: 'var(--text-strong)' }}>
{currentUser.name}
</span>
<span
style={{
fontSize: '11px',
color: 'var(--text-muted)',
backgroundColor: 'rgba(148, 163, 184, 0.1)',
padding: '2px 6px',
borderRadius: '4px',
fontFamily: 'var(--font-mono)',
}}
>
工号: {currentUser.employeeId || 'SMDF-0821'}
</span>
</div>
<div
style={{
fontSize: '13px',
color: 'var(--text)',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<Briefcase size={14} color="var(--text-muted)" />
<span>{currentUser.department || '机器人与微创内窥镜研发中心'}</span>
<span style={{ color: 'var(--border)' }}>·</span>
<span style={{ fontWeight: 500, color: 'var(--text-strong)' }}>
{currentUser.position || '资深医疗器械方案工程师'}
</span>
</div>
</div>
</div>
{/* Global Position Pill */}
<div
style={{
fontSize: '11px',
color: 'var(--success)',
backgroundColor: 'var(--success-bg)',
border: '1px solid rgba(53, 199, 126, 0.25)',
padding: '6px 12px',
borderRadius: '6px',
textAlign: 'right',
lineHeight: 1.4,
}}
>
<div>法定资质有效</div>
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>ISO 13485 / GMP 认证</div>
</div>
</div>
{/* Active Project Banner & Stats */}
<div>
<div
style={{
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-strong)',
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span>当前激活项目:{currentProjectTitle}</span>
<span style={{ fontSize: '12px', fontWeight: 400, color: 'var(--text-muted)' }}>
交付物总计: {deliverables.length} 份
</span>
</div>
{/* Quick Stats Badges */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '10px' }}>
<div
style={{
padding: '10px',
borderRadius: '6px',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.25)',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
<span style={{ fontSize: '11px', color: '#60A5FA' }}>待我编制</span>
<span style={{ fontSize: '18px', fontWeight: 700, color: '#60A5FA' }}>{authorCount} 份</span>
</div>
<div
style={{
padding: '10px',
borderRadius: '6px',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.25)',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
<span style={{ fontSize: '11px', color: '#FBBF24' }}>待我审核</span>
<span style={{ fontSize: '18px', fontWeight: 700, color: '#FBBF24' }}>{reviewerCount} 份</span>
</div>
<div
style={{
padding: '10px',
borderRadius: '6px',
backgroundColor: 'rgba(168, 85, 247, 0.1)',
border: '1px solid rgba(168, 85, 247, 0.25)',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
<span style={{ fontSize: '11px', color: '#C084FC' }}>待我批准</span>
<span style={{ fontSize: '18px', fontWeight: 700, color: '#C084FC' }}>{approverCount} 份</span>
</div>
<div
style={{
padding: '10px',
borderRadius: '6px',
backgroundColor: 'rgba(148, 163, 184, 0.1)',
border: '1px solid rgba(148, 163, 184, 0.25)',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
<span style={{ fontSize: '11px', color: '#94A3B8' }}>查阅权限</span>
<span style={{ fontSize: '18px', fontWeight: 700, color: '#94A3B8' }}>{viewerCount} 份</span>
</div>
</div>
</div>
{/* Document Responsibilities Table */}
<div>
<div
style={{
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-strong)',
marginBottom: '10px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<FileText size={15} color="var(--brand-500)" />
<span>本项目的交付物分工与法定操作权限</span>
</div>
<div
style={{
borderRadius: '8px',
border: '1px solid var(--border)',
overflow: 'hidden',
backgroundColor: 'var(--bg-card, #151B25)',
}}
>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '12px' }}>
<thead>
<tr
style={{
backgroundColor: 'var(--bg-subtle, #1A212C)',
borderBottom: '1px solid var(--border)',
textAlign: 'left',
color: 'var(--text-muted)',
}}
>
<th style={{ padding: '8px 12px', width: '90px' }}>阶段</th>
<th style={{ padding: '8px 12px' }}>受控交付物</th>
<th style={{ padding: '8px 12px', width: '180px' }}>您在此文档的法定职责</th>
<th style={{ padding: '8px 12px', width: '90px', textAlign: 'center' }}>快捷操作</th>
</tr>
</thead>
<tbody>
{deliverables.map((item, idx) => {
const role =
permissions[item.docId] ||
(item.author === currentUser.name
? 'author'
: item.reviewer === currentUser.name
? 'reviewer'
: 'viewer');
const badge = getRoleBadge(role);
const isCurrentActive = item.docId === activeDocId;
return (
<tr
key={item.docId}
style={{
borderBottom: idx === deliverables.length - 1 ? 'none' : '1px solid var(--border)',
backgroundColor: isCurrentActive ? 'rgba(79, 107, 255, 0.08)' : 'transparent',
}}
>
<td style={{ padding: '10px 12px', color: 'var(--text)' }}>
<span
style={{
padding: '2px 6px',
borderRadius: '4px',
backgroundColor: 'rgba(148, 163, 184, 0.1)',
fontSize: '11px',
fontWeight: 500,
}}
>
{item.stage}
</span>
</td>
<td style={{ padding: '10px 12px' }}>
<div style={{ fontWeight: 500, color: 'var(--text-strong)' }}>
{item.title}
{isCurrentActive && (
<span
style={{
marginLeft: '6px',
fontSize: '10px',
color: 'var(--brand-500)',
backgroundColor: 'rgba(79, 107, 255, 0.15)',
padding: '1px 5px',
borderRadius: '3px',
}}
>
当前打开
</span>
)}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
状态: {item.lifecycleState}
</div>
</td>
<td style={{ padding: '10px 12px' }}>
{onRoleChangeForDoc ? (
<select
value={role}
onChange={(e) => onRoleChangeForDoc(item.docId, e.target.value as DocPermissionRole)}
style={{
padding: '4px 8px',
borderRadius: '4px',
border: '1px solid var(--border)',
backgroundColor: badge.bg,
color: badge.color,
fontWeight: 500,
fontSize: '11px',
cursor: 'pointer',
}}
title="演练测试:支持切换该文档下的负责角色"
>
<option value="author">✍️ 编制负责人</option>
<option value="reviewer">🔍 审核负责人</option>
<option value="approver">👑 批准负责人</option>
<option value="viewer">👁️ 查阅者 (只读)</option>
</select>
) : (
<span
style={{
display: 'inline-block',
padding: '3px 8px',
borderRadius: '4px',
backgroundColor: badge.bg,
color: badge.color,
fontWeight: 500,
fontSize: '11px',
}}
title={badge.desc}
>
{badge.label}
</span>
)}
</td>
<td style={{ padding: '10px 12px', textAlign: 'center' }}>
<button
onClick={() => {
onSelectDoc(item.docId);
onClose();
}}
style={{
padding: '4px 8px',
borderRadius: '4px',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-subtle, #1A212C)',
color: 'var(--text)',
cursor: 'pointer',
fontSize: '11px',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
}}
>
<ExternalLink size={12} />
<span>前往</span>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Explanatory Tip */}
<div
style={{
padding: '10px 14px',
borderRadius: '6px',
backgroundColor: 'rgba(79, 107, 255, 0.08)',
border: '1px solid rgba(79, 107, 255, 0.2)',
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
fontSize: '11px',
color: 'var(--text)',
lineHeight: 1.5,
}}
>
<Info size={14} color="var(--brand-500)" style={{ flexShrink: 0, marginTop: '2px' }} />
<div>
<strong>权限与操作联动说明:</strong>
当您切换查看某份文档时,右侧工作栏将实时根据您在该文档上的法定职责赋予对应权限——如在《注册评估》中作为编制人可就地编辑与送审;在《竞品调研》中作为审核人则只读锁定并开放批注与通过/驳回动作。
</div>
</div>
</div>
{/* Footer */}
<div
style={{
padding: '12px 20px',
borderTop: '1px solid var(--border)',
backgroundColor: 'var(--bg-subtle, #1A212C)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
{allUsers && allUsers.length > 0 ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>切换当前账号测试隔离:</span>
{allUsers.map((u) => {
const isCurrent = u.id === currentUser.id;
return (
<button
key={u.id}
onClick={() => onSwitchUser?.(u)}
style={{
padding: '4px 10px',
borderRadius: '4px',
border: isCurrent ? '1px solid var(--brand-500)' : '1px solid var(--border)',
backgroundColor: isCurrent ? 'var(--brand-500)' : 'var(--bg-panel, #131820)',
color: isCurrent ? '#fff' : 'var(--text)',
fontSize: '11px',
fontWeight: isCurrent ? 600 : 400,
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
{u.name} ({u.position.slice(0, 4)})
</button>
);
})}
</div>
) : <div />}
<button
onClick={onClose}
style={{
padding: '6px 16px',
borderRadius: '6px',
backgroundColor: 'var(--brand-500)',
border: 'none',
color: '#fff',
fontSize: '12px',
fontWeight: 500,
cursor: 'pointer',
}}
>
关闭面板
</button>
</div>
</div>
</div>
);
};