Compare commits
No commits in common. "da65cedf28942b77e8a904ec9b16b4c190645d3a" and "c5c16ee893987e0ae2aa61d12f6a355b0615268f" have entirely different histories.
da65cedf28
...
c5c16ee893
@ -20,10 +20,8 @@
|
|||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jose": "^6.1.3",
|
"jose": "^6.1.3",
|
||||||
"jspdf": "^3.0.4",
|
"jspdf": "^3.0.4",
|
||||||
"katex": "^0.16.27",
|
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"mermaid": "^11.12.2",
|
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
"nodemailer": "^7.0.11",
|
"nodemailer": "^7.0.11",
|
||||||
@ -33,9 +31,7 @@
|
|||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"rehype-katex": "^7.0.1",
|
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"word-extractor": "^1.0.4",
|
"word-extractor": "^1.0.4",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
@ -44,7 +40,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/katex": "^0.16.7",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/nodemailer": "^7.0.4",
|
"@types/nodemailer": "^7.0.4",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
|
|||||||
1121
pnpm-lock.yaml
1121
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -77,9 +77,6 @@
|
|||||||
--color-code-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
--color-code-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
/* Mac 红黄绿按钮 - 亮色主题隐藏 */
|
/* Mac 红黄绿按钮 - 亮色主题隐藏 */
|
||||||
--color-code-traffic-light-display: none;
|
--color-code-traffic-light-display: none;
|
||||||
|
|
||||||
/* Mermaid 图表背景 - 亮色主题,与代码块背景保持一致 */
|
|
||||||
--color-mermaid-bg: #F5F3F0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
@ -138,9 +135,6 @@
|
|||||||
--color-code-shadow: 0 8px 24px rgba(0, 0, 0, 0.25), 0 2px 8px rgba(0, 0, 0, 0.15);
|
--color-code-shadow: 0 8px 24px rgba(0, 0, 0, 0.25), 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
/* Mac 红黄绿按钮 - 暗色主题显示 */
|
/* Mac 红黄绿按钮 - 暗色主题显示 */
|
||||||
--color-code-traffic-light-display: flex;
|
--color-code-traffic-light-display: flex;
|
||||||
|
|
||||||
/* Mermaid 图表背景 - 暗色主题 */
|
|
||||||
--color-mermaid-bg: #3A353E;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
@ -332,7 +332,6 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
|||||||
content={message.content}
|
content={message.content}
|
||||||
onImageLinkClick={handleImageLinkClick}
|
onImageLinkClick={handleImageLinkClick}
|
||||||
onLinkClick={onLinkClick}
|
onLinkClick={onLinkClick}
|
||||||
isStreaming={isStreaming}
|
|
||||||
/>
|
/>
|
||||||
) : isStreaming ? (
|
) : isStreaming ? (
|
||||||
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">
|
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">
|
||||||
|
|||||||
@ -3,15 +3,9 @@
|
|||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import remarkMath from 'remark-math';
|
|
||||||
import rehypeKatex from 'rehype-katex';
|
|
||||||
import { CodeBlock } from './CodeBlock';
|
import { CodeBlock } from './CodeBlock';
|
||||||
import { MermaidBlock } from './MermaidBlock';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// 导入 KaTeX 样式
|
|
||||||
import 'katex/dist/katex.min.css';
|
|
||||||
|
|
||||||
interface MarkdownRendererProps {
|
interface MarkdownRendererProps {
|
||||||
content: string;
|
content: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -19,8 +13,6 @@ interface MarkdownRendererProps {
|
|||||||
onImageLinkClick?: (url: string) => void;
|
onImageLinkClick?: (url: string) => void;
|
||||||
/** 普通链接点击回调,用于在预览窗口中打开 */
|
/** 普通链接点击回调,用于在预览窗口中打开 */
|
||||||
onLinkClick?: (url: string) => void;
|
onLinkClick?: (url: string) => void;
|
||||||
/** 是否正在流式输出(用于延迟 Mermaid 渲染) */
|
|
||||||
isStreaming?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -38,18 +30,12 @@ function isImageUrl(url: string): boolean {
|
|||||||
* 创建 Markdown 组件配置
|
* 创建 Markdown 组件配置
|
||||||
* 使用工厂函数以支持传入回调
|
* 使用工厂函数以支持传入回调
|
||||||
*/
|
*/
|
||||||
function createMarkdownComponents(
|
function createMarkdownComponents(onImageLinkClick?: (url: string) => void, onLinkClick?: (url: string) => void) {
|
||||||
onImageLinkClick?: (url: string) => void,
|
|
||||||
onLinkClick?: (url: string) => void,
|
|
||||||
isStreaming?: boolean
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
// 代码块
|
// 代码块
|
||||||
code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
|
code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
|
||||||
const match = /language-(\w+)/.exec(className || '');
|
const match = /language-(\w+)/.exec(className || '');
|
||||||
const isInline = !match && !className;
|
const isInline = !match && !className;
|
||||||
const language = match ? match[1] : 'text';
|
|
||||||
const codeContent = String(children).replace(/\n$/, '');
|
|
||||||
|
|
||||||
if (isInline) {
|
if (isInline) {
|
||||||
// 行内代码 - 无特殊样式,仅等宽字体
|
// 行内代码 - 无特殊样式,仅等宽字体
|
||||||
@ -63,16 +49,11 @@ function createMarkdownComponents(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mermaid 图表 - 使用专门的 MermaidBlock 组件
|
// 代码块
|
||||||
if (language === 'mermaid') {
|
|
||||||
return <MermaidBlock code={codeContent} isStreaming={isStreaming} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 普通代码块
|
|
||||||
return (
|
return (
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
code={codeContent}
|
code={String(children).replace(/\n$/, '')}
|
||||||
language={language}
|
language={match ? match[1] : 'text'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -278,18 +259,17 @@ function createMarkdownComponents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用 memo 包裹组件,避免不必要的重渲染
|
// 使用 memo 包裹组件,避免不必要的重渲染
|
||||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick, onLinkClick, isStreaming }: MarkdownRendererProps) {
|
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick, onLinkClick }: MarkdownRendererProps) {
|
||||||
// 使用 useMemo 缓存 components 配置,仅在回调或流式状态变化时重新创建
|
// 使用 useMemo 缓存 components 配置,仅在回调变化时重新创建
|
||||||
const components = useMemo(
|
const components = useMemo(
|
||||||
() => createMarkdownComponents(onImageLinkClick, onLinkClick, isStreaming),
|
() => createMarkdownComponents(onImageLinkClick, onLinkClick),
|
||||||
[onImageLinkClick, onLinkClick, isStreaming]
|
[onImageLinkClick, onLinkClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('markdown-content', className)}>
|
<div className={cn('markdown-content', className)}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm, remarkMath]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={[rehypeKatex]}
|
|
||||||
components={components}
|
components={components}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@ -1,479 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useId, useCallback } from 'react';
|
|
||||||
import { Copy, Check, Eye, Code, Maximize2, AlertCircle, Loader2, FileCode } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface MermaidBlockProps {
|
|
||||||
code: string;
|
|
||||||
className?: string;
|
|
||||||
/** 是否正在流式输出 */
|
|
||||||
isStreaming?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ViewMode = 'diagram' | 'code';
|
|
||||||
|
|
||||||
/** 防抖延迟时间(毫秒) */
|
|
||||||
const DEBOUNCE_DELAY = 600;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检测当前是否为暗色主题
|
|
||||||
*/
|
|
||||||
function isDarkTheme(): boolean {
|
|
||||||
if (typeof document === 'undefined') return false;
|
|
||||||
return document.documentElement.getAttribute('data-theme') === 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 Mermaid 暗色主题配置(深紫灰主题)
|
|
||||||
* 节点背景使用深色,与暗色 UI 协调
|
|
||||||
*/
|
|
||||||
function getDarkThemeConfig() {
|
|
||||||
return {
|
|
||||||
theme: 'base' as const,
|
|
||||||
themeVariables: {
|
|
||||||
primaryColor: '#4A4553', // 节点背景(深紫灰)
|
|
||||||
primaryTextColor: '#E4E4E7', // 节点文字(浅灰白)
|
|
||||||
primaryBorderColor: '#6B6575', // 节点边框(中紫灰)
|
|
||||||
secondaryColor: '#3A353E', // 次要节点背景
|
|
||||||
secondaryTextColor: '#D4D4D8',
|
|
||||||
secondaryBorderColor: '#5A5560',
|
|
||||||
tertiaryColor: '#332E38', // 第三背景色
|
|
||||||
tertiaryTextColor: '#A8A8A8',
|
|
||||||
tertiaryBorderColor: '#4A4553',
|
|
||||||
lineColor: '#8B8592', // 连接线颜色(浅紫灰)
|
|
||||||
textColor: '#E4E4E7', // 默认文字颜色
|
|
||||||
mainBkg: '#4A4553', // 主背景
|
|
||||||
nodeBorder: '#6B6575', // 节点边框
|
|
||||||
clusterBkg: '#332E38', // 分组背景
|
|
||||||
titleColor: '#F5F5F5', // 标题颜色
|
|
||||||
edgeLabelBackground: '#3A353E', // 边缘标签背景
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 Mermaid 亮色主题配置(暖色调方案)
|
|
||||||
* 米白 + 暖灰,与品牌橙色协调
|
|
||||||
*/
|
|
||||||
function getLightThemeConfig() {
|
|
||||||
return {
|
|
||||||
theme: 'base' as const,
|
|
||||||
themeVariables: {
|
|
||||||
primaryColor: '#FAF7F4', // 节点背景(米白)
|
|
||||||
primaryTextColor: '#4A4553', // 节点文字(深灰)
|
|
||||||
primaryBorderColor: '#D4C4B8', // 节点边框(暖灰)
|
|
||||||
secondaryColor: '#F5EDE6', // 次要节点背景
|
|
||||||
secondaryTextColor: '#4A4553',
|
|
||||||
secondaryBorderColor: '#D4C4B8',
|
|
||||||
tertiaryColor: '#EDE5DD', // 第三背景色
|
|
||||||
tertiaryTextColor: '#6B7280',
|
|
||||||
tertiaryBorderColor: '#C8B8A8',
|
|
||||||
lineColor: '#B8A898', // 连接线颜色(暖灰)
|
|
||||||
textColor: '#4A4553', // 默认文字颜色
|
|
||||||
mainBkg: '#FAF7F4', // 主背景
|
|
||||||
nodeBorder: '#D4C4B8', // 节点边框
|
|
||||||
clusterBkg: '#F5EDE6', // 分组背景
|
|
||||||
titleColor: '#374151', // 标题颜色
|
|
||||||
edgeLabelBackground: '#F5F3F0', // 边缘标签背景
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理全局 DOM 中的 Mermaid 错误元素
|
|
||||||
* Mermaid 渲染失败时会在 document.body 中创建错误元素
|
|
||||||
*/
|
|
||||||
function cleanupMermaidErrorElements() {
|
|
||||||
// 清理 Mermaid 创建的临时容器和错误元素
|
|
||||||
const errorElements = document.querySelectorAll('[id^="dmermaid-"], [id^="d"]');
|
|
||||||
errorElements.forEach((el) => {
|
|
||||||
// 只清理 Mermaid 相关的临时元素(通常是空的或包含错误信息)
|
|
||||||
if (el.id.startsWith('dmermaid-') || (el.id.startsWith('d') && el.id.includes('-'))) {
|
|
||||||
// 检查是否是 Mermaid 生成的元素
|
|
||||||
if (el.closest('.mermaid') === null && el.parentElement === document.body) {
|
|
||||||
el.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MermaidBlock({ code, className, isStreaming = false }: MermaidBlockProps) {
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('diagram');
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [svgContent, setSvgContent] = useState<string>('');
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
||||||
// 用于追踪是否正在等待渲染(防抖状态)
|
|
||||||
const [isWaitingToRender, setIsWaitingToRender] = useState(false);
|
|
||||||
// 当前主题状态,用于监听主题变化并触发重新渲染
|
|
||||||
const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(() =>
|
|
||||||
typeof document !== 'undefined' && isDarkTheme() ? 'dark' : 'light'
|
|
||||||
);
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const uniqueId = useId().replace(/:/g, '-');
|
|
||||||
// 防抖定时器引用
|
|
||||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
// 记录上次成功渲染的代码,避免重复渲染
|
|
||||||
const lastRenderedCodeRef = useRef<string>('');
|
|
||||||
// 使用 ref 跟踪 svgContent 是否有值,避免在 useCallback 中依赖 state
|
|
||||||
const hasSvgContentRef = useRef<boolean>(false);
|
|
||||||
|
|
||||||
// 渲染 Mermaid 图表
|
|
||||||
const renderMermaid = useCallback(async (codeToRender: string) => {
|
|
||||||
// 如果代码与上次相同且已有渲染结果,不重复渲染
|
|
||||||
if (codeToRender === lastRenderedCodeRef.current && hasSvgContentRef.current) {
|
|
||||||
setIsLoading(false);
|
|
||||||
setIsWaitingToRender(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setIsWaitingToRender(false);
|
|
||||||
|
|
||||||
// 清理全局 DOM 中的 Mermaid 错误元素
|
|
||||||
cleanupMermaidErrorElements();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 动态导入 mermaid 以避免 SSR 问题
|
|
||||||
const mermaid = (await import('mermaid')).default;
|
|
||||||
|
|
||||||
// 检测当前主题并获取对应配置
|
|
||||||
const isDark = isDarkTheme();
|
|
||||||
const themeConfig = isDark ? getDarkThemeConfig() : getLightThemeConfig();
|
|
||||||
|
|
||||||
// 初始化 mermaid 配置
|
|
||||||
mermaid.initialize({
|
|
||||||
startOnLoad: false,
|
|
||||||
theme: themeConfig.theme,
|
|
||||||
themeVariables: themeConfig.themeVariables,
|
|
||||||
securityLevel: 'loose',
|
|
||||||
// 禁止 Mermaid 在全局 DOM 显示错误
|
|
||||||
suppressErrorRendering: true,
|
|
||||||
flowchart: {
|
|
||||||
useMaxWidth: true,
|
|
||||||
htmlLabels: true,
|
|
||||||
curve: 'basis',
|
|
||||||
},
|
|
||||||
sequence: {
|
|
||||||
useMaxWidth: true,
|
|
||||||
diagramMarginX: 50,
|
|
||||||
diagramMarginY: 10,
|
|
||||||
actorMargin: 50,
|
|
||||||
width: 150,
|
|
||||||
height: 65,
|
|
||||||
boxMargin: 10,
|
|
||||||
boxTextMargin: 5,
|
|
||||||
noteMargin: 10,
|
|
||||||
messageMargin: 35,
|
|
||||||
},
|
|
||||||
gantt: {
|
|
||||||
useMaxWidth: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 渲染图表
|
|
||||||
const { svg } = await mermaid.render(`mermaid-${uniqueId}-${Date.now()}`, codeToRender.trim());
|
|
||||||
setSvgContent(svg);
|
|
||||||
hasSvgContentRef.current = true;
|
|
||||||
lastRenderedCodeRef.current = codeToRender;
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : '渲染失败';
|
|
||||||
setError(errorMessage);
|
|
||||||
hasSvgContentRef.current = false;
|
|
||||||
// 静默处理错误,不在控制台输出(流式输出时会有很多临时错误)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
setIsWaitingToRender(false);
|
|
||||||
// 再次清理,确保错误元素被移除
|
|
||||||
cleanupMermaidErrorElements();
|
|
||||||
}
|
|
||||||
}, [uniqueId]);
|
|
||||||
|
|
||||||
// 带防抖的渲染触发
|
|
||||||
useEffect(() => {
|
|
||||||
// 清理之前的定时器
|
|
||||||
if (debounceTimerRef.current) {
|
|
||||||
clearTimeout(debounceTimerRef.current);
|
|
||||||
debounceTimerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果正在流式输出,显示等待状态,不触发渲染
|
|
||||||
if (isStreaming) {
|
|
||||||
setIsWaitingToRender(true);
|
|
||||||
setIsLoading(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果代码为空或太短,不渲染
|
|
||||||
if (!code || code.trim().length < 10) {
|
|
||||||
setIsWaitingToRender(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置防抖:代码稳定后才渲染
|
|
||||||
setIsWaitingToRender(true);
|
|
||||||
debounceTimerRef.current = setTimeout(() => {
|
|
||||||
renderMermaid(code);
|
|
||||||
}, DEBOUNCE_DELAY);
|
|
||||||
|
|
||||||
// 清理函数
|
|
||||||
return () => {
|
|
||||||
if (debounceTimerRef.current) {
|
|
||||||
clearTimeout(debounceTimerRef.current);
|
|
||||||
debounceTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [code, isStreaming, renderMermaid, currentTheme]);
|
|
||||||
|
|
||||||
// 组件卸载时清理
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (debounceTimerRef.current) {
|
|
||||||
clearTimeout(debounceTimerRef.current);
|
|
||||||
}
|
|
||||||
cleanupMermaidErrorElements();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 监听主题变化,触发图表重新渲染
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
mutations.forEach((mutation) => {
|
|
||||||
if (mutation.attributeName === 'data-theme') {
|
|
||||||
const newTheme = isDarkTheme() ? 'dark' : 'light';
|
|
||||||
if (newTheme !== currentTheme) {
|
|
||||||
setCurrentTheme(newTheme);
|
|
||||||
// 清除缓存,强制重新渲染
|
|
||||||
lastRenderedCodeRef.current = '';
|
|
||||||
hasSvgContentRef.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, { attributes: true });
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [currentTheme]);
|
|
||||||
|
|
||||||
// 复制代码
|
|
||||||
const handleCopy = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(code);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换全屏
|
|
||||||
const toggleFullscreen = () => {
|
|
||||||
setIsFullscreen(!isFullscreen);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ESC 关闭全屏
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape' && isFullscreen) {
|
|
||||||
setIsFullscreen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [isFullscreen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={cn(
|
|
||||||
'relative group rounded overflow-hidden my-4 border border-[var(--color-border)]',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
style={{ boxShadow: 'var(--color-code-shadow)' }}
|
|
||||||
>
|
|
||||||
{/* 工具栏 */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-4 py-2.5 text-[0.9em]"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-code-toolbar-bg)',
|
|
||||||
color: 'var(--color-code-toolbar-text)',
|
|
||||||
borderBottom: '1px solid var(--color-code-border)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 左侧:语言标识 + 视图切换 */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Mermaid 图标 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="w-4 h-4 rounded"
|
|
||||||
style={{ background: 'linear-gradient(135deg, #FF6B6B, #4ECDC4)' }}
|
|
||||||
/>
|
|
||||||
<span className="font-mono text-[13px]">mermaid</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 视图切换 */}
|
|
||||||
<div
|
|
||||||
className="flex rounded-md overflow-hidden"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-bg-tertiary)',
|
|
||||||
border: '1px solid var(--color-border)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('diagram')}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors',
|
|
||||||
viewMode === 'diagram'
|
|
||||||
? 'bg-[var(--color-primary)] text-white'
|
|
||||||
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Eye size={12} />
|
|
||||||
<span>图表</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('code')}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors',
|
|
||||||
viewMode === 'code'
|
|
||||||
? 'bg-[var(--color-primary)] text-white'
|
|
||||||
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Code size={12} />
|
|
||||||
<span>代码</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧:操作按钮 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* 全屏按钮 */}
|
|
||||||
{viewMode === 'diagram' && !error && !isLoading && (
|
|
||||||
<button
|
|
||||||
onClick={toggleFullscreen}
|
|
||||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded transition-colors hover:bg-white/10"
|
|
||||||
style={{ color: 'var(--color-code-toolbar-text)' }}
|
|
||||||
title="全屏预览"
|
|
||||||
>
|
|
||||||
<Maximize2 size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* 复制按钮 */}
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded transition-colors hover:bg-white/10"
|
|
||||||
style={{ color: 'var(--color-code-toolbar-text)' }}
|
|
||||||
title="复制代码"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<>
|
|
||||||
<Check size={14} className="text-green-400" />
|
|
||||||
<span className="text-green-400">Copied!</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Copy size={14} />
|
|
||||||
<span>Copy</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 内容区域 */}
|
|
||||||
<div style={{ backgroundColor: viewMode === 'diagram' ? 'var(--color-mermaid-bg)' : 'var(--color-code-bg)' }}>
|
|
||||||
{viewMode === 'diagram' ? (
|
|
||||||
<div className="p-4 min-h-[200px] flex items-center justify-center">
|
|
||||||
{/* 流式输出中 - 显示代码输入中状态 */}
|
|
||||||
{isStreaming ? (
|
|
||||||
<div className="flex flex-col items-center gap-3 text-[var(--color-text-tertiary)]">
|
|
||||||
<FileCode size={32} style={{ color: 'var(--color-primary)', opacity: 0.6 }} />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>代码输入中</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0s' }} />
|
|
||||||
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
|
|
||||||
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0.4s' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs opacity-70">流式输出完成后将自动渲染图表</span>
|
|
||||||
</div>
|
|
||||||
) : isLoading || isWaitingToRender ? (
|
|
||||||
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">
|
|
||||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--color-primary)' }} />
|
|
||||||
<span>渲染中...</span>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div
|
|
||||||
className="flex items-start gap-3 p-4 rounded-lg w-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'rgba(248, 113, 113, 0.1)',
|
|
||||||
border: '1px solid rgba(248, 113, 113, 0.3)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertCircle size={20} className="text-red-400 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-red-400 mb-1">Mermaid 渲染失败</div>
|
|
||||||
<div className="text-sm text-red-300/80">{error}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="w-full overflow-auto"
|
|
||||||
dangerouslySetInnerHTML={{ __html: svgContent }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<pre className="p-4 overflow-x-auto">
|
|
||||||
<code
|
|
||||||
className="font-mono text-[13px] leading-6"
|
|
||||||
style={{ color: 'var(--color-code-text)' }}
|
|
||||||
>
|
|
||||||
{code}
|
|
||||||
</code>
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 全屏模态框 */}
|
|
||||||
{isFullscreen && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[1000] flex items-center justify-center p-8"
|
|
||||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.9)' }}
|
|
||||||
onClick={() => setIsFullscreen(false)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsFullscreen(false)}
|
|
||||||
className="absolute top-4 right-4 w-12 h-12 flex items-center justify-center rounded-xl transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-bg-tertiary)',
|
|
||||||
color: 'var(--color-text-primary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
className="rounded p-8 max-w-[90vw] max-h-[90vh] overflow-auto"
|
|
||||||
style={{ backgroundColor: 'var(--color-mermaid-bg)' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
dangerouslySetInnerHTML={{ __html: svgContent }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user