From fa194a5db2fedc1589195432019ef19fe1f8bcc1 Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 17 Jul 2025 23:27:52 +0800 Subject: [PATCH] =?UTF-8?q?=20feat:=20=E6=9C=8D=E8=A3=85=E6=90=AD=E9=85=8D?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2UI=E7=BE=8E=E5=8C=96=E5=92=8CUX=E6=94=B9?= =?UTF-8?q?=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI优化内容: - 重新设计页面头部,使用优雅的渐变背景和现代化图标 - 优化标签导航,采用卡片式设计和平滑动画效果 - 美化搜索面板,改进输入框、筛选器和按钮的视觉设计 - 重构图片上传组件,添加拖拽区域样式和上传进度动画 - 优化搜索结果展示,使用网格布局和悬停效果 - 改进AI分析结果展示,采用卡片式布局和颜色编码 - 增强LLM聊天界面,现代化消息气泡和打字动画 响应式设计: - 实现移动优先的响应式布局 - 优化平板端和桌面端适配 - 修复1200px宽度下的左右布局显示问题 - 添加触摸友好的交互元素 用户体验提升: - 统一设计语言和视觉风格 - 添加流畅的页面切换和组件加载动画 - 优化加载状态、错误提示和空状态设计 - 改进信息层次和视觉可读性 技术改进: - 使用Tailwind CSS类替代内联样式 - 统一使用Lucide React图标库 - 完善CSS变量和设计令牌系统 - 添加兼容性变量支持旧的命名格式 符合promptx/frontend-developer规定的前端开发规范,确保界面美观、操作流畅、动画优美,符合用户操作习惯和大众审美习惯。 --- .../src/components/outfit/ColorPicker.tsx | 416 +++++++++++++++ .../src/components/outfit/FilterPanel.tsx | 505 ++++++++++++++++++ .../src/components/outfit/ImageUploader.tsx | 218 ++++++++ .../desktop/src/components/outfit/LLMChat.tsx | 279 ++++++++++ .../src/components/outfit/OutfitCard.tsx | 368 +++++++++++++ .../components/outfit/OutfitSearchPanel.tsx | 220 ++++++++ .../src/components/outfit/SearchResults.tsx | 174 ++++++ apps/desktop/src/pages/OutfitMatch.tsx | 301 +++++++++++ .../src/services/outfitSearchService.ts | 288 ++++++++++ apps/desktop/src/store/outfitSearchStore.ts | 326 +++++++++++ apps/desktop/src/styles/design-system.css | 68 ++- apps/desktop/src/types/outfitSearch.ts | 287 ++++++++++ apps/desktop/src/utils/colorUtils.ts | 300 +++++++++++ docs/outfit-matching-search-system.md | 355 ++++++++++++ 14 files changed, 4097 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/components/outfit/ColorPicker.tsx create mode 100644 apps/desktop/src/components/outfit/FilterPanel.tsx create mode 100644 apps/desktop/src/components/outfit/ImageUploader.tsx create mode 100644 apps/desktop/src/components/outfit/LLMChat.tsx create mode 100644 apps/desktop/src/components/outfit/OutfitCard.tsx create mode 100644 apps/desktop/src/components/outfit/OutfitSearchPanel.tsx create mode 100644 apps/desktop/src/components/outfit/SearchResults.tsx create mode 100644 apps/desktop/src/pages/OutfitMatch.tsx create mode 100644 apps/desktop/src/services/outfitSearchService.ts create mode 100644 apps/desktop/src/store/outfitSearchStore.ts create mode 100644 apps/desktop/src/types/outfitSearch.ts create mode 100644 apps/desktop/src/utils/colorUtils.ts create mode 100644 docs/outfit-matching-search-system.md diff --git a/apps/desktop/src/components/outfit/ColorPicker.tsx b/apps/desktop/src/components/outfit/ColorPicker.tsx new file mode 100644 index 0000000..73db8b9 --- /dev/null +++ b/apps/desktop/src/components/outfit/ColorPicker.tsx @@ -0,0 +1,416 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { ColorHSV, ColorPickerProps } from '../../types/outfitSearch'; +import { ColorUtils } from '../../utils/colorUtils'; + +/** + * HSV颜色选择器组件 + * 遵循 Tauri 开发规范的组件设计原则 + */ +export const ColorPicker: React.FC = ({ + color, + onChange, + disabled = false, +}) => { + const [isDragging, setIsDragging] = useState(false); + const [dragTarget, setDragTarget] = useState<'hue' | 'saturation' | 'value' | null>(null); + const hueRef = useRef(null); + const saturationRef = useRef(null); + const valueRef = useRef(null); + + // 处理鼠标按下事件 + const handleMouseDown = useCallback((target: 'hue' | 'saturation' | 'value') => { + if (disabled) return; + setIsDragging(true); + setDragTarget(target); + }, [disabled]); + + // 处理鼠标移动事件 + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!isDragging || !dragTarget || disabled) return; + + const rect = (() => { + switch (dragTarget) { + case 'hue': + return hueRef.current?.getBoundingClientRect(); + case 'saturation': + return saturationRef.current?.getBoundingClientRect(); + case 'value': + return valueRef.current?.getBoundingClientRect(); + default: + return null; + } + })(); + + if (!rect) return; + + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + + const newColor = { ...color }; + switch (dragTarget) { + case 'hue': + newColor.hue = x; + break; + case 'saturation': + newColor.saturation = x; + break; + case 'value': + newColor.value = x; + break; + } + + onChange(newColor); + }, [isDragging, dragTarget, disabled, color, onChange]); + + // 处理鼠标释放事件 + const handleMouseUp = useCallback(() => { + setIsDragging(false); + setDragTarget(null); + }, []); + + // 添加全局事件监听器 + useEffect(() => { + if (isDragging) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [isDragging, handleMouseMove, handleMouseUp]); + + // 处理点击事件 + const handleClick = useCallback(( + e: React.MouseEvent, + target: 'hue' | 'saturation' | 'value' + ) => { + if (disabled) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + + const newColor = { ...color }; + switch (target) { + case 'hue': + newColor.hue = x; + break; + case 'saturation': + newColor.saturation = x; + break; + case 'value': + newColor.value = x; + break; + } + + onChange(newColor); + }, [disabled, color, onChange]); + + // 处理输入框变化 + const handleInputChange = useCallback(( + field: 'hue' | 'saturation' | 'value', + value: string + ) => { + if (disabled) return; + + const numValue = parseFloat(value); + if (isNaN(numValue)) return; + + const clampedValue = Math.max(0, Math.min(1, numValue)); + const newColor = { ...color, [field]: clampedValue }; + onChange(newColor); + }, [disabled, color, onChange]); + + // 处理十六进制输入 + const handleHexChange = useCallback((hex: string) => { + if (disabled) return; + + try { + const newColor = ColorUtils.hexToHsv(hex); + onChange(newColor); + } catch (error) { + // 忽略无效的十六进制值 + } + }, [disabled, onChange]); + + // 生成色相条背景 + const hueBarBackground = 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)'; + + // 生成饱和度条背景 + const saturationBarBackground = `linear-gradient(to right, + ${ColorUtils.hsvToHex({ hue: color.hue, saturation: 0, value: color.value })}, + ${ColorUtils.hsvToHex({ hue: color.hue, saturation: 1, value: color.value })})`; + + // 生成明度条背景 + const valueBarBackground = `linear-gradient(to right, + ${ColorUtils.hsvToHex({ hue: color.hue, saturation: color.saturation, value: 0 })}, + ${ColorUtils.hsvToHex({ hue: color.hue, saturation: color.saturation, value: 1 })})`; + + const currentHex = ColorUtils.hsvToHex(color); + + return ( +
+ {/* 颜色预览 */} +
+
+
+ handleHexChange(e.target.value)} + disabled={disabled} + className="hex-input" + placeholder="#FFFFFF" + maxLength={7} + /> +
+
+ + {/* 色相滑块 */} +
+ +
handleMouseDown('hue')} + onClick={(e) => handleClick(e, 'hue')} + > +
+
+ handleInputChange('hue', e.target.value)} + disabled={disabled} + className="value-input" + min="0" + max="1" + step="0.001" + /> +
+ + {/* 饱和度滑块 */} +
+ +
handleMouseDown('saturation')} + onClick={(e) => handleClick(e, 'saturation')} + > +
+
+ handleInputChange('saturation', e.target.value)} + disabled={disabled} + className="value-input" + min="0" + max="1" + step="0.001" + /> +
+ + {/* 明度滑块 */} +
+ +
handleMouseDown('value')} + onClick={(e) => handleClick(e, 'value')} + > +
+
+ handleInputChange('value', e.target.value)} + disabled={disabled} + className="value-input" + min="0" + max="1" + step="0.001" + /> +
+ + {/* 预设颜色 */} +
+
常用颜色
+
+ {PRESET_COLORS.map((presetColor, index) => ( +
+
+ + +
+ ); +}; + +// 预设颜色 +const PRESET_COLORS: ColorHSV[] = [ + { hue: 0, saturation: 1, value: 1 }, // 红色 + { hue: 0.083, saturation: 1, value: 1 }, // 橙色 + { hue: 0.167, saturation: 1, value: 1 }, // 黄色 + { hue: 0.333, saturation: 1, value: 1 }, // 绿色 + { hue: 0.5, saturation: 1, value: 1 }, // 青色 + { hue: 0.667, saturation: 1, value: 1 }, // 蓝色 + { hue: 0.833, saturation: 1, value: 1 }, // 紫色 + { hue: 0.917, saturation: 1, value: 1 }, // 粉色 + { hue: 0, saturation: 0, value: 0 }, // 黑色 + { hue: 0, saturation: 0, value: 0.2 }, // 深灰 + { hue: 0, saturation: 0, value: 0.5 }, // 中灰 + { hue: 0, saturation: 0, value: 0.8 }, // 浅灰 + { hue: 0, saturation: 0, value: 1 }, // 白色 + { hue: 0.083, saturation: 0.8, value: 0.6 }, // 棕色 + { hue: 0.167, saturation: 0.6, value: 0.9 }, // 米色 + { hue: 0.5, saturation: 0.3, value: 0.7 }, // 灰蓝 +]; + +export default ColorPicker; diff --git a/apps/desktop/src/components/outfit/FilterPanel.tsx b/apps/desktop/src/components/outfit/FilterPanel.tsx new file mode 100644 index 0000000..9181ffb --- /dev/null +++ b/apps/desktop/src/components/outfit/FilterPanel.tsx @@ -0,0 +1,505 @@ +import React, { useState, useCallback } from 'react'; +import { + FilterPanelProps, + ColorFilter, + DEFAULT_COLOR_FILTER, + COMMON_CATEGORIES, + COMMON_ENVIRONMENTS, + COMMON_DESIGN_STYLES +} from '../../types/outfitSearch'; +import { ColorPicker } from './ColorPicker'; +import { ColorUtils } from '../../utils/colorUtils'; + +/** + * 高级过滤面板组件 + * 遵循 Tauri 开发规范的组件设计原则 + */ +export const FilterPanel: React.FC = ({ + config, + onConfigChange, +}) => { + const [activeColorCategory, setActiveColorCategory] = useState(null); + + // 处理类别选择 + const handleCategoryToggle = useCallback((category: string) => { + const newCategories = config.categories.includes(category) + ? config.categories.filter(c => c !== category) + : [...config.categories, category]; + + onConfigChange({ + ...config, + categories: newCategories, + }); + }, [config, onConfigChange]); + + // 处理环境标签选择 + const handleEnvironmentToggle = useCallback((environment: string) => { + const newEnvironments = config.environments.includes(environment) + ? config.environments.filter(e => e !== environment) + : [...config.environments, environment]; + + onConfigChange({ + ...config, + environments: newEnvironments, + }); + }, [config, onConfigChange]); + + // 处理设计风格选择 + const handleDesignStyleToggle = useCallback((category: string, style: string) => { + const currentStyles = config.design_styles[category] || []; + const newStyles = currentStyles.includes(style) + ? currentStyles.filter(s => s !== style) + : [...currentStyles, style]; + + onConfigChange({ + ...config, + design_styles: { + ...config.design_styles, + [category]: newStyles, + }, + }); + }, [config, onConfigChange]); + + // 处理颜色过滤器启用/禁用 + const handleColorFilterToggle = useCallback((category: string) => { + const currentFilter = config.color_filters[category] || DEFAULT_COLOR_FILTER; + const newFilter: ColorFilter = { + ...currentFilter, + enabled: !currentFilter.enabled, + }; + + onConfigChange({ + ...config, + color_filters: { + ...config.color_filters, + [category]: newFilter, + }, + }); + }, [config, onConfigChange]); + + // 处理颜色变化 + const handleColorChange = useCallback((category: string, color: any) => { + const currentFilter = config.color_filters[category] || DEFAULT_COLOR_FILTER; + const newFilter: ColorFilter = { + ...currentFilter, + color, + }; + + onConfigChange({ + ...config, + color_filters: { + ...config.color_filters, + [category]: newFilter, + }, + }); + }, [config, onConfigChange]); + + // 处理阈值变化 + const handleThresholdChange = useCallback(( + category: string, + field: 'hue_threshold' | 'saturation_threshold' | 'value_threshold', + value: number + ) => { + const currentFilter = config.color_filters[category] || DEFAULT_COLOR_FILTER; + const newFilter: ColorFilter = { + ...currentFilter, + [field]: value, + }; + + onConfigChange({ + ...config, + color_filters: { + ...config.color_filters, + [category]: newFilter, + }, + }); + }, [config, onConfigChange]); + + // 清除所有筛选 + const handleClearFilters = useCallback(() => { + onConfigChange({ + ...config, + categories: [], + environments: [], + color_filters: {}, + design_styles: {}, + }); + }, [config, onConfigChange]); + + // 检查是否有活动筛选 + const hasActiveFilters = config.categories.length > 0 || + config.environments.length > 0 || + Object.values(config.color_filters).some(f => f.enabled) || + Object.values(config.design_styles).some(styles => styles.length > 0); + + return ( +
+
+

高级筛选

+ {hasActiveFilters && ( + + )} +
+ + {/* 类别筛选 */} +
+

服装类别

+
+ {COMMON_CATEGORIES.map((category) => ( + + ))} +
+
+ + {/* 环境标签筛选 */} +
+

环境场景

+
+ {COMMON_ENVIRONMENTS.map((environment) => ( + + ))} +
+
+ + {/* 颜色筛选 */} +
+

颜色匹配

+
+ {config.categories.map((category) => { + const colorFilter = config.color_filters[category] || DEFAULT_COLOR_FILTER; + const isActive = activeColorCategory === category; + + return ( +
+
+ + + {colorFilter.enabled && ( +
+
setActiveColorCategory(isActive ? null : category)} + /> + +
+ )} +
+ + {colorFilter.enabled && isActive && ( +
+ handleColorChange(category, color)} + /> + +
+
+ + handleThresholdChange(category, 'hue_threshold', parseFloat(e.target.value))} + className="threshold-slider" + /> + {colorFilter.hue_threshold.toFixed(2)} +
+ +
+ + handleThresholdChange(category, 'saturation_threshold', parseFloat(e.target.value))} + className="threshold-slider" + /> + {colorFilter.saturation_threshold.toFixed(2)} +
+ +
+ + handleThresholdChange(category, 'value_threshold', parseFloat(e.target.value))} + className="threshold-slider" + /> + {colorFilter.value_threshold.toFixed(2)} +
+
+
+ )} +
+ ); + })} + + {config.categories.length === 0 && ( +

+ 请先选择服装类别以启用颜色筛选 +

+ )} +
+
+ + {/* 设计风格筛选 */} +
+

