feat(ui): 修复模态背景穿透、公式/富文本Markdown解析并集成右键快捷菜单
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
ff948377bd
commit
9c4a839932
Generated
+1146
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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["已用引号包裹的 (正常节点)"]'));
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user