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; })}