feat(ui): 修复模态背景穿透、公式/富文本Markdown解析并集成右键快捷菜单

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:19 +00:00
committed by weichen
co-authored by Cursor
parent ff948377bd
commit 9c4a839932
16 changed files with 3031 additions and 106 deletions
+1146
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -7,14 +7,17 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "tsx --test src/**/*.test.ts"
"test": "tsx --test \"src/**/*.test.ts\""
},
"dependencies": {
"katex": "^0.18.7",
"lucide-react": "^1.16.0",
"mermaid": "^11.17.2",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/katex": "^0.16.8",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
+266
View File
@@ -0,0 +1,266 @@
import React, { useEffect, useRef } from 'react';
import { DocumentBlock } from '../../types/block';
import {
Edit3,
MessageSquarePlus,
Plus,
ArrowUp,
ArrowDown,
Trash2,
Copy,
Check,
Bot,
} from 'lucide-react';
interface BlockContextMenuProps {
x: number;
y: number;
block: DocumentBlock;
index: number;
isEditable: boolean;
onClose: () => void;
onEdit: () => void;
onInsertAbove: () => void;
onInsertBelow: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onDelete: () => void;
onComment: () => void;
onAskAgent?: (content: string) => void;
}
export const BlockContextMenu: React.FC<BlockContextMenuProps> = ({
x,
y,
block,
isEditable,
onClose,
onEdit,
onInsertAbove,
onInsertBelow,
onMoveUp,
onMoveDown,
onDelete,
onComment,
onAskAgent,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = React.useState(false);
// 边界保护定位
const adjustedX = Math.min(x, window.innerWidth - 210);
const adjustedY = Math.min(y, window.innerHeight - 290);
useEffect(() => {
const handleGlobalClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('mousedown', handleGlobalClick);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('mousedown', handleGlobalClick);
window.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
const handleCopy = () => {
navigator.clipboard.writeText(block.content || '');
setCopied(true);
setTimeout(() => {
setCopied(false);
onClose();
}, 600);
};
const itemStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '7px 12px',
fontSize: '12px',
color: 'var(--text-strong)',
borderRadius: '4px',
cursor: 'pointer',
userSelect: 'none',
transition: 'background 0.12s ease',
};
return (
<div
ref={menuRef}
style={{
position: 'fixed',
left: `${adjustedX}px`,
top: `${adjustedY}px`,
width: '200px',
backgroundColor: 'var(--bg-panel, #131820)',
border: '1px solid var(--border)',
borderRadius: '8px',
boxShadow: 'var(--shadow-lg)',
zIndex: 9999,
padding: '5px',
display: 'flex',
flexDirection: 'column',
gap: '2px',
animation: 'fadeIn 0.12s ease-out',
}}
onClick={(e) => e.stopPropagation()}
>
{/* 块信息微标 */}
<div
style={{
padding: '4px 8px 6px',
fontSize: '10.5px',
fontWeight: 600,
color: 'var(--text-muted)',
borderBottom: '1px solid var(--border-light)',
marginBottom: '3px',
display: 'flex',
justifyContent: 'space-between',
}}
>
<span>{block.type.toUpperCase()} 块操作</span>
{block.numbering && <span style={{ color: 'var(--brand-500)' }}>{block.numbering}</span>}
</div>
{/* 编制人权限操作 */}
{isEditable && (
<>
<div
style={itemStyle}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
onClick={() => {
onEdit();
onClose();
}}
>
<Edit3 size={14} color="var(--brand-500)" />
<span>就地编辑该块</span>
</div>
<div
style={itemStyle}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
onClick={() => {
onInsertAbove();
onClose();
}}
>
<Plus size={14} color="var(--brand-500)" />
<span>在上方插入新块</span>
</div>
<div
style={itemStyle}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
onClick={() => {
onInsertBelow();
onClose();
}}
>
<Plus size={14} color="var(--brand-500)" />
<span>在下方插入新块</span>
</div>
<div
style={itemStyle}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
onClick={() => {
onMoveUp();
onClose();
}}
>
<ArrowUp size={14} />
<span>上移此块</span>
</div>
<div
style={itemStyle}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
onClick={() => {
onMoveDown();
onClose();
}}
>
<ArrowDown size={14} />
<span>下移此块</span>
</div>
<div style={{ height: '1px', background: 'var(--border-light)', margin: '3px 0' }} />
</>
)}
{/* 评审批注 (审核人/批准人/编制人均可批注) */}
<div
style={itemStyle}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
onClick={() => {
onComment();
onClose();
}}
>
<MessageSquarePlus size={14} color="#F59E0B" />
<span>添加评审批注/修改建议</span>
</div>
{/* 复制本块内容 */}
<div
style={itemStyle}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
onClick={handleCopy}
>
{copied ? <Check size={14} color="var(--success)" /> : <Copy size={14} />}
<span>{copied ? '已复制至剪贴板' : '复制块 Markdown 内容'}</span>
</div>
{/* 发送给协同智能体参谋分析 */}
{onAskAgent && (
<div
style={itemStyle}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
onClick={() => {
onAskAgent(block.content || '');
onClose();
}}
>
<Bot size={14} color="var(--brand-500)" />
<span>提问协同智能体...</span>
</div>
)}
{/* 删除块 (仅可编辑状态) */}
{isEditable && (
<>
<div style={{ height: '1px', background: 'var(--border-light)', margin: '3px 0' }} />
<div
style={{ ...itemStyle, color: 'var(--danger)' }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--danger-bg)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
onClick={() => {
onDelete();
onClose();
}}
>
<Trash2 size={14} color="var(--danger)" />
<span>删除该受控块</span>
</div>
</>
)}
</div>
);
};
File diff suppressed because it is too large Load Diff
+47 -5
View File
@@ -6,6 +6,8 @@ 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 {
@@ -16,6 +18,7 @@ interface BlockRendererProps {
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> = ({
@@ -26,8 +29,15 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
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 || [];
@@ -42,6 +52,8 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
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':
@@ -84,11 +96,11 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
{/* 认领或锁定提示 */}
{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>
<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>
@@ -108,11 +120,21 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
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: '2px 6px',
margin: '2px -6px',
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',
@@ -145,7 +167,7 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
onInsertBlock(idx, 'above');
}}
style={{ padding: '3px', borderRadius: '4px', color: 'var(--text-muted)' }}
title="向上插入新块"
title="在此块上方插入新段落"
>
<Plus size={12} />
</button>
@@ -215,6 +237,26 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
</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>
);
};
@@ -1,6 +1,7 @@
import React from 'react';
import React, { useMemo } from 'react';
import { DocumentBlock } from '../../../types/block';
import { ShieldAlert, Info } from 'lucide-react';
import { renderInlineMarkdown } from '../../../engine/renderMarkdown';
interface CalloutBlockViewProps {
block: DocumentBlock;
@@ -9,6 +10,10 @@ interface CalloutBlockViewProps {
export const CalloutBlockView: React.FC<CalloutBlockViewProps> = ({ block }) => {
const isReg = block.properties?.calloutType === 'regulatory';
const renderedContent = useMemo(() => {
return renderInlineMarkdown(block.content || '', { preserveNewlines: true });
}, [block.content]);
return (
<div
style={{
@@ -32,7 +37,7 @@ export const CalloutBlockView: React.FC<CalloutBlockViewProps> = ({ block }) =>
<div style={{ fontWeight: 600, marginBottom: '2px', color: isReg ? 'var(--amber-600)' : 'var(--info)' }}>
{isReg ? '受控合规依据' : '提示说明'}
</div>
<div>{block.content}</div>
<div dangerouslySetInnerHTML={{ __html: renderedContent }} />
</div>
</div>
);
@@ -1,5 +1,6 @@
import React from 'react';
import { DocumentBlock } from '../../../types/block';
import { renderInlineMarkdown } from '../../../engine/renderMarkdown';
interface HeadingBlockViewProps {
block: DocumentBlock;
@@ -50,7 +51,11 @@ export const HeadingBlockView: React.FC<HeadingBlockViewProps> = ({ block }) =>
{block.numbering}
</span>
)}
<span>{block.content}</span>
<span
dangerouslySetInnerHTML={{
__html: renderInlineMarkdown(block.content || '', { preserveNewlines: false }),
}}
/>
</div>
);
};
@@ -0,0 +1,71 @@
import React, { useMemo } from 'react';
import { DocumentBlock } from '../../../types/block';
import katex from 'katex';
import { Calculator } from 'lucide-react';
interface MathBlockViewProps {
block: DocumentBlock;
}
export const MathBlockView: React.FC<MathBlockViewProps> = ({ block }) => {
const renderedHtml = useMemo(() => {
try {
const cleanContent = block.content.trim().replace(/^\\?\$+/, '').replace(/\$+$/, '');
return katex.renderToString(cleanContent, {
displayMode: true,
throwOnError: false,
});
} catch (err: any) {
return `<span style="color:var(--danger)">LaTeX 解析错误: ${err.message}</span>`;
}
}, [block.content]);
const caption = block.properties?.caption || '关键计算判据与物理算法模型';
return (
<div style={{ margin: '18px 0', display: 'flex', flexDirection: 'column', gap: '6px' }}>
<div
style={{
padding: '16px 20px',
borderRadius: 'var(--r-md)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
overflowX: 'auto',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'var(--shadow-xs)',
}}
>
<div
dangerouslySetInnerHTML={{ __html: renderedHtml }}
style={{
fontSize: '15px',
color: 'var(--text-strong)',
maxWidth: '100%',
}}
/>
</div>
{/* 题注与编号 */}
<div
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-muted)',
textAlign: 'center',
letterSpacing: '0.3px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
}}
>
<Calculator size={12} color="var(--brand-500)" />
<span>{block.numbering ? `${block.numbering} ` : ''}</span>
<span>{caption}</span>
</div>
</div>
);
};
@@ -1,16 +1,103 @@
import React from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { DocumentBlock } from '../../../types/block';
import { GitBranch } from 'lucide-react';
import { GitBranch, Code, Eye, AlertCircle, Loader2 } from 'lucide-react';
import mermaid from 'mermaid';
interface MermaidBlockViewProps {
block: DocumentBlock;
}
let isInitialized = false;
function setupMermaid() {
if (!isInitialized) {
mermaid.initialize({
startOnLoad: false,
theme: 'neutral',
securityLevel: 'loose',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSize: 12,
});
isInitialized = true;
}
}
/**
* 自动清洗 Mermaid 代码:
* 将 Node[文本 (含括号 < 35ms)] 等未带引号且含有特殊符号的内容自动用双引号包裹,杜绝解析崩溃
*/
export function sanitizeMermaidCode(text: string): string {
if (!text) return '';
return text
.split('\n')
.map((line) => {
return line.replace(/(\b[\w\.-]+)\s*\[\s*([^"\]\n]+?)\s*\]/g, (match, id, label) => {
if ((label.startsWith('"') && label.endsWith('"')) || (label.startsWith("'") && label.endsWith("'"))) {
return match;
}
if (/[\(\)<>&:#/\\@\s]/.test(label)) {
return `${id}["${label.replace(/"/g, "'")}"]`;
}
return match;
});
})
.join('\n');
}
export const MermaidBlockView: React.FC<MermaidBlockViewProps> = ({ block }) => {
const caption = block.properties?.caption || '系统架构与执行时序图';
const [svgHtml, setSvgHtml] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [showRawCode, setShowRawCode] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
const caption = block.properties?.caption || '系统架构与时序拓扑图';
useEffect(() => {
let isMounted = true;
setupMermaid();
const doRender = async () => {
setLoading(true);
setError(null);
try {
const rawText = block.content.trim();
if (!rawText) {
if (isMounted) {
setSvgHtml('');
setLoading(false);
}
return;
}
const sanitizedText = sanitizeMermaidCode(rawText);
const id = `mermaid_${block.id.replace(/[^a-zA-Z0-9]/g, '_')}_${Date.now()}`;
const renderResult = await mermaid.render(id, sanitizedText);
if (isMounted) {
setSvgHtml(renderResult.svg);
setLoading(false);
}
} catch (err: any) {
if (isMounted) {
// 清理 mermaid 错误 DOM 注入
const errorElement = document.getElementById('d' + block.id);
if (errorElement) errorElement.remove();
setError(err?.message || 'Mermaid 图形语法解析异常');
setLoading(false);
}
}
};
doRender();
return () => {
isMounted = false;
};
}, [block.content, block.id]);
return (
<div style={{ margin: '16px 0', display: 'flex', flexDirection: 'column', gap: '6px' }}>
<div style={{ margin: '18px 0', display: 'flex', flexDirection: 'column', gap: '6px' }}>
{/* 题注 (图 1-1 ...) */}
<div
style={{
@@ -19,29 +106,112 @@ export const MermaidBlockView: React.FC<MermaidBlockViewProps> = ({ block }) =>
color: 'var(--text-strong)',
textAlign: 'center',
letterSpacing: '0.3px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
}}
>
<GitBranch size={13} color="var(--brand-500)" />
<span>{block.numbering ? `${block.numbering} ` : ''}</span>
<span>{caption}</span>
</div>
{/* 图形渲染容器 */}
<div
style={{
padding: '12px 14px',
padding: '16px',
borderRadius: 'var(--r-md)',
background: 'var(--bg-subtle)',
background: 'var(--bg-card)',
border: '1px solid var(--border)',
fontFamily: 'var(--font-mono)',
fontSize: '12px',
color: 'var(--brand-600)',
position: 'relative',
overflow: 'hidden',
boxShadow: 'var(--shadow-xs)',
minHeight: '80px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<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 style={{ position: 'absolute', top: '8px', right: '8px', zIndex: 10 }}>
<button
onClick={() => setShowRawCode(!showRawCode)}
style={{
padding: '3px 8px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'var(--bg-panel)',
color: 'var(--text-muted)',
fontSize: '10.5px',
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: 'pointer',
}}
title={showRawCode ? '查看渲染图形' : '查看 Mermaid 原代码'}
>
{showRawCode ? <Eye size={12} /> : <Code size={12} />}
<span>{showRawCode ? '图形' : '源码'}</span>
</button>
</div>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>{block.content}</pre>
{loading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-muted)', padding: '20px' }}>
<Loader2 size={16} className="animate-spin-fast" />
<span style={{ fontSize: '12px' }}>正在渲染高清矢量拓扑图...</span>
</div>
) : showRawCode || error ? (
<div style={{ width: '100%' }}>
{error && (
<div
style={{
padding: '8px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--danger-bg)',
border: '1px solid rgba(239, 68, 68, 0.3)',
color: 'var(--danger)',
fontSize: '12px',
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<AlertCircle size={14} />
<span>图表解析失败: {error}</span>
</div>
)}
<pre
style={{
margin: 0,
padding: '12px',
background: 'var(--bg-subtle)',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
fontFamily: 'var(--font-mono)',
color: 'var(--text)',
overflowX: 'auto',
lineHeight: 1.5,
}}
>
{block.content}
</pre>
</div>
) : (
<div
ref={containerRef}
dangerouslySetInnerHTML={{ __html: svgHtml }}
style={{
width: '100%',
display: 'flex',
justifyContent: 'center',
overflowX: 'auto',
padding: '8px 0',
}}
/>
)}
</div>
</div>
);
@@ -1,5 +1,6 @@
import React from 'react';
import React, { useMemo } from 'react';
import { DocumentBlock } from '../../../types/block';
import { renderInlineMarkdown } from '../../../engine/renderMarkdown';
interface ParagraphBlockViewProps {
block: DocumentBlock;
@@ -8,13 +9,20 @@ interface ParagraphBlockViewProps {
export const ParagraphBlockView: React.FC<ParagraphBlockViewProps> = ({ block }) => {
const regs = block.properties?.regulations;
const renderedContent = useMemo(() => {
return renderInlineMarkdown(block.content || '', { preserveNewlines: true });
}, [block.content]);
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>
<div
dangerouslySetInnerHTML={{ __html: renderedContent }}
style={{ wordBreak: 'break-word' }}
/>
{/* 识别出的法规条款快速标签 */}
{regs && regs.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginTop: '4px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginTop: '6px' }}>
{regs.map((reg, i) => (
<span
key={i}
@@ -1,16 +1,17 @@
import React from 'react';
import { DocumentBlock } from '../../../types/block';
import { renderInlineMarkdown } from '../../../engine/renderMarkdown';
interface TableBlockViewProps {
block: DocumentBlock;
}
export const TableBlockView: React.FC<TableBlockViewProps> = ({ block }) => {
const tableData = block.properties?.tableData;
const caption = block.properties?.caption || '技术参数对照表';
const tableData = block.properties?.tableData || [];
const caption = block.properties?.caption || '关键指标与技术评估表';
if (!tableData || tableData.length === 0) {
return null;
return <div style={{ color: 'var(--text-faint)', fontStyle: 'italic' }}>[空数据表格]</div>;
}
const headerRow = tableData[0];
@@ -54,9 +55,10 @@ export const TableBlockView: React.FC<TableBlockViewProps> = ({ block }) => {
fontWeight: 600,
color: 'var(--text-strong)',
}}
>
{cell}
</th>
dangerouslySetInnerHTML={{
__html: renderInlineMarkdown(cell, { preserveNewlines: false }),
}}
/>
))}
</tr>
</thead>
@@ -83,9 +85,10 @@ export const TableBlockView: React.FC<TableBlockViewProps> = ({ block }) => {
color: 'var(--text)',
lineHeight: 1.4,
}}
>
{cell}
</td>
dangerouslySetInnerHTML={{
__html: renderInlineMarkdown(cell, { preserveNewlines: false }),
}}
/>
))}
</tr>
))}
+236
View File
@@ -0,0 +1,236 @@
import { FC, useEffect } from 'react';
import { X, Settings, Database, Users, Cpu, FileCheck } from 'lucide-react';
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export const SettingsModal: FC<SettingsModalProps> = ({ isOpen, onClose }) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(11, 14, 20, 0.75)',
backdropFilter: 'blur(6px)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
style={{
width: '560px',
maxWidth: '95vw',
backgroundColor: 'var(--bg-panel, #131820)',
borderRadius: '12px',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
animation: 'scaleIn 0.18s ease-out',
}}
>
<div
style={{
padding: '16px 20px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: 'var(--bg-subtle, #1A212C)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div
style={{
width: '34px',
height: '34px',
borderRadius: '8px',
backgroundColor: 'rgba(148, 163, 184, 0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-strong)',
}}
>
<Settings size={18} />
</div>
<div>
<div style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-strong)' }}>
系统设置与管理控制台 (规划预留)
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
水木东方医疗器械智能协同工作台 · 系统配置管理
</div>
</div>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-muted)',
padding: '6px',
borderRadius: '6px',
}}
>
<X size={18} />
</button>
</div>
<div style={{ padding: '20px', display: 'flex', flexDirection: 'column', gap: '16px', backgroundColor: 'var(--bg-panel, #131820)' }}>
<div
style={{
fontSize: '12px',
color: 'var(--text)',
lineHeight: 1.6,
backgroundColor: 'var(--bg-subtle, #1A212C)',
padding: '12px 14px',
borderRadius: '6px',
border: '1px solid var(--border)',
}}
>
本入口已为您保留,后续迭代将在此接入管理员全局管理界面与跨系统配置服务:
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
<div
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-subtle, #1A212C)',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--brand-500)' }}>
<Users size={16} />
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong)' }}>
组织与人员权限
</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', lineHeight: 1.4 }}>
配置全公司研发与质量人员库、固定职位及各项目阶段团队角色授权。
</div>
</div>
<div
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-subtle, #1A212C)',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--success)' }}>
<Database size={16} />
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong)' }}>
法规知识库索引
</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', lineHeight: 1.4 }}>
管理 NMPA、FDA、CE-MDR 监管法规库与 pgvector 语义嵌入向量同步更新。
</div>
</div>
<div
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-subtle, #1A212C)',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--amber-500)' }}>
<Cpu size={16} />
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong)' }}>
多智能体编排与模型
</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', lineHeight: 1.4 }}>
配置法规专家、方案总师、CDO报价师与评审参谋的提示词与大模型网关。
</div>
</div>
<div
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-subtle, #1A212C)',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#A855F7' }}>
<FileCheck size={16} />
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong)' }}>
合规审计与电子签名
</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', lineHeight: 1.4 }}>
遵循 21 CFR Part 11 与 GMP 电子签名审计记录、哈希防篡改检验。
</div>
</div>
</div>
</div>
<div
style={{
padding: '12px 20px',
borderTop: '1px solid var(--border)',
backgroundColor: 'var(--bg-subtle, #1A212C)',
display: 'flex',
justifyContent: 'flex-end',
}}
>
<button
onClick={onClose}
style={{
padding: '6px 16px',
borderRadius: '6px',
backgroundColor: 'var(--brand-500)',
border: 'none',
color: '#fff',
fontSize: '12px',
fontWeight: 500,
cursor: 'pointer',
}}
>
知道了
</button>
</div>
</div>
</div>
);
};
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderInlineMarkdown } from './renderMarkdown';
import { sanitizeMermaidCode } from '../components/doc/blocks/MermaidBlockView';
test('renderInlineMarkdown: renders bold, italic, strikethrough, underline', () => {
const input = '**粗体指标** *斜体说明* ~~废弃版本~~ <u>重要条款</u>';
const html = renderInlineMarkdown(input);
assert.ok(html.includes('<strong>粗体指标</strong>'));
assert.ok(html.includes('<em>斜体说明</em>'));
assert.ok(html.includes('<del style="color:var(--text-muted)">废弃版本</del>'));
assert.ok(html.includes('<u>重要条款</u>'));
});
test('renderInlineMarkdown: renders inline code and preserved line breaks', () => {
const input = '配置 `MIPI-CSI2` 接口\n第二行内容';
const html = renderInlineMarkdown(input, { preserveNewlines: true });
assert.ok(html.includes('<code'));
assert.ok(html.includes('MIPI-CSI2'));
assert.ok(html.includes('<br/>'));
});
test('renderInlineMarkdown: renders math without markdown interference', () => {
const input = '信噪比判据:$SNR = \\frac{S_{emission}}{\\sigma_{dark}} \\ge 18.5$ 符合指标';
const html = renderInlineMarkdown(input);
assert.ok(html.includes('katex'));
assert.ok(html.includes('SNR'));
// 确保公式内部的下划线没有被误解析为斜体 <em>
assert.ok(!html.includes('<em>dark</em>'));
});
test('sanitizeMermaidCode: auto wraps unquoted labels containing brackets or comparison operators', () => {
const rawMermaid = `graph LR
LaserSource[NIR近红外与白光LED复用光源] --> FPGA[FPGA双路色彩配准与超低延迟融合引擎]
FPGA --> Display[4K医用级超低延迟监视器 (延时 < 35ms)]
Display --> CleanNode["已用引号包裹的 (正常节点)"]`;
const sanitized = sanitizeMermaidCode(rawMermaid);
// 原有含括号未包裹的节点必须被加上双引号
assert.ok(sanitized.includes('Display["4K医用级超低延迟监视器 (延时 < 35ms)"]'));
// 已有双引号的节点保持不变
assert.ok(sanitized.includes('CleanNode["已用引号包裹的 (正常节点)"]'));
});
+117
View File
@@ -0,0 +1,117 @@
import katex from 'katex';
export interface RenderMarkdownOptions {
preserveNewlines?: boolean;
}
/**
* 健壮的行内 Markdown 与 LaTeX 公式渲染引擎
* 支持:
* - 块级数学公式 ($$...$$) 与行内数学公式 ($...$)
* - 粗体 (**text** / __text__)
* - 斜体 (*text* / _text_)
* - 粗斜体 (***text***)
* - 删除线 (~~text~~)
* - 下划线 (<u>text</u>)
* - 行内代码 (`code`)
* - 超链接 ([text](url))
* - 荧光高亮 (<mark> / 预置语法)
* - 安全内联样式标签 (<span style="...">)
*/
export function renderInlineMarkdown(content: string, options: RenderMarkdownOptions = {}): string {
if (!content) return '';
let text = content;
// 1. 占位保护预处理:优先解析并提取数学公式,避免公式内部的 * 或 _ 被 Markdown 解析器误伤
// 注意:占位符严禁使用包含 _ 或 * 等 Markdown 特殊字符,采用纯大写字母标识符
const mathPlaceholders: string[] = [];
const placeholderPrefix = 'XMATHTOKENX';
// 1.1 块级独立公式 $$ ... $$
text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => {
try {
const rendered = katex.renderToString(math.trim(), {
displayMode: true,
throwOnError: false,
});
const idx = mathPlaceholders.length;
mathPlaceholders.push(rendered);
return `${placeholderPrefix}${idx}XENDX`;
} catch {
const idx = mathPlaceholders.length;
mathPlaceholders.push(`<span style="color:var(--danger)">$$${math}$$</span>`);
return `${placeholderPrefix}${idx}XENDX`;
}
});
// 1.2 行内公式 $ ... $
text = text.replace(/\$([^\$\n]+?)\$/g, (_, math) => {
try {
const rendered = katex.renderToString(math.trim(), {
displayMode: false,
throwOnError: false,
});
const idx = mathPlaceholders.length;
mathPlaceholders.push(rendered);
return `${placeholderPrefix}${idx}XENDX`;
} catch {
const idx = mathPlaceholders.length;
mathPlaceholders.push(`$${math}$`);
return `${placeholderPrefix}${idx}XENDX`;
}
});
// 2. 行内代码保护 `code`
const codePlaceholders: string[] = [];
const codePlaceholderPrefix = 'XCODETOKENX';
text = text.replace(/`([^`\n]+)`/g, (_, code) => {
const rendered = `<code style="font-family:var(--font-mono);font-size:12px;background:var(--bg-subtle);color:var(--brand-500);padding:2px 5px;border-radius:4px;border:1px solid var(--border)">${escapeHtml(code)}</code>`;
const idx = codePlaceholders.length;
codePlaceholders.push(rendered);
return `${codePlaceholderPrefix}${idx}XENDX`;
});
// 3. 粗斜体 ***text***
text = text.replace(/\*\*\*([^\*\n]+?)\*\*\*/g, '<strong><em>$1</em></strong>');
// 4. 粗体 **text** 或 __text__
text = text.replace(/\*\*([^\*\n]+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/__([^_\n]+?)__/g, '<strong>$1</strong>');
// 5. 斜体 *text* 或 _text_
text = text.replace(/\*([^\*\n]+?)\*/g, '<em>$1</em>');
text = text.replace(/_([^_\n]+?)_/g, '<em>$1</em>');
// 6. 删除线 ~~text~~
text = text.replace(/~~([^~\n]+?)~~/g, '<del style="color:var(--text-muted)">$1</del>');
// 7. 链接 [text](url)
text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:var(--brand-500);text-decoration:underline">$1</a>');
// 8. 还原行内代码占位符
codePlaceholders.forEach((codeHtml, idx) => {
text = text.replace(`${codePlaceholderPrefix}${idx}XENDX`, () => codeHtml);
});
// 9. 还原数学公式占位符
mathPlaceholders.forEach((mathHtml, idx) => {
text = text.replace(`${placeholderPrefix}${idx}XENDX`, () => mathHtml);
});
// 10. 处理换行
if (options.preserveNewlines !== false) {
text = text.replace(/\n/g, '<br/>');
}
return text;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
+1
View File
@@ -1,5 +1,6 @@
import ReactDOM from 'react-dom/client';
import App from './App';
import 'katex/dist/katex.min.css';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
+20
View File
@@ -51,6 +51,16 @@
--r-md: 10px;
--r-lg: 14px;
--r-pill: 999px;
/* 语义化组件色彩兼容别名 (防透明穿透兜底) */
--color-bg-surface: var(--bg-panel);
--color-bg-canvas: var(--bg-subtle);
--color-border-subtle: var(--border);
--color-text-primary: var(--text-strong);
--color-text-secondary: var(--text);
--color-text-muted: var(--text-muted);
--color-brand-primary: var(--brand-500);
--shadow-xl: var(--shadow-lg);
}
/* 深色主题 Tokens */
@@ -93,4 +103,14 @@
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 18px 48px rgba(0, 0, 0, 0.6), 0 4px 12px rgba(0, 0, 0, 0.4);
/* 语义化组件色彩兼容别名 (防透明穿透兜底) */
--color-bg-surface: var(--bg-panel);
--color-bg-canvas: var(--bg-subtle);
--color-border-subtle: var(--border);
--color-text-primary: var(--text-strong);
--color-text-secondary: var(--text);
--color-text-muted: var(--text-muted);
--color-brand-primary: var(--brand-500);
--shadow-xl: var(--shadow-lg);
}