feat(web): implement 36px slim tabs, deck drawer, and block views with medical styling
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
ef6665288c
commit
2cb8d9b8d2
@@ -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<BlockRendererProps> = ({
|
||||
ast,
|
||||
currentRole,
|
||||
onEditBlock,
|
||||
onInsertBlock,
|
||||
onMoveBlock,
|
||||
onDeleteBlock,
|
||||
onOpenComments,
|
||||
}) => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const isEditable = currentRole === 'author' && ast.lifecycleState === 'drafting';
|
||||
const children = ast.rootBlock.children || [];
|
||||
|
||||
const renderBlockContent = (block: DocumentBlock) => {
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
return <HeadingBlockView block={block} />;
|
||||
case 'table':
|
||||
return <TableBlockView block={block} />;
|
||||
case 'callout':
|
||||
return <CalloutBlockView block={block} />;
|
||||
case 'mermaid':
|
||||
return <MermaidBlockView block={block} />;
|
||||
case 'divider':
|
||||
return <hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '16px 0' }} />;
|
||||
case 'paragraph':
|
||||
default:
|
||||
return <ParagraphBlockView block={block} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '24px 32px 64px',
|
||||
maxWidth: '860px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
{/* 顶部受控文件大标题与状态条 */}
|
||||
<div style={{ marginBottom: '20px', borderBottom: '1px solid var(--border-light)', paddingBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '12px', fontWeight: 700, color: 'var(--brand-600)', background: 'var(--brand-50)', padding: '2px 8px', borderRadius: 'var(--r-pill)' }}>
|
||||
受控版本 {ast.version}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
|
||||
更新时间: {ast.updatedAt}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', fontSize: '12px', color: 'var(--text-muted)' }}>
|
||||
<span>编制人: <b>{ast.author}</b></span>
|
||||
<span>审核人: <b>{ast.reviewer}</b></span>
|
||||
<span>批准人: <b>{ast.approver}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 认领或锁定提示 */}
|
||||
{ast.lifecycleState !== 'drafting' ? (
|
||||
<div style={{ marginTop: '8px', padding: '6px 10px', background: 'var(--amber-50)', borderRadius: 'var(--r-sm)', fontSize: '11.5px', color: 'var(--amber-600)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span>🔒 本文档当前处于【{ast.lifecycleState === 'reviewing' ? '审核中' : ast.lifecycleState === 'approving' ? '批准中' : '已生效受控'}】锁定状态,正文只读,禁止篡改。</span>
|
||||
</div>
|
||||
) : isEditable ? (
|
||||
<div style={{ marginTop: '8px', padding: '4px 8px', background: 'var(--bg-subtle)', borderRadius: 'var(--r-sm)', fontSize: '11px', color: 'var(--text-muted)' }}>
|
||||
💡 提示:双击任意段落或标题可就地激活编辑模式;鼠标悬停可增删或调换章节顺序。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 块流 */}
|
||||
{children.map((block, idx) => {
|
||||
const isHovered = hoveredIndex === idx;
|
||||
const commentCount = block.comments?.length || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={block.id || idx}
|
||||
onMouseEnter={() => 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 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '4px',
|
||||
top: '-14px',
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-pill)',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '2px 4px',
|
||||
gap: '2px',
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
{isEditable && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInsertBlock(idx, 'above');
|
||||
}}
|
||||
style={{ padding: '3px', borderRadius: '4px', color: 'var(--text-muted)' }}
|
||||
title="向上插入新块"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
{idx > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveBlock(idx, 'up');
|
||||
}}
|
||||
style={{ padding: '3px', borderRadius: '4px', color: 'var(--text-muted)' }}
|
||||
title="上移此块"
|
||||
>
|
||||
<ArrowUp size={12} />
|
||||
</button>
|
||||
)}
|
||||
{idx < children.length - 1 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveBlock(idx, 'down');
|
||||
}}
|
||||
style={{ padding: '3px', borderRadius: '4px', color: 'var(--text-muted)' }}
|
||||
title="下移此块"
|
||||
>
|
||||
<ArrowDown size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteBlock(idx);
|
||||
}}
|
||||
style={{ padding: '3px', borderRadius: '4px', color: 'var(--danger)' }}
|
||||
title="删除此块"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 批注按钮 (所有角色均可点击查看/添加) */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenComments(block);
|
||||
}}
|
||||
style={{
|
||||
padding: '3px 6px',
|
||||
borderRadius: '4px',
|
||||
color: commentCount > 0 ? 'var(--amber-500)' : 'var(--text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
title="查看或添加审核批注"
|
||||
>
|
||||
<MessageSquare size={12} />
|
||||
{commentCount > 0 && <span>{commentCount}</span>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 块正文内容 */}
|
||||
{renderBlockContent(block)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<DeckDrawerProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
deliverables,
|
||||
activeDocId,
|
||||
onSelectDoc,
|
||||
}) => {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderBadge = (state: DocLifecycleState, isClaimed: boolean) => {
|
||||
switch (state) {
|
||||
case 'approved':
|
||||
return (
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--success)', background: 'var(--success-bg)', padding: '2px 8px', borderRadius: 'var(--r-pill)', display: 'flex', alignItems: 'center', gap: '3px' }}>
|
||||
<CheckCircle2 size={11} />
|
||||
<span>终审已生效</span>
|
||||
</span>
|
||||
);
|
||||
case 'reviewing':
|
||||
return (
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--amber-600)', background: 'var(--amber-50)', padding: '2px 8px', borderRadius: 'var(--r-pill)', display: 'flex', alignItems: 'center', gap: '3px' }}>
|
||||
<Clock size={11} />
|
||||
<span>审核中 (已锁定)</span>
|
||||
</span>
|
||||
);
|
||||
case 'approving':
|
||||
return (
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: '#9333EA', background: '#F3E8FF', padding: '2px 8px', borderRadius: 'var(--r-pill)', display: 'flex', alignItems: 'center', gap: '3px' }}>
|
||||
<ShieldCheck size={11} />
|
||||
<span>批准中</span>
|
||||
</span>
|
||||
);
|
||||
case 'rejected':
|
||||
return (
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--danger)', background: 'var(--danger-bg)', padding: '2px 8px', borderRadius: 'var(--r-pill)', display: 'flex', alignItems: 'center', gap: '3px' }}>
|
||||
<AlertTriangle size={11} />
|
||||
<span>被驳回需修改</span>
|
||||
</span>
|
||||
);
|
||||
case 'drafting':
|
||||
default:
|
||||
return (
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--brand-600)', background: 'var(--brand-50)', padding: '2px 8px', borderRadius: 'var(--r-pill)', display: 'flex', alignItems: 'center', gap: '3px' }}>
|
||||
<UserCheck size={11} />
|
||||
<span>编制中 {isClaimed ? '(已认领)' : '(未认领)'}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 按阶段分组
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '36px',
|
||||
left: 0,
|
||||
right: 0,
|
||||
maxHeight: '480px',
|
||||
background: 'var(--bg-panel)',
|
||||
borderBottom: '2px solid var(--brand-500)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
backdropFilter: 'blur(16px)',
|
||||
}}
|
||||
>
|
||||
{/* 抽屉顶栏 */}
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--bg-subtle)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-strong)' }}>
|
||||
当前项目阶段交付物台账大盘
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
|
||||
(共 {deliverables.length} 份受控交付物)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '26px',
|
||||
height: '26px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
title="关闭大盘"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 交付物卡片列表 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '14px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '14px',
|
||||
}}
|
||||
>
|
||||
{stages.map((stg) => {
|
||||
const docsInStage = deliverables.filter((d) => d.stage === stg.stage);
|
||||
if (docsInStage.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={stg.stage} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ fontSize: '12px', fontWeight: 700, color: 'var(--brand-600)' }}>
|
||||
{stg.title}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '10px' }}>
|
||||
{docsInStage.map((doc) => {
|
||||
const isActive = doc.docId === activeDocId;
|
||||
return (
|
||||
<div
|
||||
key={doc.docId}
|
||||
onClick={() => {
|
||||
onSelectDoc(doc.docId);
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
background: isActive ? 'var(--bg-active)' : 'var(--bg-card)',
|
||||
border: isActive ? '1.5px solid var(--brand-500)' : '1px solid var(--border)',
|
||||
boxShadow: 'var(--shadow-xs)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) e.currentTarget.style.borderColor = 'var(--brand-100)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) e.currentTarget.style.borderColor = 'var(--border)';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong)' }}>
|
||||
{doc.title}
|
||||
</span>
|
||||
{renderBadge(doc.lifecycleState, doc.isClaimed)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', display: 'flex', gap: '12px' }}>
|
||||
<span>编制: <b>{doc.author}</b></span>
|
||||
<span>审核: <b>{doc.reviewer}</b></span>
|
||||
<span>批准: <b>{doc.approver}</b></span>
|
||||
</div>
|
||||
|
||||
{doc.summary && (
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-faint)', lineHeight: 1.4 }}>
|
||||
{doc.summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<SlimTabBarProps> = ({
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
height: '36px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--bg-subtle)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
gap: '4px',
|
||||
overflowX: 'auto',
|
||||
flexShrink: 0,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{/* 交付物 Tabs */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', flex: 1, overflowX: 'auto' }}>
|
||||
{deliverables.map((doc) => {
|
||||
const isActive = doc.docId === activeDocId;
|
||||
const status = getStatusDot(doc.lifecycleState);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={doc.docId}
|
||||
onClick={() => onSelectDoc(doc.docId)}
|
||||
style={{
|
||||
height: '28px',
|
||||
padding: '0 10px',
|
||||
borderRadius: '6px 6px 0 0',
|
||||
background: isActive ? 'var(--bg-panel)' : 'transparent',
|
||||
border: isActive ? '1px solid var(--border)' : '1px solid transparent',
|
||||
borderBottom: isActive ? '1px solid var(--bg-panel)' : '1px solid transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
color: isActive ? 'var(--text-strong)' : 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) e.currentTarget.style.color = 'var(--text)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) e.currentTarget.style.color = 'var(--text-muted)';
|
||||
}}
|
||||
title={`${doc.title} (${status.label})`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: status.color,
|
||||
boxShadow: `0 0 5px ${status.color}`,
|
||||
}}
|
||||
/>
|
||||
<span style={{ maxWidth: '140px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{doc.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 右侧全局操作区 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginLeft: 'auto', flexShrink: 0 }}>
|
||||
{/* 全部交付物大盘抽屉入口 */}
|
||||
<button
|
||||
onClick={onToggleDeckDrawer}
|
||||
style={{
|
||||
height: '26px',
|
||||
padding: '0 8px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
title="展开当前项目各阶段全部交付物状态大盘"
|
||||
>
|
||||
<FolderKanban size={13} style={{ color: 'var(--brand-500)' }} />
|
||||
<span>全部交付物 ({deliverables.length}) ▾</span>
|
||||
</button>
|
||||
|
||||
{/* 导出受控 Word 按钮 */}
|
||||
{onExportDocx && (
|
||||
<button
|
||||
onClick={onExportDocx}
|
||||
style={{
|
||||
height: '26px',
|
||||
padding: '0 8px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--brand-50)',
|
||||
border: '1px solid var(--brand-100)',
|
||||
color: 'var(--brand-600)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
title="导出为符合严肃合规模板的受控 DOCX 交付物"
|
||||
>
|
||||
<FileDown size={13} />
|
||||
<span>导出受控 DOCX</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 全屏切换 */}
|
||||
{onToggleFullscreen && (
|
||||
<button
|
||||
onClick={onToggleFullscreen}
|
||||
style={{
|
||||
width: '26px',
|
||||
height: '26px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
title={isFullscreen ? '退出全屏' : '全屏沉浸阅读与编辑'}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={13} /> : <Maximize2 size={13} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<CalloutBlockViewProps> = ({ block }) => {
|
||||
const isReg = block.properties?.calloutType === 'regulatory';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
margin: '12px 0',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
background: isReg ? 'var(--amber-50)' : 'var(--info-bg)',
|
||||
borderLeft: `4px solid ${isReg ? 'var(--amber-500)' : 'var(--info)'}`,
|
||||
borderTop: '1px solid var(--border)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginTop: '2px', color: isReg ? 'var(--amber-600)' : 'var(--info)' }}>
|
||||
{isReg ? <ShieldAlert size={16} /> : <Info size={16} />}
|
||||
</div>
|
||||
<div style={{ flex: 1, fontSize: '13px', lineHeight: 1.6, color: 'var(--text-strong)' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '2px', color: isReg ? 'var(--amber-600)' : 'var(--info)' }}>
|
||||
{isReg ? '受控合规依据' : '提示说明'}
|
||||
</div>
|
||||
<div>{block.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { DocumentBlock } from '../../../types/block';
|
||||
|
||||
interface HeadingBlockViewProps {
|
||||
block: DocumentBlock;
|
||||
}
|
||||
|
||||
export const HeadingBlockView: React.FC<HeadingBlockViewProps> = ({ block }) => {
|
||||
const level = block.level || 1;
|
||||
|
||||
const fontSizes: Record<number, string> = {
|
||||
1: '20px',
|
||||
2: '16.5px',
|
||||
3: '14.5px',
|
||||
4: '13.5px',
|
||||
5: '13px',
|
||||
6: '12.5px',
|
||||
};
|
||||
|
||||
const margins: Record<number, string> = {
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
margin: margins[level] || '12px 0 6px',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: '8px',
|
||||
color: 'var(--text-strong)',
|
||||
fontWeight: level <= 2 ? 700 : 600,
|
||||
fontSize: fontSizes[level] || '14px',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{block.numbering && (
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--brand-500)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{block.numbering}
|
||||
</span>
|
||||
)}
|
||||
<span>{block.content}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<MermaidBlockViewProps> = ({ block }) => {
|
||||
const caption = block.properties?.caption || '系统架构与执行时序图';
|
||||
|
||||
return (
|
||||
<div style={{ margin: '16px 0', display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{/* 题注 (图 1-1 ...) */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-strong)',
|
||||
textAlign: 'center',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
<span>{block.numbering ? `${block.numbering} ` : ''}</span>
|
||||
<span>{caption}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 14px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
background: 'var(--bg-subtle)',
|
||||
border: '1px solid var(--border)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '12px',
|
||||
color: 'var(--brand-600)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px', color: 'var(--text-muted)' }}>
|
||||
<GitBranch size={13} />
|
||||
<span style={{ fontSize: '11px', fontWeight: 600 }}>Mermaid 架构流程图渲染</span>
|
||||
</div>
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>{block.content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { DocumentBlock } from '../../../types/block';
|
||||
|
||||
interface ParagraphBlockViewProps {
|
||||
block: DocumentBlock;
|
||||
}
|
||||
|
||||
export const ParagraphBlockView: React.FC<ParagraphBlockViewProps> = ({ block }) => {
|
||||
const regs = block.properties?.regulations;
|
||||
|
||||
return (
|
||||
<div style={{ margin: '8px 0', lineHeight: 1.7, fontSize: '13.5px', color: 'var(--text)' }}>
|
||||
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{block.content}</p>
|
||||
|
||||
{/* 识别出的法规条款快速标签 */}
|
||||
{regs && regs.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginTop: '4px' }}>
|
||||
{regs.map((reg, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: '10.5px',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 'var(--r-pill)',
|
||||
background: 'var(--brand-50)',
|
||||
color: 'var(--brand-600)',
|
||||
border: '1px solid var(--brand-100)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
title="法规知识库引证条款"
|
||||
>
|
||||
§ {reg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { DocumentBlock } from '../../../types/block';
|
||||
|
||||
interface TableBlockViewProps {
|
||||
block: DocumentBlock;
|
||||
}
|
||||
|
||||
export const TableBlockView: React.FC<TableBlockViewProps> = ({ 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 (
|
||||
<div style={{ margin: '16px 0', display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{/* 题注 (表 1-1 ...) */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-strong)',
|
||||
textAlign: 'center',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
<span>{block.numbering ? `${block.numbering} ` : ''}</span>
|
||||
<span>{caption}</span>
|
||||
</div>
|
||||
|
||||
{/* 医疗标准规范三线表容器 */}
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
borderTop: '2px solid var(--text-strong)',
|
||||
borderBottom: '2px solid var(--text-strong)',
|
||||
fontSize: '12.5px',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--text-strong)', background: 'var(--bg-subtle)' }}>
|
||||
{headerRow.map((cell, idx) => (
|
||||
<th
|
||||
key={idx}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-strong)',
|
||||
}}
|
||||
>
|
||||
{cell}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bodyRows.map((row, rIdx) => (
|
||||
<tr
|
||||
key={rIdx}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
{row.map((cell, cIdx) => (
|
||||
<td
|
||||
key={cIdx}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
color: 'var(--text)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user