diff --git a/src/components/ChatBot/EChartsRenderer.js b/src/components/ChatBot/EChartsRenderer.js
index 1d86496c..6e9dd206 100644
--- a/src/components/ChatBot/EChartsRenderer.js
+++ b/src/components/ChatBot/EChartsRenderer.js
@@ -1,63 +1,10 @@
// src/components/ChatBot/EChartsRenderer.js
// ECharts 图表渲染组件
-import React, { useEffect, useRef, useCallback, useState } from 'react';
-import { Box, useColorModeValue, Skeleton } from '@chakra-ui/react';
+import React, { useEffect, useRef } from 'react';
+import { Box, useColorModeValue } from '@chakra-ui/react';
import * as echarts from 'echarts';
-/**
- * 验证 ECharts 配置是否有效
- */
-const isValidOption = (option) => {
- if (!option || typeof option !== 'object') return false;
-
- // 检查 xAxis 配置(柱状图/折线图必须)
- if (option.xAxis !== undefined) {
- const xAxis = Array.isArray(option.xAxis) ? option.xAxis[0] : option.xAxis;
- // xAxis 必须存在且有效
- if (!xAxis || typeof xAxis !== 'object') {
- return false;
- }
- // category 类型必须有 data
- if (xAxis.type === 'category' && (!xAxis.data || !Array.isArray(xAxis.data) || xAxis.data.length === 0)) {
- return false;
- }
- }
-
- // 检查 yAxis 配置
- if (option.yAxis !== undefined) {
- const yAxis = Array.isArray(option.yAxis) ? option.yAxis[0] : option.yAxis;
- if (!yAxis || typeof yAxis !== 'object') {
- return false;
- }
- }
-
- // 检查 series 配置(必须有且有效)
- if (!option.series) {
- return false;
- }
-
- const series = Array.isArray(option.series) ? option.series : [option.series];
- if (series.length === 0) {
- return false;
- }
-
- // 至少有一个 series 有有效数据
- const hasValidSeries = series.some(s => {
- if (!s || typeof s !== 'object') return false;
- // 检查 data 是否存在且为数组
- if (!s.data || !Array.isArray(s.data)) return false;
- // 检查 data 是否有内容
- return s.data.length > 0;
- });
-
- if (!hasValidSeries) {
- return false;
- }
-
- return true;
-};
-
/**
* ECharts 图表渲染组件
* @param {Object} option - ECharts 配置对象
@@ -67,9 +14,6 @@ const isValidOption = (option) => {
export const EChartsRenderer = ({ option, height = 400, variant = 'auto' }) => {
const chartRef = useRef(null);
const chartInstance = useRef(null);
- const resizeObserverRef = useRef(null);
- const [isReady, setIsReady] = useState(false);
- const initTimeoutRef = useRef(null);
// 系统颜色模式
const systemBgColor = useColorModeValue('white', 'transparent');
@@ -79,155 +23,74 @@ export const EChartsRenderer = ({ option, height = 400, variant = 'auto' }) => {
const isDarkMode = variant === 'dark' ? true : variant === 'light' ? false : systemIsDark;
const bgColor = variant === 'dark' ? 'transparent' : variant === 'light' ? 'white' : systemBgColor;
- // 初始化或更新图表的函数
- const initChart = useCallback(() => {
- if (!chartRef.current || !option) return;
-
- // 验证配置是否有效
- if (!isValidOption(option)) {
- console.warn('EChartsRenderer: Invalid or empty chart configuration, skipping render');
- return;
- }
-
- // 确保容器有有效尺寸
- const containerWidth = chartRef.current.offsetWidth;
- const containerHeight = chartRef.current.offsetHeight;
-
- if (containerWidth < 50 || containerHeight < 50) {
- // 容器尺寸太小,延迟重试
- initTimeoutRef.current = setTimeout(initChart, 100);
- return;
- }
-
- // 初始化图表(支持深色模式)
- if (!chartInstance.current) {
- chartInstance.current = echarts.init(chartRef.current, isDarkMode ? 'dark' : null, {
- renderer: 'canvas',
- });
- }
-
- // 深色模式下的默认文字颜色
- const darkModeTextStyle = isDarkMode
- ? {
- textStyle: { color: '#e5e7eb' },
- title: { textStyle: { color: '#f3f4f6' }, ...option?.title },
- legend: { textStyle: { color: '#d1d5db' }, ...option?.legend },
- xAxis: { axisLabel: { color: '#9ca3af' }, axisLine: { lineStyle: { color: '#4b5563' } }, ...option?.xAxis },
- yAxis: { axisLabel: { color: '#9ca3af' }, axisLine: { lineStyle: { color: '#4b5563' } }, splitLine: { lineStyle: { color: '#374151' } }, ...option?.yAxis },
- }
- : {};
-
- // 设置默认主题配置
- const defaultOption = {
- backgroundColor: 'transparent',
- grid: {
- left: '3%',
- right: '4%',
- bottom: '3%',
- containLabel: true,
- },
- ...option,
- ...darkModeTextStyle,
- };
-
- // 设置图表配置(使用 try-catch 防止 ECharts 内部错误)
- try {
- chartInstance.current.setOption(defaultOption, true);
- setIsReady(true);
- } catch (error) {
- console.error('EChartsRenderer: Failed to render chart', error);
- // 销毁出错的图表实例
- chartInstance.current?.dispose();
- chartInstance.current = null;
- return;
- }
- }, [option, isDarkMode]);
-
- // 处理容器尺寸变化
- const handleResize = useCallback(() => {
- if (chartInstance.current) {
- // 使用 requestAnimationFrame 确保在下一帧渲染时调整大小
- requestAnimationFrame(() => {
- chartInstance.current?.resize();
- });
- }
- }, []);
-
useEffect(() => {
- // 使用 setTimeout 确保 DOM 已经完全渲染
- initTimeoutRef.current = setTimeout(() => {
- initChart();
- }, 50);
-
- // 使用 ResizeObserver 监听容器尺寸变化
- if (chartRef.current && typeof ResizeObserver !== 'undefined') {
- resizeObserverRef.current = new ResizeObserver((entries) => {
- // 防抖处理
- if (initTimeoutRef.current) {
- clearTimeout(initTimeoutRef.current);
- }
- initTimeoutRef.current = setTimeout(() => {
- handleResize();
- }, 100);
- });
- resizeObserverRef.current.observe(chartRef.current);
+ if (!chartRef.current || !option) {
+ console.warn('[EChartsRenderer] Missing chartRef or option');
+ return;
}
- // 窗口 resize 事件作为备用
+ // 延迟初始化,确保 DOM 已渲染
+ const timer = setTimeout(() => {
+ try {
+ // 如果已有实例,先销毁
+ if (chartInstance.current) {
+ chartInstance.current.dispose();
+ }
+
+ // 初始化图表
+ chartInstance.current = echarts.init(chartRef.current, isDarkMode ? 'dark' : null);
+
+ // 深色模式下的样式调整
+ const darkModeStyle = isDarkMode ? {
+ backgroundColor: 'transparent',
+ textStyle: { color: '#e5e7eb' },
+ } : {};
+
+ // 合并配置
+ const finalOption = {
+ backgroundColor: 'transparent',
+ grid: {
+ left: '3%',
+ right: '4%',
+ bottom: '3%',
+ containLabel: true,
+ },
+ ...darkModeStyle,
+ ...option,
+ };
+
+ // 设置配置
+ chartInstance.current.setOption(finalOption);
+
+ console.log('[EChartsRenderer] Chart rendered successfully');
+ } catch (error) {
+ console.error('[EChartsRenderer] Failed to render chart:', error);
+ }
+ }, 100);
+
+ // 窗口 resize 处理
+ const handleResize = () => {
+ chartInstance.current?.resize();
+ };
window.addEventListener('resize', handleResize);
return () => {
+ clearTimeout(timer);
window.removeEventListener('resize', handleResize);
- if (resizeObserverRef.current) {
- resizeObserverRef.current.disconnect();
- }
- if (initTimeoutRef.current) {
- clearTimeout(initTimeoutRef.current);
- }
- };
- }, [initChart, handleResize]);
-
- // option 变化时重新渲染
- useEffect(() => {
- if (chartInstance.current && option && isValidOption(option)) {
- initChart();
- }
- }, [option, initChart]);
-
- // 组件卸载时销毁图表
- useEffect(() => {
- return () => {
if (chartInstance.current) {
chartInstance.current.dispose();
chartInstance.current = null;
}
};
- }, []);
+ }, [option, isDarkMode]);
return (
-
- {!isReady && (
-
- )}
-
-
+
);
};
diff --git a/src/components/ChatBot/MarkdownWithCharts.js b/src/components/ChatBot/MarkdownWithCharts.js
index 8a021c8d..d34f7387 100644
--- a/src/components/ChatBot/MarkdownWithCharts.js
+++ b/src/components/ChatBot/MarkdownWithCharts.js
@@ -1,13 +1,39 @@
// src/components/ChatBot/MarkdownWithCharts.js
// 支持 ECharts 图表的 Markdown 渲染组件
-import React from 'react';
+import React, { useMemo } from 'react';
import { Box, Alert, AlertIcon, Text, VStack, Code, useColorModeValue, Table, Thead, Tbody, Tr, Th, Td, TableContainer } from '@chakra-ui/react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { EChartsRenderer } from './EChartsRenderer';
import { logger } from '@utils/logger';
+/**
+ * 稳定的图表组件包装器
+ * 使用 useMemo 避免 option 对象引用变化导致的重复渲染
+ */
+const StableChart = React.memo(({ jsonString, height, variant }) => {
+ const chartOption = useMemo(() => {
+ try {
+ return JSON.parse(jsonString);
+ } catch (e) {
+ console.error('[StableChart] JSON parse error:', e);
+ return null;
+ }
+ }, [jsonString]);
+
+ if (!chartOption) {
+ return (
+
+
+ 图表配置解析失败
+
+ );
+ }
+
+ return ;
+});
+
/**
* 解析 Markdown 内容,提取 ECharts 代码块
* 支持处理:
@@ -309,194 +335,21 @@ export const MarkdownWithCharts = ({ content, variant = 'auto' }) => {
);
} else if (part.type === 'chart') {
// 渲染 ECharts 图表
+ // 清理可能的残留字符
+ let cleanContent = part.content.trim();
+ cleanContent = cleanContent.replace(/```\s*$/g, '').trim();
+
+ // 调试日志
+ console.log('[MarkdownWithCharts] Rendering chart, content length:', cleanContent.length);
+ console.log('[MarkdownWithCharts] Content preview:', cleanContent.substring(0, 100));
+
+ // 验证 JSON 是否可以解析
try {
- // 清理可能的 Markdown 残留符号和代码块标记
- let cleanContent = part.content.trim();
-
- // 移除可能残留的代码块结束标记
- cleanContent = cleanContent.replace(/```\s*$/g, '').trim();
-
- // 移除可能的前后空白和不可见字符
- cleanContent = cleanContent.replace(/^\s+|\s+$/g, '');
-
- // ========== 增强的 JSON 修复逻辑 ==========
- // 使用栈来跟踪括号和字符串状态
- const stack = [];
- let inString = false;
- let escape = false;
- let stringStartPos = -1;
-
- for (let i = 0; i < cleanContent.length; i++) {
- const char = cleanContent[i];
-
- if (escape) {
- escape = false;
- continue;
- }
-
- if (char === '\\' && inString) {
- escape = true;
- continue;
- }
-
- if (char === '"') {
- if (inString) {
- inString = false;
- stringStartPos = -1;
- } else {
- inString = true;
- stringStartPos = i;
- }
- continue;
- }
-
- if (inString) continue;
-
- if (char === '{' || char === '[') {
- stack.push(char);
- } else if (char === '}') {
- if (stack.length > 0 && stack[stack.length - 1] === '{') {
- stack.pop();
- }
- } else if (char === ']') {
- if (stack.length > 0 && stack[stack.length - 1] === '[') {
- stack.pop();
- }
- }
- }
-
- // 修复1: 如果字符串未闭合,需要关闭字符串
- if (inString) {
- logger.warn('检测到未闭合的字符串,尝试修复', {
- position: stringStartPos,
- });
- // 找到最后一个有意义的位置(非空白)
- let lastMeaningful = cleanContent.length - 1;
- while (lastMeaningful > stringStartPos && /\s/.test(cleanContent[lastMeaningful])) {
- lastMeaningful--;
- }
- // 截取到最后一个有意义的字符,然后闭合字符串
- cleanContent = cleanContent.substring(0, lastMeaningful + 1) + '"';
- logger.info('字符串已修复(添加闭合引号)');
- }
-
- // 修复2: 如果栈不为空,说明有未闭合的括号,需要补全
- if (stack.length > 0) {
- logger.warn('检测到不完整的 ECharts JSON,尝试修复', {
- unclosed: stack.join(''),
- });
-
- // 按照栈的逆序补全闭合括号
- while (stack.length > 0) {
- const open = stack.pop();
- cleanContent += open === '{' ? '}' : ']';
- }
-
- logger.info('ECharts JSON 括号已修复');
- }
-
- // 修复3: 尝试清理可能的尾部垃圾字符(如截断导致的无效字符)
- // 找到最后一个有效的 JSON 结束字符
- const lastValidEnd = Math.max(
- cleanContent.lastIndexOf('}'),
- cleanContent.lastIndexOf(']'),
- cleanContent.lastIndexOf('"')
- );
- if (lastValidEnd > 0 && lastValidEnd < cleanContent.length - 1) {
- const tail = cleanContent.substring(lastValidEnd + 1).trim();
- // 如果尾部不是有效的 JSON 字符,则截断
- if (tail && !/^[,\}\]\s]*$/.test(tail)) {
- logger.warn('检测到尾部垃圾字符,截断处理', { tail: tail.substring(0, 50) });
- cleanContent = cleanContent.substring(0, lastValidEnd + 1);
- }
- }
-
- // 尝试解析 JSON
- let chartOption;
- try {
- chartOption = JSON.parse(cleanContent);
- } catch (parseError) {
- // 如果解析失败,尝试更激进的修复
- logger.warn('首次 JSON 解析失败,尝试更激进的修复', { error: parseError.message });
-
- // 尝试找到 JSON 的有效开始和结束
- const jsonStart = cleanContent.indexOf('{');
- if (jsonStart >= 0) {
- let fixedContent = cleanContent.substring(jsonStart);
- // 重新计算并补全括号
- const fixStack = [];
- let fixInString = false;
- let fixEscape = false;
-
- for (let i = 0; i < fixedContent.length; i++) {
- const char = fixedContent[i];
- if (fixEscape) { fixEscape = false; continue; }
- if (char === '\\' && fixInString) { fixEscape = true; continue; }
- if (char === '"') { fixInString = !fixInString; continue; }
- if (fixInString) continue;
- if (char === '{' || char === '[') fixStack.push(char);
- else if (char === '}' && fixStack.length > 0 && fixStack[fixStack.length - 1] === '{') fixStack.pop();
- else if (char === ']' && fixStack.length > 0 && fixStack[fixStack.length - 1] === '[') fixStack.pop();
- }
-
- // 如果仍在字符串中,关闭字符串
- if (fixInString) fixedContent += '"';
- // 补全括号
- while (fixStack.length > 0) {
- const open = fixStack.pop();
- fixedContent += open === '{' ? '}' : ']';
- }
-
- chartOption = JSON.parse(fixedContent);
- logger.info('激进修复成功');
- } else {
- throw parseError;
- }
- }
-
- // 验证是否是有效的 ECharts 配置
- if (!chartOption || typeof chartOption !== 'object') {
- throw new Error('Invalid chart configuration: not an object');
- }
-
- // 验证 series 是否存在且有效
- if (!chartOption.series) {
- throw new Error('Invalid chart configuration: missing series');
- }
- const series = Array.isArray(chartOption.series) ? chartOption.series : [chartOption.series];
- const hasValidSeries = series.some(s => s && s.data && Array.isArray(s.data) && s.data.length > 0);
- if (!hasValidSeries) {
- throw new Error('Invalid chart configuration: series has no valid data');
- }
-
- // 验证 xAxis(如果存在)
- if (chartOption.xAxis) {
- const xAxis = Array.isArray(chartOption.xAxis) ? chartOption.xAxis[0] : chartOption.xAxis;
- if (xAxis && xAxis.type === 'category' && (!xAxis.data || !Array.isArray(xAxis.data) || xAxis.data.length === 0)) {
- throw new Error('Invalid chart configuration: xAxis category type requires data');
- }
- }
-
- return (
-
-
-
- );
- } catch (error) {
- // 记录详细的错误信息
- logger.error('解析 ECharts 配置失败', {
- error: error.message,
- contentLength: part.content.length,
- contentPreview: part.content.substring(0, 200),
- errorStack: error.stack
- });
+ const testParse = JSON.parse(cleanContent);
+ console.log('[MarkdownWithCharts] JSON valid, has series:', !!testParse.series);
+ } catch (e) {
+ console.error('[MarkdownWithCharts] JSON parse error:', e.message);
+ console.log('[MarkdownWithCharts] Problematic content:', cleanContent.substring(0, 300));
return (
@@ -506,16 +359,29 @@ export const MarkdownWithCharts = ({ content, variant = 'auto' }) => {
图表配置解析失败
- 错误: {error.message}
+ 错误: {e.message}
- {part.content.substring(0, 300)}
- {part.content.length > 300 ? '...' : ''}
+ {cleanContent.substring(0, 300)}
+ {cleanContent.length > 300 ? '...' : ''}
);
}
+
+ return (
+
+
+
+ );
}
return null;
})}