feat(web): complete in-place block editing, reviewer comments, and role governance loop

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-08 14:50:48 +08:00
co-authored by Cursor
parent 267b8c89a5
commit 916bddc04f
5 changed files with 1508 additions and 21 deletions
+573 -21
View File
@@ -1,30 +1,582 @@
import { useState } 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 { 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';
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 default function App() {
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
// 会话与用户状态
const [sessions, setSessions] = useState<ProjectSession[]>([
{
sessionId: 'sess-001',
projectId: 1,
title: '4K超高清荧光内窥镜系统',
deviceType: '硬管荧光腹腔内窥镜',
stage: 'V0.5',
stageLabel: 'V0.5 方案成型与法规对齐',
messageCount: 5,
updatedAt: '14:20',
},
{
sessionId: 'sess-002',
projectId: 2,
title: '便携式掌上无线超声诊断仪',
deviceType: '便携式超声探头',
stage: 'V0.1',
stageLabel: 'V0.1 需求摸底与产品定义',
messageCount: 3,
updatedAt: '昨天',
},
]);
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
document.documentElement.setAttribute('data-theme', next);
const [activeSessionId, setActiveSessionId] = useState('sess-001');
const [currentRole, setCurrentRole] = useState<UserRole>('author');
const [currentUserName, setCurrentUserName] = useState('张工');
// 交付物状态大盘
const [deliverables, setDeliverables] = useState<DeliverableItem[]>([
{
docId: 'doc-001',
taskKey: 'feasibility_reg',
title: '注册路径可行性评估报告',
stage: 'V0.5',
isFixedWorkflow: true,
lifecycleState: 'drafting',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 14:15',
summary: '针对4K荧光腹腔镜分类界定、临床评价路径与法规标准准入全景评估。',
},
{
docId: 'doc-002',
taskKey: 'research',
title: '微创腹腔镜全球竞品深度调研',
stage: 'V0.5',
isFixedWorkflow: true,
lifecycleState: 'reviewing',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 11:30',
summary: '针对史赛克1688、卡尔史托斯Rubina荧光内窥镜性能全方位对比分析。',
},
{
docId: 'doc-003',
taskKey: 'prelim_design',
title: '光学系统初步设计规格书',
stage: 'V0.5',
isFixedWorkflow: true,
lifecycleState: 'approved',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
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',
lifecycleState: 'drafting',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
})
);
// 模态弹窗与侧栏
const [editingBlock, setEditingBlock] = useState<DocumentBlock | null>(null);
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
const [pendingProposal, setPendingProposal] = useState<{
changes: string;
impact: string;
risk: string;
patchBlockId?: string;
} | null>(null);
// 消息流与思考脉冲
const [isThinking, setIsThinking] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([
{
id: 'm-0',
sessionId: 'sess-001',
role: 'system',
content: '水木东方协同参谋小队已就位,当前项目进入【V0.5 方案成型与法规对齐】阶段。',
timestamp: '14:00',
},
{
id: 'm-1',
sessionId: 'sess-001',
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🎯 推进立项阶段',
content:
'张工您好!参谋小队已依据法规知识库完成《4K超高清荧光内窥镜系统立项与可行性报告》初稿编制。右侧已就绪块状结构化视图,您可以直接审阅或下达修改指令。',
timestamp: '14:02',
phaseEvents: [
{ step: '1', icon: '👥', label: '组装小队', status: 'completed' },
{ step: '2', icon: '📚', label: '检索法规', status: 'completed' },
{ step: '3', icon: '⚙️', label: '执行编制', status: 'completed' },
{ step: '4', icon: '🛡️', label: '门禁把关', status: 'completed' },
],
recommendedCases: [
{
docId: 'SMDF-MD-2025-09',
taskKey: 'feasibility_reg',
artifactTitle: '4K硬管内窥镜摄像系统注册申报经验',
sectionTitle: '电气安全与高频电刀抗扰度评价',
similarityScore: 0.89,
keyRegulations: ['GB 9706.1-2020', 'IEC 60601-1-2'],
summarySnippet:
'在型式检验中,腹腔内窥镜主机需特别注意电刀高频耦合对荧光信噪比的干扰,建议在前端光电接收处增加硬件陷波滤波。',
},
],
},
]);
// 发送消息与对话驱动双轨逻辑
const handleSendMessage = (text: string, attachments?: { name: string; size: string; type: string }[]) => {
const userMsg: ChatMessage = {
id: 'msg-' + Date.now(),
sessionId: activeSessionId,
role: 'user',
authorName: currentUserName,
content: text,
attachments,
timestamp: new Date().toTimeString().substring(0, 5),
};
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 });
}
setMessages((prev) => [
...prev,
{
id: 'reply-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
intentTag: '💡 评审批注挂载',
content: `李主管,已为您将审核意见记录并挂载在右侧受控文档【${targetBlock?.numbering || ''} ${targetBlock?.content.substring(0, 16)}...】章节旁,编制人随时可见。`,
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) {
setPendingProposal({
changes: `根据您的指令【${text}】,拟将第二章关键技术指标补充 4K 荧光抗扰度测试参数,并在表格中增加检验依据条款。`,
impact: '将直接影响后续型检大纲覆盖率,增加 IEC 60601-1-2 第 8 节要求。',
risk: '低风险合规强化项,无需重跑临床验证。',
patchBlockId: docAST.rootBlock.children?.[4]?.id,
});
setMessages((prev) => [
...prev,
{
id: 'reply-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
intentTag: '🛡️ 认领变更拦截',
content:
'检测到本文档已被张工认领受控。依据认领保护机制,已为您生成《变更提案》。请在右侧下方或对话卡片中确认是否采纳本次变更。',
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
return;
}
}
// 常规参谋解答
setMessages((prev) => [
...prev,
{
id: 'reply-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
intentTag: '💡 参谋解答',
content: `收到您的指令:“${text}”。参谋小队已依据医疗器械法规标准进行综合排查,相关指标已实时对齐。您可随时在右侧文档栏双击块进行微调。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
}, 1200);
};
// 采纳变更提案
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);
setMessages((prev) => [
...prev,
{
id: 'prop-acc-' + Date.now(),
sessionId: activeSessionId,
role: 'system',
content: '✅ 编制负责人已确认并采纳智能体变更提案,右侧受控文档已实时更新完成。',
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);
if (idx !== -1) {
children[idx] = updated;
recomputeNumbering(docAST.rootBlock);
setDocAST({ ...docAST });
}
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: '新插入的受控文档段落文本,双击可就地编辑...',
};
const targetIdx = position === 'above' ? index : index + 1;
children.splice(targetIdx, 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 / Markdown
const handleExportDocx = () => {
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);
};
return (
<div style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
<h1>水木东方 · 医疗器械现代 AI 协同工作台</h1>
<p>当前主题: {theme}</p>
<button
onClick={toggleTheme}
style={{
width: '120px',
padding: '8px 16px',
background: 'var(--brand-500)',
color: '#fff',
borderRadius: 'var(--r-md)',
}}
>
切换主题
</button>
</div>
<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)}
/>
</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}
/>
{/* 块状文档渲染器 */}
<BlockRenderer
ast={docAST}
currentRole={currentRole}
onEditBlock={setEditingBlock}
onInsertBlock={handleInsertBlock}
onMoveBlock={handleMoveBlock}
onDeleteBlock={handleDeleteBlock}
onOpenComments={setCommentingBlock}
/>
{/* 底部法定流转动作条 */}
<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>
}
/>
);
}
+277
View File
@@ -0,0 +1,277 @@
import React, { useState } from 'react';
import { DocumentBlock } from '../../types/block';
import { X, Check, Table, Heading, AlignLeft, ShieldAlert } from 'lucide-react';
interface BlockEditorModalProps {
block: DocumentBlock;
onSave: (updated: DocumentBlock) => void;
onClose: () => void;
}
export const BlockEditorModal: React.FC<BlockEditorModalProps> = ({ block, onSave, onClose }) => {
const [content, setContent] = useState(block.content || '');
const [level, setLevel] = useState(block.level || 1);
const [tableData, setTableData] = useState<string[][]>(
block.properties?.tableData ? JSON.parse(JSON.stringify(block.properties.tableData)) : []
);
const handleSave = () => {
const updated: DocumentBlock = {
...block,
content,
level: block.type === 'heading' ? level : block.level,
properties: {
...block.properties,
tableData: block.type === 'table' ? tableData : block.properties?.tableData,
},
metadata: {
...block.metadata,
lastModifiedBy: '张工 (编制负责人)',
lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
},
};
onSave(updated);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
};
const updateTableCell = (r: number, c: number, val: string) => {
const next = [...tableData];
next[r][c] = val;
setTableData(next);
};
const addTableRow = () => {
if (tableData.length === 0) return;
const colCount = tableData[0].length;
setTableData([...tableData, Array.from({ length: colCount }, () => '新数据')]);
};
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(4px)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px',
}}
onKeyDown={handleKeyDown}
>
<div
style={{
width: '640px',
maxHeight: '80vh',
background: 'var(--bg-panel)',
borderRadius: 'var(--r-lg)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* 弹窗顶栏 */}
<div
style={{
padding: '14px 18px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--bg-subtle)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{block.type === 'heading' && <Heading size={16} style={{ color: 'var(--brand-500)' }} />}
{block.type === 'table' && <Table size={16} style={{ color: 'var(--brand-500)' }} />}
{block.type === 'callout' && <ShieldAlert size={16} style={{ color: 'var(--amber-500)' }} />}
{block.type === 'paragraph' && <AlignLeft size={16} style={{ color: 'var(--brand-500)' }} />}
<span style={{ fontSize: '14px', fontWeight: 700, color: 'var(--text-strong)' }}>
编辑文档块 ({block.type.toUpperCase()})
</span>
</div>
<button
onClick={onClose}
style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}
>
<X size={16} />
</button>
</div>
{/* 弹窗主体表单 */}
<div
style={{
padding: '18px',
flex: 1,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '14px',
}}
>
{block.type === 'heading' && (
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<label style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>
标题层级:
</label>
<div style={{ display: 'flex', gap: '4px' }}>
{[1, 2, 3, 4, 5, 6].map((lvl) => (
<button
key={lvl}
onClick={() => setLevel(lvl)}
style={{
padding: '4px 10px',
borderRadius: 'var(--r-sm)',
background: level === lvl ? 'var(--brand-grad)' : 'var(--bg-subtle)',
color: level === lvl ? '#fff' : 'var(--text)',
fontWeight: 600,
fontSize: '12px',
border: '1px solid var(--border)',
}}
>
H{lvl}
</button>
))}
</div>
</div>
)}
{block.type === 'table' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>
就地表格矩阵编辑 (三线表规范):
</span>
<button
onClick={addTableRow}
style={{
fontSize: '11px',
padding: '3px 8px',
borderRadius: 'var(--r-sm)',
background: 'var(--brand-50)',
color: 'var(--brand-600)',
fontWeight: 600,
}}
>
+ 添加一行
</button>
</div>
<div style={{ overflowX: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '12px' }}>
<tbody>
{tableData.map((row, r) => (
<tr key={r} style={{ borderBottom: '1px solid var(--border)' }}>
{row.map((cell, c) => (
<td key={c} style={{ padding: '4px', borderRight: '1px solid var(--border)' }}>
<input
type="text"
value={cell}
onChange={(e) => updateTableCell(r, c, e.target.value)}
style={{
width: '100%',
padding: '4px 6px',
border: '1px solid transparent',
background: r === 0 ? 'var(--bg-subtle)' : 'transparent',
color: 'var(--text-strong)',
fontWeight: r === 0 ? 600 : 400,
}}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>
块内容文本:
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={8}
style={{
width: '100%',
padding: '10px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
color: 'var(--text-strong)',
fontSize: '13.5px',
lineHeight: 1.6,
resize: 'vertical',
}}
/>
</div>
)}
</div>
{/* 弹窗底部操作 */}
<div
style={{
padding: '12px 18px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-subtle)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span style={{ fontSize: '11px', color: 'var(--text-faint)' }}>
快捷键: Ctrl/Cmd + S 保存修改
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={onClose}
style={{
padding: '6px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
color: 'var(--text)',
fontSize: '12.5px',
fontWeight: 500,
}}
>
取消
</button>
<button
onClick={handleSave}
style={{
padding: '6px 16px',
borderRadius: 'var(--r-sm)',
background: 'var(--brand-grad)',
color: '#fff',
fontSize: '12.5px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '4px',
boxShadow: '0 2px 6px rgba(79, 107, 255, 0.3)',
}}
>
<Check size={14} />
<span>保存修改</span>
</button>
</div>
</div>
</div>
</div>
);
};
+226
View File
@@ -0,0 +1,226 @@
import React, { useState } from 'react';
import { DocumentBlock, BlockComment } from '../../types/block';
import { UserRole } from '../../types/docgov';
import { X, Send, CheckCircle2, MessageSquare } from 'lucide-react';
interface CommentSidePanelProps {
block: DocumentBlock;
currentRole: UserRole;
currentUserName: string;
onAddComment: (comment: Omit<BlockComment, 'id' | 'createdAt'>) => void;
onClose: () => void;
}
export const CommentSidePanel: React.FC<CommentSidePanelProps> = ({
block,
currentRole,
currentUserName,
onAddComment,
onClose,
}) => {
const [content, setContent] = useState('');
const [type, setType] = useState<'suggestion' | 'issue' | 'approval'>('suggestion');
const comments = block.comments || [];
const handleSend = () => {
if (!content.trim()) return;
onAddComment({
authorName: currentUserName,
authorRole: currentRole === 'author' ? 'reviewer' : (currentRole as 'reviewer' | 'approver'),
type,
content: content.trim(),
resolved: false,
});
setContent('');
};
return (
<div
style={{
position: 'absolute',
top: '36px',
bottom: 0,
right: 0,
width: '320px',
background: 'var(--bg-panel)',
borderLeft: '1px solid var(--border)',
boxShadow: 'var(--shadow-md)',
zIndex: 40,
display: 'flex',
flexDirection: 'column',
}}
>
{/* 顶栏 */}
<div
style={{
padding: '12px 14px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--bg-subtle)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<MessageSquare size={15} style={{ color: 'var(--amber-500)' }} />
<span style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-strong)' }}>
审核人批注与评审意见
</span>
</div>
<button
onClick={onClose}
style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}
>
<X size={15} />
</button>
</div>
{/* 目标块摘录 */}
<div style={{ padding: '8px 14px', background: 'var(--bg-subtle)', borderBottom: '1px solid var(--border-light)', fontSize: '11px', color: 'var(--text-muted)' }}>
<span>锚定块: </span>
<span style={{ color: 'var(--text-strong)', fontWeight: 600 }}>
{block.numbering ? `${block.numbering} ` : ''}
{block.content.substring(0, 30)}...
</span>
</div>
{/* 批注列表 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '12px 14px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
}}
>
{comments.length === 0 ? (
<div style={{ textAlign: 'center', padding: '32px 0', color: 'var(--text-faint)', fontSize: '12px' }}>
该块暂无审核批注
</div>
) : (
comments.map((c) => (
<div
key={c.id}
style={{
padding: '10px 12px',
borderRadius: 'var(--r-md)',
background: 'var(--bg-card)',
border: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
gap: '5px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-strong)' }}>
{c.authorName} ({c.authorRole === 'reviewer' ? '审核' : '批准'})
</span>
<span
style={{
fontSize: '10px',
padding: '1px 6px',
borderRadius: 'var(--r-pill)',
background:
c.type === 'issue'
? 'var(--danger-bg)'
: c.type === 'approval'
? 'var(--success-bg)'
: 'var(--amber-50)',
color:
c.type === 'issue'
? 'var(--danger)'
: c.type === 'approval'
? 'var(--success)'
: 'var(--amber-600)',
fontWeight: 600,
}}
>
{c.type === 'issue' ? '合规卡点' : c.type === 'approval' ? '同意意见' : '修改建议'}
</span>
</div>
<div style={{ fontSize: '12.5px', color: 'var(--text)', lineHeight: 1.5 }}>
{c.content}
</div>
<div style={{ fontSize: '10.5px', color: 'var(--text-faint)', display: 'flex', justifyContent: 'space-between' }}>
<span>{c.createdAt}</span>
{c.resolved ? (
<span style={{ color: 'var(--success)', display: 'flex', alignItems: 'center', gap: '2px' }}>
<CheckCircle2 size={11} /> 已采纳解决
</span>
) : (
<span style={{ color: 'var(--amber-500)' }}>待处理</span>
)}
</div>
</div>
))
)}
</div>
{/* 底部新增批注输入 */}
<div style={{ padding: '12px 14px', borderTop: '1px solid var(--border)', background: 'var(--bg-subtle)', display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', gap: '4px' }}>
{(['suggestion', 'issue', 'approval'] as const).map((t) => (
<button
key={t}
onClick={() => setType(t)}
style={{
flex: 1,
padding: '4px',
borderRadius: 'var(--r-sm)',
fontSize: '11px',
fontWeight: 600,
background: type === t ? 'var(--bg-panel)' : 'transparent',
color: type === t ? 'var(--brand-500)' : 'var(--text-muted)',
border: type === t ? '1px solid var(--border)' : '1px solid transparent',
}}
>
{t === 'issue' ? '合规卡点' : t === 'approval' ? '同意意见' : '修改建议'}
</button>
))}
</div>
<div style={{ display: 'flex', gap: '6px' }}>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={`作为 ${currentUserName} 提出批注意见...`}
rows={2}
style={{
flex: 1,
padding: '6px 8px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'var(--bg-panel)',
fontSize: '12px',
color: 'var(--text)',
resize: 'none',
}}
/>
<button
onClick={handleSend}
style={{
width: '32px',
borderRadius: 'var(--r-sm)',
background: 'var(--brand-grad)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
title="发送批注"
>
<Send size={14} />
</button>
</div>
</div>
</div>
);
};
+347
View File
@@ -0,0 +1,347 @@
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';
interface DocGovActionBarProps {
ast: DocumentAST;
currentRole: UserRole;
onClaim: () => void;
onSubmitReview: () => void;
onPassReview: () => void;
onRejectReview: (reason: string) => void;
onApprove: () => void;
onRejectApprove: (reason: string) => void;
}
export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
ast,
currentRole,
onClaim,
onSubmitReview,
onPassReview,
onRejectReview,
onApprove,
onRejectApprove,
}) => {
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [rejectTarget, setRejectTarget] = useState<'review' | 'approve'>('review');
const openReject = (target: 'review' | 'approve') => {
setRejectTarget(target);
setRejectReason('');
setRejectModalOpen(true);
};
const confirmReject = () => {
if (!rejectReason.trim()) return;
if (rejectTarget === 'review') {
onRejectReview(rejectReason.trim());
} else {
onRejectApprove(rejectReason.trim());
}
setRejectModalOpen(false);
};
return (
<div
style={{
position: 'sticky',
bottom: 0,
left: 0,
right: 0,
background: 'var(--bg-panel)',
borderTop: '1px solid var(--border)',
padding: '10px 18px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: '0 -4px 16px rgba(0, 0, 0, 0.06)',
zIndex: 30,
}}
>
{/* 驳回理由弹窗 */}
{rejectModalOpen && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.5)',
zIndex: 110,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
width: '420px',
background: 'var(--bg-panel)',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
boxShadow: 'var(--shadow-lg)',
}}
>
<div style={{ fontSize: '14px', fontWeight: 700, color: 'var(--danger)' }}>
请填写法定驳回原因 (必填)
</div>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="说明驳回修改的具体意见,例如:缺少第3章电气安全检验标准,或技术参数指标不符合临床预期..."
rows={4}
style={{
width: '100%',
padding: '8px 10px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
color: 'var(--text)',
fontSize: '12.5px',
resize: 'none',
}}
/>
<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)' }}
>
取消
</button>
<button
onClick={confirmReject}
disabled={!rejectReason.trim()}
style={{
padding: '6px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--danger)',
color: '#fff',
fontWeight: 600,
opacity: rejectReason.trim() ? 1 : 0.5,
}}
>
确认驳回退回编制
</button>
</div>
</div>
</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} /> 文档已锁定
</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>
);
};
@@ -0,0 +1,85 @@
import React from 'react';
import { ShieldAlert, Check, X } from 'lucide-react';
interface ProposalConfirmCardProps {
proposedChanges: string;
impactAnalysis: string;
potentialRisk: string;
onAccept: () => void;
onReject: () => void;
}
export const ProposalConfirmCard: React.FC<ProposalConfirmCardProps> = ({
proposedChanges,
impactAnalysis,
potentialRisk,
onAccept,
onReject,
}) => {
return (
<div
style={{
margin: '12px 0',
padding: '14px 16px',
borderRadius: 'var(--r-md)',
background: 'var(--amber-50)',
border: '1.5px solid var(--amber-500)',
boxShadow: 'var(--shadow-sm)',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--amber-600)', fontWeight: 700, fontSize: '13px' }}>
<ShieldAlert size={16} />
<span>【认领保护拦截】智能体小队拟发起文档变更提案,请编制负责人确认</span>
</div>
<div style={{ fontSize: '12.5px', color: 'var(--text-strong)', lineHeight: 1.5 }}>
<div><b>• 拟修改内容:</b> {proposedChanges}</div>
<div><b>• 预期合规影响:</b> {impactAnalysis}</div>
<div><b>• 潜在风险提示:</b> {potentialRisk}</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' }}>
<button
onClick={onReject}
style={{
padding: '5px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
color: 'var(--text)',
fontSize: '12px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<X size={13} />
<span>拒绝变更</span>
</button>
<button
onClick={onAccept}
style={{
padding: '5px 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)',
}}
>
<Check size={13} />
<span>批准并采纳修改</span>
</button>
</div>
</div>
);
};