hongjli
2025-04-28 3bc274ad0cd5021ba17b3e585762c846b87ee199
渲染echarts图优化
已修改1个文件
491 ■■■■ 文件已修改
src/app/chat/page.tsx 491 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/app/chat/page.tsx
@@ -43,59 +43,76 @@
// 添加用于解析和渲染 ECharts 的组件
function EchartsRenderer({ code }: { code: string }) {
  const [error, setError] = useState<string | null>(null);
  const [isLoaded, setIsLoaded] = useState(false);
  const [isLoading, setIsLoading] = useState(true);
  const chartContainerRef = useRef<HTMLDivElement>(null);
  const codeRef = useRef<string>(code);
  const renderAttemptedRef = useRef(false);
  const hasRenderedRef = useRef<boolean>(false);
  
  // 更新代码引用
  // 初始化DOM容器
  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;
    // 确保容器准备好了
    if (chartContainerRef.current) {
      // 设置一个初始高度和宽度,避免"invalid dom"错误
      const container = chartContainerRef.current;
      container.style.width = '100%';
      container.style.height = '400px';
      
      const initChart = async () => {
      // 添加临时内容,确保DOM渲染完成
      container.innerHTML = '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;"><span>正在准备图表...</span></div>';
    }
  }, []);
  // 一次性加载图表,不进行动态更新
  useEffect(() => {
    // 如果已经渲染过,跳过
    if (hasRenderedRef.current) {
      return;
    }
    setIsLoading(true);
    // 确保DOM元素已经完全挂载和渲染
    const timer = setTimeout(() => {
      // 确保DOM元素仍然存在
      if (!chartContainerRef.current) {
        console.error('图表容器不存在,跳过初始化');
        setIsLoading(false);
        return;
      }
      // 清除临时内容
      chartContainerRef.current.innerHTML = '';
      // 标记为已渲染
      hasRenderedRef.current = true;
      // 简化的图表初始化逻辑
      const loadChart = async () => {
        try {
          // 动态导入echarts
          const echarts = await import('echarts');
          
          if (!chartContainerRef.current) {
            console.warn('图表容器已不存在');
          // 再次确认DOM元素存在并具有有效尺寸
          if (!chartContainerRef.current ||
              !chartContainerRef.current.offsetWidth ||
              !chartContainerRef.current.offsetHeight) {
            console.error('图表容器尺寸无效,跳过初始化');
            setError('图表容器尺寸无效');
            setIsLoading(false);
            return;
          }
          
          // 初始化图表
          chartInstance = echarts.init(chartContainerRef.current);
          // 初始化图表 - 添加渲染器类型参数
          const chartInstance = echarts.init(
            chartContainerRef.current,
            undefined,
            { renderer: 'canvas', devicePixelRatio: window.devicePixelRatio || 1 }
          );
          
          // 使用Function构造器,但添加额外的保护
          try {
            // 安全处理代码
            const safeCode = code.replace(/window\.option/g, '_uniqueOptionVar');
            // 执行代码获取配置
            const getFinalOption = new Function(`
              "use strict";
              let option;
@@ -111,163 +128,79 @@
            
            const chartOption = getFinalOption();
            
            // 应用配置
            if (chartOption) {
              chartInstance.setOption(chartOption);
              setError(null);
            } else {
              console.warn('未获取到有效的图表配置,使用备用配置');
              useFallbackOption(chartInstance);
              // 应用默认配置
              chartInstance.setOption({
                title: { text: '图表数据解析失败' },
                xAxis: { type: 'category', data: ['无数据'] },
                yAxis: { type: 'value' },
                series: [{ data: [0], type: 'bar' }]
              });
            }
            setIsLoading(false);
          } catch (e) {
            console.error('执行图表代码错误:', e);
            setError(e instanceof Error ? e.message : '图表代码执行错误');
            useFallbackOption(chartInstance);
            console.error('图表代码执行错误:', e);
            setError('图表解析失败');
            setIsLoading(false);
          }
          
          // 添加响应式调整
          const handleResize = () => {
            chartInstance && chartInstance.resize();
          };
          // 窗口大小改变时,重设图表大小
          const handleResize = () => chartInstance.resize();
          window.addEventListener('resize', handleResize);
          
          // 确保组件卸载时清理资源
          // 组件卸载时清理
          return () => {
            window.removeEventListener('resize', handleResize);
            if (chartInstance) {
              try {
                chartInstance.dispose();
              } catch (e) {
                console.warn('图表实例销毁失败', e);
              }
            }
            try {
              chartInstance.dispose();
            } catch (e) {}
          };
        } catch (e) {
          console.error('图表初始化失败:', e);
          setError('加载图表库失败');
          return () => {};
          console.error('图表加载失败:', e);
          setError('图表库加载失败');
          setIsLoading(false);
        }
      };
      
      // 备用配置函数
      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]);
  // 渲染图表容器
      // 执行加载
      loadChart();
    }, 500); // 增加延迟,确保DOM完全渲染
    return () => clearTimeout(timer);
  }, [code]);
  // 简化的渲染逻辑,确保容器有明确的尺寸
  return (
    <div className="relative my-4">
    <div className="relative my-4 echart-container">
      {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>
      )}
      {isLoading && (
        <div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-70 z-10">
          <div className="flex flex-col items-center">
            <div className="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-blue-500 mb-3"></div>
            <p className="text-gray-500">图表加载中...</p>
          </div>
        </div>
      )}
      <div 
        ref={chartContainerRef} 
        className="w-full bg-white border border-gray-200 rounded-lg overflow-hidden"
        style={{ height: '400px' }}
        className="w-full bg-white border border-gray-200 rounded-lg overflow-hidden chart-container"
        style={{
          height: '400px',
          minHeight: '300px', // 确保有最小高度
          visibility: 'visible', // 确保容器可见
          position: 'relative' // 创建BFC
        }}
        data-echarts-container="true" // 添加标识属性
      />
    </div>
  );
@@ -322,6 +255,84 @@
  );
}
// 创建独立的输入组件,避免状态共享导致的重渲染
interface ChatInputProps {
  onSendMessage: () => void;
  isStreaming: boolean;
  isMessageComplete: boolean;
}
function ChatInput({ onSendMessage, isStreaming, isMessageComplete }: ChatInputProps) {
  // 内部状态,与外部完全隔离
  const [inputText, setInputText] = useState('');
  const inputRef = useRef<HTMLTextAreaElement>(null);
  // 处理输入变化
  const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setInputText(e.target.value);
  };
  // 处理按键事件
  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };
  // 处理发送
  const handleSend = () => {
    if (isStreaming || !inputText.trim() || !isMessageComplete) return;
    // 通知父组件发送消息前更新消息内容
    (window as any).messageToSend = inputText.trim();
    // 清空输入
    setInputText('');
    // 调用父组件的发送方法
    onSendMessage();
  };
  return (
    <div className="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-white via-white to-white/95 pt-4 pb-6" style={{ zIndex: 50 }}>
      <div className="max-w-4xl mx-auto px-6">
        <div className="relative">
          <div className="absolute -top-6 left-1/2 -translate-x-1/2 w-48 h-[1px] bg-gradient-to-r from-transparent via-gray-200 to-transparent"></div>
          <div className="flex gap-4 items-start">
            <div className="flex-1 relative">
              <textarea
                ref={inputRef}
                value={inputText}
                onChange={handleInputChange}
                onKeyDown={handleKeyDown}
                placeholder="输入消息..."
                disabled={isStreaming}
                className="w-full resize-none rounded-xl border-0 bg-gray-50 px-4 py-3 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-100/50 focus:bg-white min-h-[48px] max-h-32 shadow-inner transition-all duration-300 ease-in-out hover:bg-gray-100/70 disabled:opacity-50 disabled:cursor-not-allowed"
                style={{ height: '48px' }}
              />
              <div className="absolute right-4 bottom-2 text-xs text-gray-400 bg-gray-50 px-2">
                按Enter发送,Shift+Enter换行
              </div>
            </div>
            <button
              onClick={handleSend}
              disabled={isStreaming || !inputText.trim() || !isMessageComplete}
              className="h-12 px-5 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-medium rounded-xl transition-all duration-300 flex items-center gap-2 shadow-lg shadow-blue-500/20 hover:shadow-blue-500/30 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
            >
              <span>{isStreaming ? '回复中...' : (isMessageComplete ? '发送' : '处理中...')}</span>
              <svg xmlns="http://www.w3.org/2000/svg" className={`h-4 w-4 transform rotate-45 ${isStreaming ? 'animate-pulse' : ''}`} viewBox="0 0 20 20" fill="currentColor">
                <path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" />
              </svg>
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}
export default function Page() {
  const router = useRouter();
  const [apiKey, setApiKey] = useState<string>('');
@@ -352,6 +363,40 @@
  // 在组件顶部添加一个引用,用于跟踪组件是否已卸载
  const isMountedRef = useRef(true);
  // 分离消息输入状态,避免触发不必要的重渲染
  const messageInputRef = useRef<HTMLTextAreaElement>(null);
  const [localMessage, setLocalMessage] = useState('');
  const handleMessageChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    // 只更新本地状态,不触发全局重渲染
    setLocalMessage(e.target.value);
  };
  // 仅在发送时更新全局消息状态
  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      // 更新全局状态并发送
      setMessage(localMessage);
      setTimeout(() => handleSendMessage(), 10);
    }
  };
  // 点击发送按钮时
  const handleSendButtonClick = () => {
    // 检查消息是否为空
    if (!localMessage.trim() || !isMessageComplete) return;
    // 更新全局状态并发送
    setMessage(localMessage);
    setTimeout(() => handleSendMessage(), 10);
  };
  // 同步消息状态到本地状态
  useEffect(() => {
    setLocalMessage(message);
  }, [message]);
  useEffect(() => {
    // 获取存储的API Key
@@ -427,7 +472,9 @@
  };
  const handleSendMessage = async () => {
    if (!message.trim() || !isMessageComplete) return;
    // 从window对象获取消息内容
    const inputMessage = (window as any).messageToSend || '';
    if (!inputMessage.trim() || !isMessageComplete) return;
    if (!apiKey) {
      showErrorMessage('请先设置API Key');
      return;
@@ -439,7 +486,7 @@
    // 创建新消息
    const userMessage: Message = {
      role: 'user',
      content: message.trim(),
      content: inputMessage.trim(),
      timestamp: Date.now(),
      id: 'user-' + Date.now().toString()
    };
@@ -466,6 +513,9 @@
    setCurrentMessageId(assistantMessage.id);
    setMessage('');
    // 清除输入内容
    (window as any).messageToSend = '';
    let controller: AbortController | null = new AbortController();
    
@@ -768,16 +818,6 @@
    });
  }, []);
  // 更新发送按钮的禁用状态
  const isSendDisabled = isStreaming || !message.trim() || !isMessageComplete;
  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSendMessage();
    }
  };
  useEffect(() => {
    // 页面加载后延迟显示消息列表,避免闪烁
    const timer = setTimeout(() => {
@@ -797,6 +837,37 @@
      .dot-animation span:nth-child(1) { animation: dotBounce 1.4s -0.32s infinite ease-in-out; }
      .dot-animation span:nth-child(2) { animation: dotBounce 1.4s -0.16s infinite ease-in-out; }
      .dot-animation span:nth-child(3) { animation: dotBounce 1.4s 0s infinite ease-in-out; }
      /* 优化图表容器样式,不再使用isolation和will-change */
      .echart-wrapper {
        position: relative;
        z-index: auto;
      }
      /* 使用更温和的性能优化属性,避免层级错误 */
      canvas {
        transition: none !important;
      }
      /* 修复输入框遮挡问题 */
      .fixed.bottom-0 {
        z-index: 10;
      }
      /* 防止图表闪烁但不影响其他元素 */
      .echart-wrapper > div {
        transform: translateZ(0);
        will-change: transform;
        transition: none !important;
      }
      /* 修复echarts渲染问题 */
      [data-echarts-container] {
        visibility: visible !important;
        opacity: 1 !important;
        width: 100% !important;
        contain: none !important;
      }
    `;
    document.head.appendChild(style);
@@ -970,15 +1041,6 @@
                          : 'bg-[#E8F4FF] rounded-xl'
                        } inline-block max-w-[85%] relative overflow-hidden`}>
                        
                        {/* 调试信息 - 移除这部分 */}
                        {/*
                        {process.env.NODE_ENV !== 'production' && (
                          <div className="bg-gray-100 px-2 py-1 text-xs text-gray-500">
                            长度: {(msg.content || '').length} | 计数: {forceUpdateCounter}
                          </div>
                        )}
                        */}
                        {msg.role === 'assistant' && (
                          <>{(() => {
                            // 解析消息内容,提取思考部分
@@ -1089,41 +1151,12 @@
          </div>
        </div>
        {/* 输入区域 */}
        <div className="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-white via-white to-white/95 pt-4 pb-6">
          <div className="max-w-4xl mx-auto px-6">
            <div className="relative">
              <div className="absolute -top-6 left-1/2 -translate-x-1/2 w-48 h-[1px] bg-gradient-to-r from-transparent via-gray-200 to-transparent"></div>
              <div className="flex gap-4 items-start">
                <div className="flex-1 relative">
                  <textarea
                    value={message}
                    onChange={(e) => setMessage(e.target.value)}
                    onKeyDown={handleKeyPress}
                    placeholder="输入消息..."
                    disabled={isStreaming}
                    className="w-full resize-none rounded-xl border-0 bg-gray-50 px-4 py-3 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-100/50 focus:bg-white min-h-[48px] max-h-32 shadow-inner transition-all duration-300 ease-in-out hover:bg-gray-100/70 disabled:opacity-50 disabled:cursor-not-allowed"
                    style={{ height: '48px' }}
                  />
                  <div className="absolute right-4 bottom-2 text-xs text-gray-400 bg-gray-50 px-2">
                    按Enter发送,Shift+Enter换行
                  </div>
                </div>
                <button
                  onClick={handleSendMessage}
                  disabled={isSendDisabled}
                  className="h-12 px-5 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-medium rounded-xl transition-all duration-300 flex items-center gap-2 shadow-lg shadow-blue-500/20 hover:shadow-blue-500/30 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
                >
                  <span>{isStreaming ? '回复中...' : (isMessageComplete ? '发送' : '处理中...')}</span>
                  <svg xmlns="http://www.w3.org/2000/svg" className={`h-4 w-4 transform rotate-45 ${isStreaming ? 'animate-pulse' : ''}`} viewBox="0 0 20 20" fill="currentColor">
                    <path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" />
                  </svg>
                </button>
              </div>
            </div>
          </div>
        </div>
        {/* 输入区域 - 抽取为独立组件 */}
        <ChatInput
          onSendMessage={handleSendMessage}
          isStreaming={isStreaming}
          isMessageComplete={isMessageComplete}
        />
      </div>
    </div>
  );