| | |
| | | // 添加用于解析和渲染 ECharts 的组件 |
| | | function EchartsRenderer({ code }: { code: string }) { |
| | | const [error, setError] = useState<string | null>(null); |
| | | const [isLoaded, setIsLoaded] = useState(false); |
| | | const [isLoading, setIsLoading] = useState(true); |
| | | const [isModalOpen, setIsModalOpen] = useState(false); |
| | | const chartContainerRef = useRef<HTMLDivElement>(null); |
| | | const codeRef = useRef<string>(code); |
| | | const renderAttemptedRef = useRef(false); |
| | | const modalChartRef = useRef<HTMLDivElement>(null); |
| | | const hasRenderedRef = useRef<boolean>(false); |
| | | const chartInstanceRef = useRef<any>(null); |
| | | |
| | | // 更新代码引用 |
| | | // 初始化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 initializeChart = async () => { |
| | | try { |
| | | // 动态导入echarts |
| | | const echarts = await import('echarts'); |
| | | |
| | | if (!chartContainerRef.current) { |
| | | console.warn('图表容器已不存在'); |
| | | return; |
| | | } |
| | | |
| | | // 初始化图表 |
| | | chartInstance = echarts.init(chartContainerRef.current); |
| | | const chartInstance = echarts.init(chartContainerRef.current); |
| | | chartInstanceRef.current = chartInstance; |
| | | |
| | | // 使用Function构造器,但添加额外的保护 |
| | | // 尝试解析和设置图表选项 |
| | | try { |
| | | const getFinalOption = new Function(` |
| | | const safeCode = code.replace(/window\.option/g, 'option'); |
| | | const safeFunc = new Function(` |
| | | "use strict"; |
| | | let option; |
| | | let _uniqueOptionVar; |
| | | try { |
| | | ${safeCode} |
| | | return option || _uniqueOptionVar; |
| | | return option; |
| | | } catch (e) { |
| | | console.error("ECharts内部执行错误:", e); |
| | | console.error("图表代码执行错误:", e); |
| | | return null; |
| | | } |
| | | `); |
| | | |
| | | const chartOption = getFinalOption(); |
| | | const chartOption = safeFunc(); |
| | | |
| | | if (chartOption) { |
| | | chartInstance.setOption(chartOption); |
| | | setError(null); |
| | | } else { |
| | | console.warn('未获取到有效的图表配置,使用备用配置'); |
| | | useFallbackOption(chartInstance); |
| | | throw new Error("无法获取图表配置"); |
| | | } |
| | | } 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[] |
| | | }]; |
| | | console.error('图表代码执行错误:', e); |
| | | setError('图表配置错误'); |
| | | |
| | | 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%' |
| | | } |
| | | // 设置一个默认图表以显示错误 |
| | | chartInstance.setOption({ |
| | | title: { text: '图表配置错误' }, |
| | | xAxis: { type: 'category', data: ['错误'] }, |
| | | yAxis: { type: 'value' }, |
| | | series: [{ data: [0], type: 'bar' }] |
| | | }); |
| | | } |
| | | |
| | | // 设置备用配置 |
| | | instance.setOption(fallbackOption); |
| | | // 添加窗口大小变化监听器 |
| | | const handleResize = () => chartInstance.resize(); |
| | | window.addEventListener('resize', handleResize); |
| | | |
| | | // 清理函数 |
| | | return () => { |
| | | window.removeEventListener('resize', handleResize); |
| | | chartInstance.dispose(); |
| | | }; |
| | | } catch (e) { |
| | | console.error('应用备用配置失败:', e); |
| | | console.error('ECharts加载失败:', e); |
| | | setError('图表库加载失败'); |
| | | } finally { |
| | | setIsLoading(false); |
| | | } |
| | | }; |
| | | |
| | | // 初始化图表并获取清理函数 |
| | | 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]); |
| | | initializeChart(); |
| | | }, 500); // 增加延迟,确保DOM完全渲染 |
| | | |
| | | return () => clearTimeout(timer); |
| | | }, [code]); |
| | | |
| | | // 当全屏状态变化时重新调整图表大小 |
| | | useEffect(() => { |
| | | if (chartInstanceRef.current) { |
| | | setTimeout(() => { |
| | | chartInstanceRef.current.resize(); |
| | | }, 300); // 给DOM一些时间来更新 |
| | | } |
| | | }, [isModalOpen]); |
| | | |
| | | // 处理全屏切换 |
| | | const toggleModal = useCallback(() => { |
| | | setIsModalOpen(prev => !prev); |
| | | }, []); |
| | | |
| | | // 渲染图表容器 |
| | | // 处理模态窗口的图表 |
| | | useEffect(() => { |
| | | if (!isModalOpen || !modalChartRef.current) return; |
| | | |
| | | const initModalChart = async () => { |
| | | try { |
| | | const echarts = await import('echarts'); |
| | | |
| | | // 初始化模态窗口中的图表 |
| | | const modalChartInstance = echarts.init(modalChartRef.current); |
| | | |
| | | // 如果主图表已经初始化,复用其配置 |
| | | if (chartInstanceRef.current) { |
| | | const option = chartInstanceRef.current.getOption(); |
| | | modalChartInstance.setOption(option); |
| | | } else { |
| | | // 如果主图表没有初始化,尝试从代码初始化 |
| | | try { |
| | | const safeCode = code.replace(/window\.option/g, 'option'); |
| | | const safeFunc = new Function(` |
| | | "use strict"; |
| | | let option; |
| | | try { |
| | | ${safeCode} |
| | | return option; |
| | | } catch (e) { |
| | | console.error("模态图表代码执行错误:", e); |
| | | return null; |
| | | } |
| | | `); |
| | | |
| | | const chartOption = safeFunc(); |
| | | |
| | | if (chartOption) { |
| | | modalChartInstance.setOption(chartOption); |
| | | } else { |
| | | throw new Error("无法获取模态图表配置"); |
| | | } |
| | | } catch (e) { |
| | | console.error('模态图表代码执行错误:', e); |
| | | |
| | | // 设置一个默认图表 |
| | | modalChartInstance.setOption({ |
| | | title: { text: '图表配置错误' }, |
| | | xAxis: { type: 'category', data: ['错误'] }, |
| | | yAxis: { type: 'value' }, |
| | | series: [{ data: [0], type: 'bar' }] |
| | | }); |
| | | } |
| | | } |
| | | |
| | | // 添加窗口大小变化监听器 |
| | | const handleResize = () => modalChartInstance.resize(); |
| | | window.addEventListener('resize', handleResize); |
| | | |
| | | // 返回清理函数 |
| | | return () => { |
| | | window.removeEventListener('resize', handleResize); |
| | | modalChartInstance.dispose(); |
| | | }; |
| | | } catch (e) { |
| | | console.error('模态图表初始化失败:', e); |
| | | } |
| | | }; |
| | | |
| | | // 延迟一点执行,确保DOM已更新完成 |
| | | const timer = setTimeout(initModalChart, 100); |
| | | return () => clearTimeout(timer); |
| | | }, [isModalOpen, code]); |
| | | |
| | | // 简化的渲染逻辑,确保容器有明确的尺寸 |
| | | return ( |
| | | <div className="relative my-4"> |
| | | {isLoading && ( |
| | | <div className="absolute inset-0 flex items-center justify-center bg-white/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> |
| | | )} |
| | | |
| | | {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 |
| | | ref={chartContainerRef} |
| | | className="w-full bg-white border border-gray-200 rounded-lg overflow-hidden chart-container" |
| | | style={{ |
| | | height: '300px', |
| | | minHeight: '300px', |
| | | visibility: 'visible', |
| | | position: 'relative' |
| | | }} |
| | | data-echarts-container="true" |
| | | /> |
| | | |
| | | {/* 打开弹窗按钮 */} |
| | | <button |
| | | onClick={() => setIsModalOpen(true)} |
| | | className="absolute top-2 right-2 bg-white/90 hover:bg-white p-2 rounded-full shadow-md z-20 transition-all duration-300 hover:scale-110 cursor-pointer" |
| | | title="在弹窗中查看" |
| | | > |
| | | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| | | <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 3h6v6m0-6L14 9M9 21H3v-6m0 6l7-7" /> |
| | | </svg> |
| | | </button> |
| | | |
| | | {/* 弹出式对话框 */} |
| | | {isModalOpen && ( |
| | | <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"> |
| | | <div className="bg-white rounded-lg w-[90vw] max-w-6xl h-[80vh] relative flex flex-col"> |
| | | {/* 对话框头部 */} |
| | | <div className="flex justify-between items-center p-4 border-b"> |
| | | <h3 className="text-xl font-semibold text-gray-800">数据可视化图表</h3> |
| | | <button |
| | | onClick={() => setIsModalOpen(false)} |
| | | className="p-1 rounded-full hover:bg-gray-100 cursor-pointer" |
| | | aria-label="关闭" |
| | | > |
| | | <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| | | <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> |
| | | </svg> |
| | | </button> |
| | | </div> |
| | | |
| | | {/* 对话框主体 - 图表 */} |
| | | <div className="flex-1 overflow-hidden p-4"> |
| | | <div |
| | | ref={modalChartRef} |
| | | className="w-full h-full" |
| | | data-echarts-modal |
| | | ></div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | )} |
| | | </div> |
| | | ); |
| | | } |
| | |
| | | ); |
| | | } |
| | | |
| | | // 创建独立的输入组件,避免状态共享导致的重渲染 |
| | | 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 cursor-pointer" |
| | | > |
| | | <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>(''); |
| | |
| | | |
| | | // 在组件顶部添加一个引用,用于跟踪组件是否已卸载 |
| | | 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 |
| | |
| | | }; |
| | | |
| | | 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; |
| | |
| | | // 创建新消息 |
| | | const userMessage: Message = { |
| | | role: 'user', |
| | | content: message.trim(), |
| | | content: inputMessage.trim(), |
| | | timestamp: Date.now(), |
| | | id: 'user-' + Date.now().toString() |
| | | }; |
| | |
| | | |
| | | setCurrentMessageId(assistantMessage.id); |
| | | setMessage(''); |
| | | |
| | | // 清除输入内容 |
| | | (window as any).messageToSend = ''; |
| | | |
| | | let controller: AbortController | null = new AbortController(); |
| | | |
| | |
| | | }); |
| | | }, []); |
| | | |
| | | // 更新发送按钮的禁用状态 |
| | | const isSendDisabled = isStreaming || !message.trim() || !isMessageComplete; |
| | | |
| | | const handleKeyPress = (e: React.KeyboardEvent) => { |
| | | if (e.key === 'Enter' && !e.shiftKey) { |
| | | e.preventDefault(); |
| | | handleSendMessage(); |
| | | } |
| | | }; |
| | | |
| | | useEffect(() => { |
| | | // 页面加载后延迟显示消息列表,避免闪烁 |
| | | const timer = setTimeout(() => { |
| | |
| | | .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); |
| | | |
| | |
| | | : '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' && ( |
| | | <>{(() => { |
| | | // 解析消息内容,提取思考部分 |
| | |
| | | <div className="p-3"> |
| | | <div className="text-gray-800 leading-relaxed"> |
| | | {mainContent ? ( |
| | | <MessageCompletionContext.Provider value={isMessageComplete && msg.id === currentMessageId}> |
| | | <MessageCompletionContext.Provider |
| | | value={msg.id !== currentMessageId || isMessageComplete} |
| | | > |
| | | <ReactMarkdown |
| | | remarkPlugins={[remarkGfm]} |
| | | rehypePlugins={[rehypeRaw, rehypeSanitize]} |
| | |
| | | </div> |
| | | |
| | | {/* 加载指示器 */} |
| | | {msg.role === 'assistant' && !isMessageComplete && ( |
| | | {msg.role === 'assistant' && |
| | | !isMessageComplete && |
| | | msg.id === currentMessageId && ( // 只在当前处理的消息显示加载指示器 |
| | | <div className="absolute bottom-1 right-2"> |
| | | <div className="flex space-x-1"> |
| | | <div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse delay-0"></div> |
| | |
| | | </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> |
| | | ); |