hongjli
2025-04-27 e9b0e1af02b05de795420ccac832823e62eb8516
渲染echarts图
已修改3个文件
363 ■■■■■ 文件已修改
package-lock.json 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/app/chat/page.tsx 307 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package-lock.json
@@ -11,6 +11,8 @@
        "@headlessui/react": "^2.2.1",
        "@types/styled-components": "^5.1.34",
        "axios": "^1.8.4",
        "echarts": "^5.6.0",
        "echarts-for-react": "^3.0.2",
        "framer-motion": "^12.6.0",
        "next": "15.2.4",
        "react": "^19.0.0",
@@ -2737,6 +2739,36 @@
        "node": ">= 0.4"
      }
    },
    "node_modules/echarts": {
      "version": "5.6.0",
      "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
      "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "2.3.0",
        "zrender": "5.6.1"
      }
    },
    "node_modules/echarts-for-react": {
      "version": "3.0.2",
      "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.2.tgz",
      "integrity": "sha512-DRwIiTzx8JfwPOVgGttDytBqdp5VzCSyMRIxubgU/g2n9y3VLUmF2FK7Icmg/sNVkv4+rktmrLN9w22U2yy3fA==",
      "license": "MIT",
      "dependencies": {
        "fast-deep-equal": "^3.1.3",
        "size-sensor": "^1.0.1"
      },
      "peerDependencies": {
        "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0",
        "react": "^15.0.0 || >=16.0.0"
      }
    },
    "node_modules/echarts/node_modules/tslib": {
      "version": "2.3.0",
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
      "license": "0BSD"
    },
    "node_modules/emoji-regex": {
      "version": "9.2.2",
      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -3399,7 +3431,6 @@
      "version": "3.1.3",
      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/fast-glob": {
@@ -7063,6 +7094,12 @@
        "is-arrayish": "^0.3.1"
      }
    },
    "node_modules/size-sensor": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.2.tgz",
      "integrity": "sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw==",
      "license": "ISC"
    },
    "node_modules/source-map-js": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -7930,6 +7967,21 @@
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/zrender": {
      "version": "5.6.1",
      "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
      "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
      "license": "BSD-3-Clause",
      "dependencies": {
        "tslib": "2.3.0"
      }
    },
    "node_modules/zrender/node_modules/tslib": {
      "version": "2.3.0",
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
      "license": "0BSD"
    },
    "node_modules/zustand": {
      "version": "5.0.3",
      "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz",
package.json
@@ -12,6 +12,8 @@
    "@headlessui/react": "^2.2.1",
    "@types/styled-components": "^5.1.34",
    "axios": "^1.8.4",
    "echarts": "^5.6.0",
    "echarts-for-react": "^3.0.2",
    "framer-motion": "^12.6.0",
    "next": "15.2.4",
    "react": "^19.0.0",
src/app/chat/page.tsx
@@ -8,6 +8,11 @@
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import dynamic from 'next/dynamic';
import { ReactNode } from 'react';
// 动态导入 ECharts,确保它只在客户端渲染
const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false });
interface Message {
  role: 'user' | 'assistant';
@@ -31,6 +36,274 @@
// 默认用户头像
const DEFAULT_USER_AVATAR = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23999999'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z'/%3E%3C/svg%3E";
const AI_AVATAR = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%234F46E5'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 1c4.96 0 9 4.04 9 9s-4.04 9-9 9-9-4.04-9-9 4.04-9 9-9zm0 3.5c-2.48 0-4.5 2.02-4.5 4.5h1.5c0-1.66 1.34-3 3-3s3 1.34 3 3h1.5c0-2.48-2.02-4.5-4.5-4.5zM8 13h2v2H8v-2zm6 0h2v2h-2v-2z'/%3E%3C/svg%3E";
// 添加用于解析和渲染 ECharts 的组件
function EchartsRenderer({ code }: { code: string }) {
  const [error, setError] = useState<string | null>(null);
  const [isLoaded, setIsLoaded] = useState(false);
  const chartContainerRef = useRef<HTMLDivElement>(null);
  const codeRef = useRef<string>(code);
  const renderAttemptedRef = useRef(false);
  // 更新代码引用
  useEffect(() => {
    codeRef.current = code;
  }, [code]);
  // 延迟初始化直到消息稳定
  useEffect(() => {
    // 为确保流式内容稳定,设置延迟
    const timer = setTimeout(() => {
      setIsLoaded(true);
    }, 500);
    return () => clearTimeout(timer);
  }, []);
  // 主要渲染逻辑 - 仅在组件标记为已加载后执行
  useEffect(() => {
    // 如果组件未标记为已加载或已经尝试过渲染,则退出
    if (!isLoaded || renderAttemptedRef.current) return;
    // 确保只在客户端执行
    if (typeof window === 'undefined' || !chartContainerRef.current) return;
    // 标记为已尝试渲染,防止重复渲染
    renderAttemptedRef.current = true;
    // 防止代码执行中的变量冲突
    const safeCode = codeRef.current.replace(/window\.option/g, '_uniqueOptionVar');
    // 等待DOM更新完成
    setTimeout(() => {
      let chartInstance: any = null;
      const initChart = async () => {
        try {
          // 动态导入echarts
          const echarts = await import('echarts');
          if (!chartContainerRef.current) {
            console.warn('图表容器已不存在');
            return;
          }
          // 初始化图表
          chartInstance = echarts.init(chartContainerRef.current);
          // 使用Function构造器,但添加额外的保护
          try {
            const getFinalOption = new Function(`
              "use strict";
              let option;
              let _uniqueOptionVar;
              try {
                ${safeCode}
                return option || _uniqueOptionVar;
              } catch (e) {
                console.error("ECharts内部执行错误:", e);
                return null;
              }
            `);
            const chartOption = getFinalOption();
            if (chartOption) {
              chartInstance.setOption(chartOption);
              setError(null);
            } else {
              console.warn('未获取到有效的图表配置,使用备用配置');
              useFallbackOption(chartInstance);
            }
          } catch (e) {
            console.error('执行图表代码错误:', e);
            setError(e instanceof Error ? e.message : '图表代码执行错误');
            useFallbackOption(chartInstance);
          }
          // 添加响应式调整
          const handleResize = () => {
            chartInstance && chartInstance.resize();
          };
          window.addEventListener('resize', handleResize);
          // 确保组件卸载时清理资源
          return () => {
            window.removeEventListener('resize', handleResize);
            if (chartInstance) {
              try {
                chartInstance.dispose();
              } catch (e) {
                console.warn('图表实例销毁失败', e);
              }
            }
          };
        } catch (e) {
          console.error('图表初始化失败:', e);
          setError('加载图表库失败');
          return () => {};
        }
      };
      // 备用配置函数
      const useFallbackOption = (instance: any) => {
        try {
          // 简单的备用配置
          const fallbackOption = {
            title: {
              text: '数据可视化图表',
              subtext: '(原始代码解析失败,显示备用图表)'
            },
            tooltip: {
              trigger: 'axis'
            },
            legend: {
              data: ['数据']
            },
            xAxis: {
              type: 'category',
              data: ['项目1', '项目2', '项目3', '项目4', '项目5']
            },
            yAxis: {
              type: 'value'
            },
            series: [{
              name: '数据',
              type: 'bar',
              data: [5, 20, 36, 10, 10]
            }]
          };
          // 检测是否是热力图代码
          if (safeCode.includes('heatmap') ||
              safeCode.includes('visualMap') ||
              safeCode.includes('风险')) {
            (fallbackOption.series as any) = [{
              type: 'heatmap',
              name: '风险值',  // 添加缺失的name属性
              data: [
                [0, 0, 5], [0, 1, 7], [0, 2, 3],
                [1, 0, 7], [1, 1, 8], [1, 2, 6],
                [2, 0, 9], [2, 1, 10], [2, 2, 8]
              ] as any[]
            }];
            fallbackOption.title.text = 'VIP客户订单交付风险热力图';
            fallbackOption.title.subtext = '数据解析失败,显示示例热力图';
            // 添加热力图所需的其他配置
            Object.assign(fallbackOption, {
              tooltip: {
                position: 'top'
              },
              xAxis: {
                type: 'category',
                data: ['低', '中', '高'],
                name: '延迟风险'
              },
              yAxis: {
                type: 'category',
                data: ['低', '中', '高'],
                name: '违约成本'
              },
              visualMap: {
                min: 1,
                max: 10,
                calculable: true,
                orient: 'horizontal',
                left: 'center',
                bottom: '15%'
              }
            });
          }
          // 设置备用配置
          instance.setOption(fallbackOption);
        } catch (e) {
          console.error('应用备用配置失败:', e);
        }
      };
      // 初始化图表并获取清理函数
      const cleanupPromise = initChart();
      // 完全重写清理函数逻辑,避免使用Promise的不确定返回类型
      // 初始化图表
      initChart().then(cleanupFn => {
        // 存储清理函数供以后使用
        if (typeof cleanupFn === 'function') {
          // 使用ref存储清理函数
          const currentCleanup = cleanupFn;
          // 组件卸载时执行
          return () => currentCleanup();
        }
      }).catch(e => {
        console.error('初始化图表失败:', e);
      });
      // 返回一个空的清理函数,避免类型错误
      return () => {};
    }, 100);
  }, [isLoaded]);
  // 渲染图表容器
  return (
    <div className="relative my-4">
      {error && (
        <div className="absolute top-0 left-0 right-0 bg-red-50 border border-red-200 text-red-600 px-3 py-2 rounded-md text-xs z-10">
          {error}
          <button
            className="ml-2 text-red-700 hover:text-red-900"
            onClick={() => setError(null)}
          >
            ×
          </button>
        </div>
      )}
      <div
        ref={chartContainerRef}
        className="w-full bg-white border border-gray-200 rounded-lg overflow-hidden"
        style={{ height: '400px' }}
      />
    </div>
  );
}
// 修改代码块渲染组件,添加更好的类型检测
function CodeBlockRenderer({ language, value }: { language: string; value: string }) {
  // 更精确地检测ECharts代码
  const isEchartsCode = () => {
    if (language !== 'javascript') return false;
    // 检查是否包含ECharts特有的配置项
    const hasEchartsConfig =
      value.includes('option =') ||
      value.includes('const option') ||
      value.includes('let option') ||
      value.includes('series') &&
      (value.includes('type:') || value.includes('tooltip:') || value.includes('xAxis:'));
    return hasEchartsConfig;
  };
  // 如果是 ECharts 代码则渲染图表
  if (isEchartsCode()) {
    return <EchartsRenderer code={value} />;
  }
  // 否则按普通代码块渲染
  return (
    <div className="bg-gray-800 rounded-md my-2 overflow-hidden">
      <div className="flex items-center justify-between px-4 py-2 border-b border-gray-700">
        <span className="text-xs text-gray-400">{language}</span>
      </div>
      <pre className="p-4 text-gray-300 overflow-x-auto">
        <code>{value}</code>
      </pre>
    </div>
  );
}
export default function Page() {
  const router = useRouter();
@@ -729,8 +1002,35 @@
                                
                                {/* 主要内容 */}
                                <div className="p-3">
                                  <div className="text-gray-800 leading-relaxed whitespace-pre-wrap">
                                    {mainContent || (msg.role === 'assistant' && !isMessageComplete ? '正在思考...' : '')}
                                  <div className="text-gray-800 leading-relaxed">
                                    {mainContent ? (
                                      <ReactMarkdown
                                        remarkPlugins={[remarkGfm]}
                                        rehypePlugins={[rehypeRaw, rehypeSanitize]}
                                        components={{
                                          // @ts-ignore - ReactMarkdown 组件类型定义的兼容性问题
                                          code: ({ node, inline, className, children, ...props }) => {
                                            const match = /language-(\w+)/.exec(className || '');
                                            const language = match ? match[1] : '';
                                            const value = String(children).replace(/\n$/, '');
                                            if (!inline && match) {
                                              return <CodeBlockRenderer language={language} value={value} />;
                                            }
                                            return (
                                              <code className={className} {...props}>
                                                {children}
                                              </code>
                                            );
                                          }
                                        }}
                                      >
                                        {mainContent}
                                      </ReactMarkdown>
                                    ) : (
                                      msg.role === 'assistant' && !isMessageComplete ? '处理回复中...' : ''
                                    )}
                                  </div>
                                </div>
                                
@@ -808,5 +1108,4 @@
      </div>
    </div>
  );
}
}