diff --git a/web/src/components/doc/BlockRenderer.tsx b/web/src/components/doc/BlockRenderer.tsx new file mode 100644 index 00000000..83430c01 --- /dev/null +++ b/web/src/components/doc/BlockRenderer.tsx @@ -0,0 +1,220 @@ +import React, { useState } from 'react'; +import { DocumentAST, DocumentBlock } from '../../types/block'; +import { UserRole } from '../../types/docgov'; +import { HeadingBlockView } from './blocks/HeadingBlockView'; +import { ParagraphBlockView } from './blocks/ParagraphBlockView'; +import { TableBlockView } from './blocks/TableBlockView'; +import { CalloutBlockView } from './blocks/CalloutBlockView'; +import { MermaidBlockView } from './blocks/MermaidBlockView'; +import { Plus, ArrowUp, ArrowDown, Trash2, MessageSquare } from 'lucide-react'; + +interface BlockRendererProps { + ast: DocumentAST; + currentRole: UserRole; + onEditBlock: (block: DocumentBlock) => void; + onInsertBlock: (index: number, position: 'above' | 'below') => void; + onMoveBlock: (index: number, direction: 'up' | 'down') => void; + onDeleteBlock: (index: number) => void; + onOpenComments: (block: DocumentBlock) => void; +} + +export const BlockRenderer: React.FC = ({ + ast, + currentRole, + onEditBlock, + onInsertBlock, + onMoveBlock, + onDeleteBlock, + onOpenComments, +}) => { + const [hoveredIndex, setHoveredIndex] = useState(null); + + const isEditable = currentRole === 'author' && ast.lifecycleState === 'drafting'; + const children = ast.rootBlock.children || []; + + const renderBlockContent = (block: DocumentBlock) => { + switch (block.type) { + case 'heading': + return ; + case 'table': + return ; + case 'callout': + return ; + case 'mermaid': + return ; + case 'divider': + return
; + case 'paragraph': + default: + return ; + } + }; + + return ( +
+ {/* 顶部受控文件大标题与状态条 */} +
+
+
+ + 受控版本 {ast.version} + + + 更新时间: {ast.updatedAt} + +
+ +
+ 编制人: {ast.author} + 审核人: {ast.reviewer} + 批准人: {ast.approver} +
+
+ + {/* 认领或锁定提示 */} + {ast.lifecycleState !== 'drafting' ? ( +
+ 🔒 本文档当前处于【{ast.lifecycleState === 'reviewing' ? '审核中' : ast.lifecycleState === 'approving' ? '批准中' : '已生效受控'}】锁定状态,正文只读,禁止篡改。 +
+ ) : isEditable ? ( +
+ 💡 提示:双击任意段落或标题可就地激活编辑模式;鼠标悬停可增删或调换章节顺序。 +
+ ) : null} +
+ + {/* 块流 */} + {children.map((block, idx) => { + const isHovered = hoveredIndex === idx; + const commentCount = block.comments?.length || 0; + + return ( +
setHoveredIndex(idx)} + onMouseLeave={() => setHoveredIndex(null)} + onDoubleClick={() => { + if (isEditable) { + onEditBlock(block); + } + }} + style={{ + position: 'relative', + borderRadius: 'var(--r-sm)', + padding: '2px 6px', + margin: '2px -6px', + border: isHovered && isEditable ? '1px dashed var(--brand-500)' : '1px solid transparent', + background: isHovered && isEditable ? 'var(--bg-hover)' : 'transparent', + transition: 'background 0.15s ease, border-color 0.15s ease', + cursor: isEditable ? 'text' : 'default', + }} + > + {/* 块微操作浮动条 */} + {isHovered && ( +
+ {isEditable && ( + <> + + {idx > 0 && ( + + )} + {idx < children.length - 1 && ( + + )} + + + )} + + {/* 批注按钮 (所有角色均可点击查看/添加) */} + +
+ )} + + {/* 块正文内容 */} + {renderBlockContent(block)} +
+ ); + })} +
+ ); +}; diff --git a/web/src/components/doc/DeckDrawer.tsx b/web/src/components/doc/DeckDrawer.tsx new file mode 100644 index 00000000..0ca89e3d --- /dev/null +++ b/web/src/components/doc/DeckDrawer.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import { DeliverableItem } from '../../types/workbench'; +import { DocLifecycleState } from '../../types/docgov'; +import { X, CheckCircle2, Clock, ShieldCheck, UserCheck, AlertTriangle } from 'lucide-react'; + +interface DeckDrawerProps { + isOpen: boolean; + onClose: () => void; + deliverables: DeliverableItem[]; + activeDocId: string; + onSelectDoc: (docId: string) => void; +} + +export const DeckDrawer: React.FC = ({ + isOpen, + onClose, + deliverables, + activeDocId, + onSelectDoc, +}) => { + if (!isOpen) { + return null; + } + + const renderBadge = (state: DocLifecycleState, isClaimed: boolean) => { + switch (state) { + case 'approved': + return ( + + + 终审已生效 + + ); + case 'reviewing': + return ( + + + 审核中 (已锁定) + + ); + case 'approving': + return ( + + + 批准中 + + ); + case 'rejected': + return ( + + + 被驳回需修改 + + ); + case 'drafting': + default: + return ( + + + 编制中 {isClaimed ? '(已认领)' : '(未认领)'} + + ); + } + }; + + // 按阶段分组 + 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工时测算' }, + ]; + + return ( +
+ {/* 抽屉顶栏 */} +
+
+ + 当前项目阶段交付物台账大盘 + + + (共 {deliverables.length} 份受控交付物) + +
+ +
+ + {/* 交付物卡片列表 */} +
+ {stages.map((stg) => { + const docsInStage = deliverables.filter((d) => d.stage === stg.stage); + if (docsInStage.length === 0) return null; + + return ( +
+
+ {stg.title} +
+ +
+ {docsInStage.map((doc) => { + const isActive = doc.docId === activeDocId; + return ( +
{ + 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)'; + }} + > +
+ + {doc.title} + + {renderBadge(doc.lifecycleState, doc.isClaimed)} +
+ +
+ 编制: {doc.author} + 审核: {doc.reviewer} + 批准: {doc.approver} +
+ + {doc.summary && ( +
+ {doc.summary} +
+ )} +
+ ); + })} +
+
+ ); + })} +
+
+ ); +}; diff --git a/web/src/components/doc/SlimTabBar.tsx b/web/src/components/doc/SlimTabBar.tsx new file mode 100644 index 00000000..f7a42ee6 --- /dev/null +++ b/web/src/components/doc/SlimTabBar.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { DeliverableItem } from '../../types/workbench'; +import { DocLifecycleState } from '../../types/docgov'; +import { FolderKanban, FileDown, Maximize2, Minimize2 } from 'lucide-react'; + +interface SlimTabBarProps { + deliverables: DeliverableItem[]; + activeDocId: string; + onSelectDoc: (docId: string) => void; + onToggleDeckDrawer: () => void; + onExportDocx?: () => void; + isFullscreen?: boolean; + onToggleFullscreen?: () => void; +} + +export const SlimTabBar: React.FC = ({ + deliverables, + activeDocId, + onSelectDoc, + onToggleDeckDrawer, + onExportDocx, + isFullscreen, + onToggleFullscreen, +}) => { + const getStatusDot = (state: DocLifecycleState) => { + switch (state) { + case 'approved': + return { color: 'var(--success)', label: '已生效受控' }; + case 'reviewing': + return { color: 'var(--amber-500)', label: '审核中' }; + case 'approving': + return { color: '#A855F7', label: '批准中' }; + case 'rejected': + return { color: 'var(--danger)', label: '已驳回' }; + case 'drafting': + default: + return { color: 'var(--brand-500)', label: '编制中' }; + } + }; + + return ( +
+ {/* 交付物 Tabs */} +
+ {deliverables.map((doc) => { + const isActive = doc.docId === activeDocId; + const status = getStatusDot(doc.lifecycleState); + + return ( +
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})`} + > +
+ + {doc.title} + +
+ ); + })} +
+ + {/* 右侧全局操作区 */} +
+ {/* 全部交付物大盘抽屉入口 */} + + + {/* 导出受控 Word 按钮 */} + {onExportDocx && ( + + )} + + {/* 全屏切换 */} + {onToggleFullscreen && ( + + )} +
+
+ ); +}; diff --git a/web/src/components/doc/blocks/CalloutBlockView.tsx b/web/src/components/doc/blocks/CalloutBlockView.tsx new file mode 100644 index 00000000..448a61e2 --- /dev/null +++ b/web/src/components/doc/blocks/CalloutBlockView.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { DocumentBlock } from '../../../types/block'; +import { ShieldAlert, Info } from 'lucide-react'; + +interface CalloutBlockViewProps { + block: DocumentBlock; +} + +export const CalloutBlockView: React.FC = ({ block }) => { + const isReg = block.properties?.calloutType === 'regulatory'; + + return ( +
+
+ {isReg ? : } +
+
+
+ {isReg ? '受控合规依据' : '提示说明'} +
+
{block.content}
+
+
+ ); +}; diff --git a/web/src/components/doc/blocks/HeadingBlockView.tsx b/web/src/components/doc/blocks/HeadingBlockView.tsx new file mode 100644 index 00000000..3b278bb1 --- /dev/null +++ b/web/src/components/doc/blocks/HeadingBlockView.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { DocumentBlock } from '../../../types/block'; + +interface HeadingBlockViewProps { + block: DocumentBlock; +} + +export const HeadingBlockView: React.FC = ({ block }) => { + const level = block.level || 1; + + const fontSizes: Record = { + 1: '20px', + 2: '16.5px', + 3: '14.5px', + 4: '13.5px', + 5: '13px', + 6: '12.5px', + }; + + const margins: Record = { + 1: '24px 0 12px', + 2: '18px 0 8px', + 3: '14px 0 6px', + 4: '10px 0 4px', + 5: '8px 0 4px', + 6: '6px 0 4px', + }; + + return ( +
+ {block.numbering && ( + + {block.numbering} + + )} + {block.content} +
+ ); +}; diff --git a/web/src/components/doc/blocks/MermaidBlockView.tsx b/web/src/components/doc/blocks/MermaidBlockView.tsx new file mode 100644 index 00000000..a7048a63 --- /dev/null +++ b/web/src/components/doc/blocks/MermaidBlockView.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { DocumentBlock } from '../../../types/block'; +import { GitBranch } from 'lucide-react'; + +interface MermaidBlockViewProps { + block: DocumentBlock; +} + +export const MermaidBlockView: React.FC = ({ block }) => { + const caption = block.properties?.caption || '系统架构与执行时序图'; + + return ( +
+ {/* 题注 (图 1-1 ...) */} +
+ {block.numbering ? `${block.numbering} ` : ''} + {caption} +
+ +
+
+ + Mermaid 架构流程图渲染 +
+
{block.content}
+
+
+ ); +}; diff --git a/web/src/components/doc/blocks/ParagraphBlockView.tsx b/web/src/components/doc/blocks/ParagraphBlockView.tsx new file mode 100644 index 00000000..b11aff90 --- /dev/null +++ b/web/src/components/doc/blocks/ParagraphBlockView.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { DocumentBlock } from '../../../types/block'; + +interface ParagraphBlockViewProps { + block: DocumentBlock; +} + +export const ParagraphBlockView: React.FC = ({ block }) => { + const regs = block.properties?.regulations; + + return ( +
+

{block.content}

+ + {/* 识别出的法规条款快速标签 */} + {regs && regs.length > 0 && ( +
+ {regs.map((reg, i) => ( + + § {reg} + + ))} +
+ )} +
+ ); +}; diff --git a/web/src/components/doc/blocks/TableBlockView.tsx b/web/src/components/doc/blocks/TableBlockView.tsx new file mode 100644 index 00000000..3b49ef19 --- /dev/null +++ b/web/src/components/doc/blocks/TableBlockView.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { DocumentBlock } from '../../../types/block'; + +interface TableBlockViewProps { + block: DocumentBlock; +} + +export const TableBlockView: React.FC = ({ block }) => { + const tableData = block.properties?.tableData; + const caption = block.properties?.caption || '技术参数对照表'; + + if (!tableData || tableData.length === 0) { + return null; + } + + const headerRow = tableData[0]; + const bodyRows = tableData.slice(1); + + return ( +
+ {/* 题注 (表 1-1 ...) */} +
+ {block.numbering ? `${block.numbering} ` : ''} + {caption} +
+ + {/* 医疗标准规范三线表容器 */} +
+ + + + {headerRow.map((cell, idx) => ( + + ))} + + + + {bodyRows.map((row, rIdx) => ( + { + e.currentTarget.style.background = 'var(--bg-hover)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'transparent'; + }} + > + {row.map((cell, cIdx) => ( + + ))} + + ))} + +
+ {cell} +
+ {cell} +
+
+
+ ); +};