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:
+573
-21
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user