设计风格

+ {config.categories.map((category) => ( +
+
{category}
+
+ {COMMON_DESIGN_STYLES.map((style) => { + const isSelected = (config.design_styles[category] || []).includes(style); + return ( + + ); + })} +
+
+ ))} + + {config.categories.length === 0 && ( +

+ 请先选择服装类别以启用风格筛选 +

+ )} +
+ + +
+ ); +}; + +export default FilterPanel; diff --git a/apps/desktop/src/components/outfit/ImageUploader.tsx b/apps/desktop/src/components/outfit/ImageUploader.tsx new file mode 100644 index 0000000..043cdb3 --- /dev/null +++ b/apps/desktop/src/components/outfit/ImageUploader.tsx @@ -0,0 +1,218 @@ +import React, { useCallback, useState } from 'react'; +import { open } from '@tauri-apps/plugin-dialog'; +import { ImageUploaderProps, SUPPORTED_IMAGE_FORMATS } from '../../types/outfitSearch'; +import OutfitSearchService from '../../services/outfitSearchService'; +import { Upload, Image as ImageIcon, X, Sparkles, AlertCircle } from 'lucide-react'; + +/** + * 图像上传和分析组件 + * 遵循 Tauri 开发规范的组件设计原则 + */ +export const ImageUploader: React.FC = ({ + onImageSelect, + onAnalysisComplete, + isAnalyzing, + selectedImage, +}) => { + const [dragOver, setDragOver] = useState(false); + const [error, setError] = useState(null); + // const fileInputRef = useRef(null); + + // 处理文件选择 + const handleFileSelect = useCallback(async () => { + try { + const selected = await open({ + multiple: false, + filters: [ + { + name: '图像文件', + extensions: SUPPORTED_IMAGE_FORMATS, + }, + ], + }); + + if (selected && typeof selected === 'string') { + setError(null); + onImageSelect(selected); + } + } catch (error) { + console.error('Failed to select file:', error); + setError('文件选择失败'); + } + }, [onImageSelect]); + + // 处理拖拽进入 + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(true); + }, []); + + // 处理拖拽离开 + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + }, []); + + // 处理拖拽悬停 + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + // 处理文件拖放 + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + + const file = files[0]; + const extension = file.name.split('.').pop()?.toLowerCase(); + + if (!extension || !SUPPORTED_IMAGE_FORMATS.includes(extension)) { + setError(`不支持的文件格式。支持的格式:${SUPPORTED_IMAGE_FORMATS.join(', ')}`); + return; + } + + // 在实际应用中,这里需要将文件保存到本地并获取路径 + // 暂时使用文件名作为路径 + setError(null); + onImageSelect(file.name); + }, [onImageSelect]); + + // 执行图像分析 + const handleAnalyze = useCallback(async () => { + if (!selectedImage) return; + + try { + setError(null); + const response = await OutfitSearchService.analyzeOutfitImage({ + image_path: selectedImage, + image_name: selectedImage.split('/').pop() || selectedImage, + }); + + onAnalysisComplete(response.result); + } catch (error) { + console.error('Failed to analyze image:', error); + setError(error instanceof Error ? error.message : '图像分析失败'); + } + }, [selectedImage, onAnalysisComplete]); + + // 清除选择的图像 + const handleClearImage = useCallback(() => { + onImageSelect(null); + setError(null); + }, [onImageSelect]); + + return ( +
+
+
+ +
+
+

图像分析

+

+ 上传服装图片进行AI分析,提取颜色、风格和类别信息 +

+
+
+ + {!selectedImage ? ( +
+
+
+ +
+
+

+ 点击选择图片或拖拽图片到此处 +

+

+ 支持格式:{SUPPORTED_IMAGE_FORMATS.join(', ')} +

+
+
+ + {dragOver && ( +
+ )} +
+ ) : ( +
+
+
+ Selected outfit setError('图片加载失败')} + /> +
+ +
+
+ +
+
+ + {selectedImage.split('/').pop()} +
+ + +
+
+ )} + + {error && ( +
+ + {error} +
+ )} +
+ + ); +}; + +export default ImageUploader; diff --git a/apps/desktop/src/components/outfit/LLMChat.tsx b/apps/desktop/src/components/outfit/LLMChat.tsx new file mode 100644 index 0000000..aa2aee1 --- /dev/null +++ b/apps/desktop/src/components/outfit/LLMChat.tsx @@ -0,0 +1,279 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { LLMChatProps } from '../../types/outfitSearch'; +import { OutfitCard } from './OutfitCard'; +import { MessageCircle, Send, Trash2, Bot, User, AlertCircle, Sparkles } from 'lucide-react'; + +/** + * LLM聊天组件 + * 遵循 Tauri 开发规范的组件设计原则 + */ +export const LLMChat: React.FC = ({ + onAskLLM, + response, + isLoading, + error, +}) => { + const [input, setInput] = useState(''); + const [chatHistory, setChatHistory] = useState>([]); + const inputRef = useRef(null); + const chatContainerRef = useRef(null); + + // 处理发送消息 + const handleSendMessage = useCallback(() => { + if (input.trim().length === 0 || isLoading) return; + + const userMessage = input.trim(); + setInput(''); + + // 添加用户消息到历史 + setChatHistory(prev => [...prev, { + type: 'user', + content: userMessage, + timestamp: new Date(), + }]); + + // 发送到LLM + onAskLLM({ + user_input: userMessage, + }); + }, [input, isLoading, onAskLLM]); + + // 处理回车键发送 + const handleKeyPress = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }, [handleSendMessage]); + + // 当收到LLM响应时更新聊天历史 + useEffect(() => { + if (response && !isLoading) { + setChatHistory(prev => [...prev, { + type: 'assistant', + content: response.answer, + timestamp: new Date(), + relatedResults: response.related_results, + }]); + } + }, [response, isLoading]); + + // 自动滚动到底部 + useEffect(() => { + if (chatContainerRef.current) { + chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; + } + }, [chatHistory, isLoading]); + + // 清除聊天历史 + const handleClearChat = useCallback(() => { + setChatHistory([]); + }, []); + + // 预设问题 + const presetQuestions = [ + '如何搭配牛仔裤?', + '正式场合应该穿什么?', + '夏季有什么清爽的搭配?', + '约会穿什么比较好?', + '运动风格怎么搭配?', + ]; + + // 处理预设问题点击 + const handlePresetClick = useCallback((question: string) => { + setInput(question); + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); + + return ( +
+
+
+
+ +
+
+

AI搭配顾问

+

+ 向AI顾问咨询搭配建议,获得专业的时尚指导 +

+
+
+ {chatHistory.length > 0 && ( + + )} +
+ + {/* 聊天历史 - 现代化设计 */} +
+ {chatHistory.length === 0 && ( +
+
+
+
+ +
+
+
+

欢迎咨询AI搭配顾问

+

+ 您可以询问任何关于服装搭配的问题,我会为您提供专业建议 +

+
+
+
+ )} + + {chatHistory.map((message, index) => ( +
+ {message.type === 'assistant' && ( +
+ +
+ )} + +
+
+
+ {message.content} +
+
+
+ {message.timestamp.toLocaleTimeString()} +
+ + {/* 相关搜索结果 - 响应式 */} + {message.relatedResults && message.relatedResults.length > 0 && ( +
+
+ + 相关搭配推荐 +
+
+ {message.relatedResults.slice(0, 3).map((result, resultIndex) => ( +
+ +
+ ))} +
+
+ )} +
+ + {message.type === 'user' && ( +
+ +
+ )} +
+ ))} + + {/* 加载状态 - 打字动画 */} + {isLoading && ( +
+
+ +
+
+
+
+
+
+
+
+
+ AI正在思考中... +
+
+
+
+ )} + + {/* 错误消息 */} + {error && ( +
+ + {error} +
+ )} +
+ + {/* 预设问题 - 美化设计 */} + {chatHistory.length === 0 && ( +
+
常见问题
+
+ {presetQuestions.map((question, index) => ( + + ))} +
+
+ )} + + {/* 输入区域 - 现代化设计 */} +
+
+
+