263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
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 { MathBlockView } from './blocks/MathBlockView';
|
|
import { BlockContextMenu } from './BlockContextMenu';
|
|
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;
|
|
onAskAgent?: (content: string) => void;
|
|
}
|
|
|
|
export const BlockRenderer: React.FC<BlockRendererProps> = ({
|
|
ast,
|
|
currentRole,
|
|
onEditBlock,
|
|
onInsertBlock,
|
|
onMoveBlock,
|
|
onDeleteBlock,
|
|
onOpenComments,
|
|
onAskAgent,
|
|
}) => {
|
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
|
const [contextMenu, setContextMenu] = useState<{
|
|
x: number;
|
|
y: number;
|
|
block: DocumentBlock;
|
|
index: 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 'math':
|
|
return <MathBlockView 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);
|
|
}
|
|
}}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setContextMenu({
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
block,
|
|
index: idx,
|
|
});
|
|
}}
|
|
style={{
|
|
position: 'relative',
|
|
borderRadius: 'var(--r-sm)',
|
|
padding: '3px 8px',
|
|
margin: '3px -8px',
|
|
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>
|
|
);
|
|
})}
|
|
|
|
{/* 鼠标右键上下文菜单 */}
|
|
{contextMenu && (
|
|
<BlockContextMenu
|
|
x={contextMenu.x}
|
|
y={contextMenu.y}
|
|
block={contextMenu.block}
|
|
index={contextMenu.index}
|
|
isEditable={isEditable}
|
|
onClose={() => setContextMenu(null)}
|
|
onEdit={() => onEditBlock(contextMenu.block)}
|
|
onInsertAbove={() => onInsertBlock(contextMenu.index, 'above')}
|
|
onInsertBelow={() => onInsertBlock(contextMenu.index, 'below')}
|
|
onMoveUp={() => onMoveBlock(contextMenu.index, 'up')}
|
|
onMoveDown={() => onMoveBlock(contextMenu.index, 'down')}
|
|
onDelete={() => onDeleteBlock(contextMenu.index)}
|
|
onComment={() => onOpenComments(contextMenu.block)}
|
|
onAskAgent={onAskAgent}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|