feat: 服装搭配页面UI美化和UX改进

UI优化内容:
- 重新设计页面头部,使用优雅的渐变背景和现代化图标
- 优化标签导航,采用卡片式设计和平滑动画效果
- 美化搜索面板,改进输入框、筛选器和按钮的视觉设计
- 重构图片上传组件,添加拖拽区域样式和上传进度动画
- 优化搜索结果展示,使用网格布局和悬停效果
- 改进AI分析结果展示,采用卡片式布局和颜色编码
- 增强LLM聊天界面,现代化消息气泡和打字动画

 响应式设计:
- 实现移动优先的响应式布局
- 优化平板端和桌面端适配
- 修复1200px宽度下的左右布局显示问题
- 添加触摸友好的交互元素

 用户体验提升:
- 统一设计语言和视觉风格
- 添加流畅的页面切换和组件加载动画
- 优化加载状态、错误提示和空状态设计
- 改进信息层次和视觉可读性

 技术改进:
- 使用Tailwind CSS类替代内联样式
- 统一使用Lucide React图标库
- 完善CSS变量和设计令牌系统
- 添加兼容性变量支持旧的命名格式

符合promptx/frontend-developer规定的前端开发规范,确保界面美观、操作流畅、动画优美,符合用户操作习惯和大众审美习惯。
This commit is contained in:
imeepos
2025-07-17 23:27:52 +08:00
parent 504b1a6577
commit fa194a5db2
14 changed files with 4097 additions and 8 deletions

View File

@@ -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<ColorPickerProps> = ({
color,
onChange,
disabled = false,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [dragTarget, setDragTarget] = useState<'hue' | 'saturation' | 'value' | null>(null);
const hueRef = useRef<HTMLDivElement>(null);
const saturationRef = useRef<HTMLDivElement>(null);
const valueRef = useRef<HTMLDivElement>(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 (
<div className={`color-picker ${disabled ? 'disabled' : ''}`}>
{/* 颜色预览 */}
<div className="color-preview-section">
<div
className="color-preview"
style={{ backgroundColor: currentHex }}
title={`当前颜色: ${currentHex}`}
/>
<div className="color-info">
<input
type="text"
value={currentHex}
onChange={(e) => handleHexChange(e.target.value)}
disabled={disabled}
className="hex-input"
placeholder="#FFFFFF"
maxLength={7}
/>
</div>
</div>
{/* 色相滑块 */}
<div className="slider-section">
<label className="slider-label"> (H)</label>
<div
ref={hueRef}
className="color-slider hue-slider"
style={{ background: hueBarBackground }}
onMouseDown={() => handleMouseDown('hue')}
onClick={(e) => handleClick(e, 'hue')}
>
<div
className="slider-thumb"
style={{ left: `${color.hue * 100}%` }}
/>
</div>
<input
type="number"
value={color.hue.toFixed(3)}
onChange={(e) => handleInputChange('hue', e.target.value)}
disabled={disabled}
className="value-input"
min="0"
max="1"
step="0.001"
/>
</div>
{/* 饱和度滑块 */}
<div className="slider-section">
<label className="slider-label"> (S)</label>
<div
ref={saturationRef}
className="color-slider saturation-slider"
style={{ background: saturationBarBackground }}
onMouseDown={() => handleMouseDown('saturation')}
onClick={(e) => handleClick(e, 'saturation')}
>
<div
className="slider-thumb"
style={{ left: `${color.saturation * 100}%` }}
/>
</div>
<input
type="number"
value={color.saturation.toFixed(3)}
onChange={(e) => handleInputChange('saturation', e.target.value)}
disabled={disabled}
className="value-input"
min="0"
max="1"
step="0.001"
/>
</div>
{/* 明度滑块 */}
<div className="slider-section">
<label className="slider-label"> (V)</label>
<div
ref={valueRef}
className="color-slider value-slider"
style={{ background: valueBarBackground }}
onMouseDown={() => handleMouseDown('value')}
onClick={(e) => handleClick(e, 'value')}
>
<div
className="slider-thumb"
style={{ left: `${color.value * 100}%` }}
/>
</div>
<input
type="number"
value={color.value.toFixed(3)}
onChange={(e) => handleInputChange('value', e.target.value)}
disabled={disabled}
className="value-input"
min="0"
max="1"
step="0.001"
/>
</div>
{/* 预设颜色 */}
<div className="preset-colors">
<div className="preset-colors-label"></div>
<div className="preset-colors-grid">
{PRESET_COLORS.map((presetColor, index) => (
<button
key={index}
className="preset-color-button"
style={{ backgroundColor: ColorUtils.hsvToHex(presetColor) }}
onClick={() => !disabled && onChange(presetColor)}
disabled={disabled}
title={`预设颜色 ${index + 1}`}
/>
))}
</div>
</div>
<style>{`
.color-picker {
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: white;
min-width: 280px;
}
.color-picker.disabled {
opacity: 0.6;
pointer-events: none;
}
.color-preview-section {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.color-preview {
width: 48px;
height: 48px;
border-radius: 8px;
border: 2px solid #e5e7eb;
cursor: pointer;
}
.hex-input {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-family: monospace;
font-size: 14px;
width: 100px;
}
.slider-section {
margin-bottom: 12px;
}
.slider-label {
display: block;
font-size: 12px;
font-weight: 500;
color: #374151;
margin-bottom: 4px;
}
.color-slider {
position: relative;
height: 20px;
border-radius: 10px;
cursor: pointer;
margin-bottom: 4px;
border: 1px solid #e5e7eb;
}
.slider-thumb {
position: absolute;
top: -2px;
width: 16px;
height: 24px;
background: white;
border: 2px solid #374151;
border-radius: 3px;
transform: translateX(-50%);
cursor: grab;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.slider-thumb:active {
cursor: grabbing;
}
.value-input {
width: 80px;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
text-align: center;
}
.preset-colors {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
}
.preset-colors-label {
font-size: 12px;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
}
.preset-colors-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
}
.preset-color-button {
width: 24px;
height: 24px;
border: 1px solid #e5e7eb;
border-radius: 4px;
cursor: pointer;
transition: transform 0.1s;
}
.preset-color-button:hover {
transform: scale(1.1);
}
.preset-color-button:disabled {
cursor: not-allowed;
transform: none;
}
`}</style>
</div>
);
};
// 预设颜色
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;

View File

@@ -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<FilterPanelProps> = ({
config,
onConfigChange,
}) => {
const [activeColorCategory, setActiveColorCategory] = useState<string | null>(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 (
<div className="filter-panel">
<div className="filter-header">
<h3 className="filter-title"></h3>
{hasActiveFilters && (
<button onClick={handleClearFilters} className="clear-filters-button">
</button>
)}
</div>
{/* 类别筛选 */}
<div className="filter-section">
<h4 className="section-title"></h4>
<div className="filter-tags">
{COMMON_CATEGORIES.map((category) => (
<button
key={category}
onClick={() => handleCategoryToggle(category)}
className={`filter-tag ${config.categories.includes(category) ? 'active' : ''}`}
>
{category}
</button>
))}
</div>
</div>
{/* 环境标签筛选 */}
<div className="filter-section">
<h4 className="section-title"></h4>
<div className="filter-tags">
{COMMON_ENVIRONMENTS.map((environment) => (
<button
key={environment}
onClick={() => handleEnvironmentToggle(environment)}
className={`filter-tag ${config.environments.includes(environment) ? 'active' : ''}`}
>
{environment}
</button>
))}
</div>
</div>
{/* 颜色筛选 */}
<div className="filter-section">
<h4 className="section-title"></h4>
<div className="color-filters">
{config.categories.map((category) => {
const colorFilter = config.color_filters[category] || DEFAULT_COLOR_FILTER;
const isActive = activeColorCategory === category;
return (
<div key={category} className="color-filter-item">
<div className="color-filter-header">
<label className="color-filter-label">
<input
type="checkbox"
checked={colorFilter.enabled}
onChange={() => handleColorFilterToggle(category)}
className="color-filter-checkbox"
/>
{category}
</label>
{colorFilter.enabled && (
<div className="color-preview-container">
<div
className="color-preview-small"
style={{ backgroundColor: ColorUtils.hsvToHex(colorFilter.color) }}
onClick={() => setActiveColorCategory(isActive ? null : category)}
/>
<button
onClick={() => setActiveColorCategory(isActive ? null : category)}
className="color-edit-button"
>
{isActive ? '收起' : '编辑'}
</button>
</div>
)}
</div>
{colorFilter.enabled && isActive && (
<div className="color-picker-container">
<ColorPicker
color={colorFilter.color}
onChange={(color) => handleColorChange(category, color)}
/>
<div className="threshold-controls">
<div className="threshold-control">
<label></label>
<input
type="range"
min="0"
max="0.2"
step="0.01"
value={colorFilter.hue_threshold}
onChange={(e) => handleThresholdChange(category, 'hue_threshold', parseFloat(e.target.value))}
className="threshold-slider"
/>
<span className="threshold-value">{colorFilter.hue_threshold.toFixed(2)}</span>
</div>
<div className="threshold-control">
<label></label>
<input
type="range"
min="0"
max="0.2"
step="0.01"
value={colorFilter.saturation_threshold}
onChange={(e) => handleThresholdChange(category, 'saturation_threshold', parseFloat(e.target.value))}
className="threshold-slider"
/>
<span className="threshold-value">{colorFilter.saturation_threshold.toFixed(2)}</span>
</div>
<div className="threshold-control">
<label></label>
<input
type="range"
min="0"
max="0.5"
step="0.01"
value={colorFilter.value_threshold}
onChange={(e) => handleThresholdChange(category, 'value_threshold', parseFloat(e.target.value))}
className="threshold-slider"
/>
<span className="threshold-value">{colorFilter.value_threshold.toFixed(2)}</span>
</div>
</div>
</div>
)}
</div>
);
})}
{config.categories.length === 0 && (
<p className="no-categories-hint">
</p>
)}
</div>
</div>
{/* 设计风格筛选 */}
<div className="filter-section">
<h4 className="section-title"></h4>
{config.categories.map((category) => (
<div key={category} className="style-category">
<h5 className="style-category-title">{category}</h5>
<div className="filter-tags">
{COMMON_DESIGN_STYLES.map((style) => {
const isSelected = (config.design_styles[category] || []).includes(style);
return (
<button
key={style}
onClick={() => handleDesignStyleToggle(category, style)}
className={`filter-tag ${isSelected ? 'active' : ''}`}
>
{style}
</button>
);
})}
</div>
</div>
))}
{config.categories.length === 0 && (
<p className="no-categories-hint">
</p>
)}
</div>
<style>{`
.filter-panel {
background: #f9fafb;
border-radius: 8px;
padding: 16px;
margin-top: 16px;
border: 1px solid #e5e7eb;
}
.filter-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.filter-title {
font-size: 16px;
font-weight: 600;
color: #374151;
margin: 0;
}
.clear-filters-button {
padding: 6px 12px;
background: #ef4444;
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s;
}
.clear-filters-button:hover {
background: #dc2626;
}
.filter-section {
margin-bottom: 20px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #374151;
margin: 0 0 8px 0;
}
.filter-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter-tag {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 16px;
background: white;
color: #374151;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.filter-tag:hover {
border-color: #9ca3af;
}
.filter-tag.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.color-filters {
space-y: 12px;
}
.color-filter-item {
background: white;
border-radius: 8px;
padding: 12px;
border: 1px solid #e5e7eb;
margin-bottom: 12px;
}
.color-filter-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.color-filter-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.color-filter-checkbox {
width: 16px;
height: 16px;
}
.color-preview-container {
display: flex;
align-items: center;
gap: 8px;
}
.color-preview-small {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid #e5e7eb;
cursor: pointer;
}
.color-edit-button {
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
background: white;
font-size: 12px;
cursor: pointer;
}
.color-picker-container {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e5e7eb;
}
.threshold-controls {
margin-top: 16px;
display: grid;
gap: 12px;
}
.threshold-control {
display: flex;
align-items: center;
gap: 12px;
}
.threshold-control label {
font-size: 12px;
font-weight: 500;
min-width: 80px;
}
.threshold-slider {
flex: 1;
height: 4px;
background: #e5e7eb;
border-radius: 2px;
outline: none;
cursor: pointer;
}
.threshold-value {
font-size: 12px;
font-family: monospace;
min-width: 40px;
text-align: right;
}
.style-category {
margin-bottom: 12px;
}
.style-category-title {
font-size: 13px;
font-weight: 500;
color: #6b7280;
margin: 0 0 6px 0;
}
.no-categories-hint {
color: #6b7280;
font-size: 13px;
font-style: italic;
margin: 0;
text-align: center;
padding: 20px;
}
`}</style>
</div>
);
};
export default FilterPanel;

View File

@@ -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<ImageUploaderProps> = ({
onImageSelect,
onAnalysisComplete,
isAnalyzing,
selectedImage,
}) => {
const [dragOver, setDragOver] = useState(false);
const [error, setError] = useState<string | null>(null);
// const fileInputRef = useRef<HTMLInputElement>(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 (
<div className="card p-6 animate-fade-in">
<div className="flex items-center gap-3 mb-6">
<div className="icon-container purple w-8 h-8">
<ImageIcon className="w-4 h-4" />
</div>
<div>
<h3 className="text-heading-4 text-high-emphasis"></h3>
<p className="text-sm text-medium-emphasis">
AI分析
</p>
</div>
</div>
{!selectedImage ? (
<div
className={`relative border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-300 ${
dragOver
? 'border-primary-500 bg-primary-50 scale-105'
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleFileSelect}
>
<div className="space-y-4">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center transition-all duration-300 ${
dragOver ? 'bg-primary-100 text-primary-600' : 'bg-gray-100 text-gray-400'
}`}>
<Upload className="w-8 h-8" />
</div>
<div>
<p className="text-lg font-medium text-gray-900 mb-2">
</p>
<p className="text-sm text-gray-500">
{SUPPORTED_IMAGE_FORMATS.join(', ')}
</p>
</div>
</div>
{dragOver && (
<div className="absolute inset-0 bg-primary-500 bg-opacity-10 rounded-xl animate-pulse" />
)}
</div>
) : (
<div className="space-y-6 animate-slide-in-up">
<div className="relative group">
<div className="relative overflow-hidden rounded-xl bg-gray-100">
<img
src={`file://${selectedImage}`}
alt="Selected outfit"
className="w-full h-64 object-cover transition-transform duration-300 group-hover:scale-105"
onError={() => setError('图片加载失败')}
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-300" />
<button
onClick={handleClearImage}
className="absolute top-3 right-3 w-8 h-8 bg-white bg-opacity-90 hover:bg-opacity-100 rounded-full flex items-center justify-center text-gray-600 hover:text-red-500 transition-all duration-200 opacity-0 group-hover:opacity-100"
disabled={isAnalyzing}
title="移除图片"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-gray-600">
<ImageIcon className="w-4 h-4" />
<span className="font-medium">{selectedImage.split('/').pop()}</span>
</div>
<button
onClick={handleAnalyze}
disabled={isAnalyzing}
className="btn btn-primary w-full hover-lift"
>
{isAnalyzing ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
AI分析中...
</>
) : (
<>
<Sparkles className="w-4 h-4" />
AI分析
</>
)}
</button>
</div>
</div>
)}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-xl text-red-800 animate-shake">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
<span className="text-sm font-medium">{error}</span>
</div>
)}
</div>
);
};
export default ImageUploader;

View File

@@ -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<LLMChatProps> = ({
onAskLLM,
response,
isLoading,
error,
}) => {
const [input, setInput] = useState('');
const [chatHistory, setChatHistory] = useState<Array<{
type: 'user' | 'assistant';
content: string;
timestamp: Date;
relatedResults?: any[];
}>>([]);
const inputRef = useRef<HTMLTextAreaElement>(null);
const chatContainerRef = useRef<HTMLDivElement>(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 (
<div className="card p-4 sm:p-6 h-[500px] sm:h-[600px] flex flex-col animate-fade-in">
<div className="flex items-start justify-between mb-4 sm:mb-6 gap-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="icon-container primary w-8 h-8 sm:w-10 sm:h-10 flex-shrink-0">
<Bot className="w-4 h-4 sm:w-5 sm:h-5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-lg sm:text-xl font-bold text-high-emphasis truncate">AI搭配顾问</h3>
<p className="text-xs sm:text-sm text-medium-emphasis hidden sm:block">
AI顾问咨询搭配建议
</p>
</div>
</div>
{chatHistory.length > 0 && (
<button
onClick={handleClearChat}
className="btn btn-secondary btn-sm hover-lift flex-shrink-0"
title="清除对话历史"
>
<Trash2 className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline ml-2"></span>
</button>
)}
</div>
{/* 聊天历史 - 现代化设计 */}
<div ref={chatContainerRef} className="flex-1 overflow-y-auto space-y-4 mb-6 pr-2 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
{chatHistory.length === 0 && (
<div className="h-full flex items-center justify-center">
<div className="text-center space-y-4 max-w-md">
<div className="flex justify-center">
<div className="icon-container purple w-16 h-16">
<MessageCircle className="w-8 h-8" />
</div>
</div>
<div className="space-y-2">
<h4 className="text-heading-4 text-high-emphasis">AI搭配顾问</h4>
<p className="text-body text-medium-emphasis">
</p>
</div>
</div>
</div>
)}
{chatHistory.map((message, index) => (
<div key={index} className={`flex gap-2 sm:gap-3 animate-slide-in-up ${message.type === 'user' ? 'justify-end' : 'justify-start'}`}>
{message.type === 'assistant' && (
<div className="icon-container primary w-6 h-6 sm:w-8 sm:h-8 flex-shrink-0">
<Bot className="w-3 h-3 sm:w-4 sm:h-4" />
</div>
)}
<div className={`max-w-[85%] sm:max-w-[80%] ${message.type === 'user' ? 'order-1' : ''}`}>
<div className={`p-3 sm:p-4 rounded-2xl ${
message.type === 'user'
? 'bg-gradient-primary text-white ml-auto'
: 'bg-gray-100 text-gray-900'
}`}>
<div className="text-sm leading-relaxed whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
<div className={`text-xs text-gray-500 mt-1 px-1 ${message.type === 'user' ? 'text-right' : 'text-left'}`}>
{message.timestamp.toLocaleTimeString()}
</div>
{/* 相关搜索结果 - 响应式 */}
{message.relatedResults && message.relatedResults.length > 0 && (
<div className="mt-3 sm:mt-4 p-3 sm:p-4 bg-blue-50 rounded-xl border border-blue-100">
<h5 className="text-sm font-semibold text-blue-900 mb-3 flex items-center gap-2">
<Sparkles className="w-4 h-4" />
</h5>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{message.relatedResults.slice(0, 3).map((result, resultIndex) => (
<div key={resultIndex} className="transform hover:scale-105 transition-transform duration-200">
<OutfitCard
result={result}
showScore={false}
/>
</div>
))}
</div>
</div>
)}
</div>
{message.type === 'user' && (
<div className="icon-container secondary w-6 h-6 sm:w-8 sm:h-8 flex-shrink-0 order-2">
<User className="w-3 h-3 sm:w-4 sm:h-4" />
</div>
)}
</div>
))}
{/* 加载状态 - 打字动画 */}
{isLoading && (
<div className="flex gap-3 justify-start animate-slide-in-up">
<div className="icon-container primary w-8 h-8 flex-shrink-0">
<Bot className="w-4 h-4" />
</div>
<div className="max-w-[80%]">
<div className="p-4 rounded-2xl bg-gray-100">
<div className="flex items-center gap-2">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<span className="text-sm text-gray-600">AI正在思考中...</span>
</div>
</div>
</div>
</div>
)}
{/* 错误消息 */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-xl text-red-800 animate-shake">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
<span className="text-sm font-medium">{error}</span>
</div>
)}
</div>
{/* 预设问题 - 美化设计 */}
{chatHistory.length === 0 && (
<div className="mb-4 animate-fade-in">
<div className="text-sm font-medium text-gray-700 mb-3"></div>
<div className="flex flex-wrap gap-2">
{presetQuestions.map((question, index) => (
<button
key={index}
onClick={() => handlePresetClick(question)}
className="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-full transition-colors duration-200 hover-lift disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isLoading}
>
{question}
</button>
))}
</div>
</div>
)}
{/* 输入区域 - 现代化设计 */}
<div className="border-t border-gray-200 pt-4">
<div className="flex gap-3 items-end">
<div className="flex-1 relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="输入您的搭配问题..."
className="form-input resize-none min-h-[44px] max-h-32 pr-12"
disabled={isLoading}
rows={1}
/>
<div className="absolute right-3 bottom-3 text-gray-400">
<MessageCircle className="w-5 h-5" />
</div>
</div>
<button
onClick={handleSendMessage}
disabled={isLoading || input.trim().length === 0}
className="btn btn-primary px-4 py-3 hover-lift disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
<div className="text-xs text-gray-500 mt-2 text-center">
Enter Shift + Enter
</div>
</div>
</div>
);
};
export default LLMChat;

View File

@@ -0,0 +1,368 @@
import React, { useState, useCallback } from 'react';
import { OutfitCardProps } from '../../types/outfitSearch';
import { ColorUtils } from '../../utils/colorUtils';
import OutfitSearchService from '../../services/outfitSearchService';
/**
* 服装搭配卡片组件
* 遵循 Tauri 开发规范的组件设计原则
*/
export const OutfitCard: React.FC<OutfitCardProps> = ({
result,
onSelect,
showScore = false,
}) => {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
// 处理图片加载成功
const handleImageLoad = useCallback(() => {
setImageLoaded(true);
setImageError(false);
}, []);
// 处理图片加载失败
const handleImageError = useCallback(() => {
setImageLoaded(false);
setImageError(true);
}, []);
// 处理卡片点击
const handleCardClick = useCallback(() => {
if (onSelect) {
onSelect(result);
}
}, [result, onSelect]);
// 获取主要产品类别
const getMainCategories = useCallback(() => {
const categories = result.products.map(p => p.category);
const uniqueCategories = Array.from(new Set(categories));
return uniqueCategories.slice(0, 3); // 最多显示3个类别
}, [result.products]);
// 获取主要颜色
const getMainColors = useCallback(() => {
const colors = result.products.map(p => p.color_pattern);
return colors.slice(0, 4); // 最多显示4个颜色
}, [result.products]);
// 获取设计风格
const getDesignStyles = useCallback(() => {
const styles = result.products.flatMap(p => p.design_styles);
const uniqueStyles = Array.from(new Set(styles));
return uniqueStyles.slice(0, 3); // 最多显示3个风格
}, [result.products]);
const mainCategories = getMainCategories();
const mainColors = getMainColors();
const designStyles = getDesignStyles();
return (
<div
className={`outfit-card ${onSelect ? 'clickable' : ''}`}
onClick={handleCardClick}
>
{/* 图片区域 */}
<div className="card-image-container">
{!imageLoaded && !imageError && (
<div className="image-placeholder">
<div className="loading-spinner" />
</div>
)}
{imageError && (
<div className="image-placeholder error">
<svg className="error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21,15 16,10 5,21" />
</svg>
<span></span>
</div>
)}
<img
src={result.image_url}
alt={result.style_description}
className={`card-image ${imageLoaded ? 'loaded' : ''}`}
onLoad={handleImageLoad}
onError={handleImageError}
style={{ display: imageError ? 'none' : 'block' }}
/>
{/* 相关性评分 */}
{showScore && (
<div className="score-badge">
{OutfitSearchService.formatRelevanceScore(result.relevance_score)}
</div>
)}
</div>
{/* 内容区域 */}
<div className="card-content">
{/* 风格描述 */}
<h3 className="style-description">
{result.style_description || '时尚搭配'}
</h3>
{/* 类别标签 */}
{mainCategories.length > 0 && (
<div className="categories">
{mainCategories.map((category, index) => (
<span key={index} className="category-tag">
{category}
</span>
))}
</div>
)}
{/* 颜色调色板 */}
{mainColors.length > 0 && (
<div className="color-palette">
<span className="palette-label"></span>
<div className="color-swatches">
{mainColors.map((color, index) => (
<div
key={index}
className="color-swatch"
style={{ backgroundColor: ColorUtils.hsvToHex(color) }}
title={`颜色 ${index + 1}: ${ColorUtils.hsvToHex(color)}`}
/>
))}
</div>
</div>
)}
{/* 设计风格 */}
{designStyles.length > 0 && (
<div className="design-styles">
{designStyles.map((style, index) => (
<span key={index} className="style-tag">
{style}
</span>
))}
</div>
)}
{/* 环境标签 */}
{result.environment_tags.length > 0 && (
<div className="environment-tags">
<span className="env-label"></span>
<div className="env-tags">
{result.environment_tags.slice(0, 2).map((tag, index) => (
<span key={index} className="env-tag">
{tag}
</span>
))}
{result.environment_tags.length > 2 && (
<span className="env-tag more">
+{result.environment_tags.length - 2}
</span>
)}
</div>
</div>
)}
</div>
<style>{`
.outfit-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
transition: all 0.2s;
}
.outfit-card.clickable {
cursor: pointer;
}
.outfit-card.clickable:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.card-image-container {
position: relative;
width: 100%;
height: 200px;
background: #f9fafb;
overflow: hidden;
}
.image-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #9ca3af;
font-size: 14px;
}
.image-placeholder.error {
background: #fef2f2;
color: #dc2626;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #e5e7eb;
border-top: 3px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 8px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-icon {
width: 32px;
height: 32px;
margin-bottom: 8px;
}
.card-image {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.3s;
}
.card-image.loaded {
opacity: 1;
}
.score-badge {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.card-content {
padding: 16px;
}
.style-description {
font-size: 16px;
font-weight: 600;
color: #374151;
margin: 0 0 12px 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.categories {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.category-tag {
padding: 4px 8px;
background: #eff6ff;
color: #1d4ed8;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.color-palette {
margin-bottom: 12px;
}
.palette-label {
font-size: 12px;
font-weight: 500;
color: #6b7280;
display: block;
margin-bottom: 6px;
}
.color-swatches {
display: flex;
gap: 4px;
}
.color-swatch {
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid #e5e7eb;
cursor: pointer;
}
.design-styles {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.style-tag {
padding: 4px 8px;
background: #f3f4f6;
color: #374151;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.environment-tags {
display: flex;
align-items: center;
gap: 8px;
}
.env-label {
font-size: 12px;
font-weight: 500;
color: #6b7280;
}
.env-tags {
display: flex;
gap: 4px;
}
.env-tag {
padding: 2px 6px;
background: #ecfdf5;
color: #065f46;
border-radius: 8px;
font-size: 11px;
font-weight: 500;
}
.env-tag.more {
background: #f3f4f6;
color: #6b7280;
}
`}</style>
</div>
);
};
export default OutfitCard;

View File

@@ -0,0 +1,220 @@
import React, { useState, useCallback } from 'react';
import {
OutfitSearchPanelProps,
SearchRequest,
RELEVANCE_THRESHOLD_OPTIONS
} from '../../types/outfitSearch';
import { FilterPanel } from './FilterPanel';
import OutfitSearchService from '../../services/outfitSearchService';
import { Search, Filter, ChevronDown, Sparkles } from 'lucide-react';
/**
* 服装搜索面板组件
* 遵循 Tauri 开发规范的组件设计原则
*/
export const OutfitSearchPanel: React.FC<OutfitSearchPanelProps> = ({
config,
onConfigChange,
onSearch,
isLoading,
}) => {
const [query, setQuery] = useState('model');
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
// 获取搜索建议
const fetchSuggestions = useCallback(async (searchQuery: string) => {
if (searchQuery.trim().length < 2) {
setSuggestions([]);
return;
}
try {
const newSuggestions = await OutfitSearchService.getSearchSuggestions(searchQuery);
setSuggestions(newSuggestions);
} catch (error) {
console.error('Failed to fetch suggestions:', error);
setSuggestions([]);
}
}, []);
// 处理查询输入变化
const handleQueryChange = useCallback((value: string) => {
setQuery(value);
fetchSuggestions(value);
}, [fetchSuggestions]);
// 处理搜索执行
const handleSearch = useCallback(() => {
if (query.trim().length === 0) return;
const request: SearchRequest = {
query: query.trim(),
config,
page_size: 9,
page_offset: 0,
};
onSearch(request);
setShowSuggestions(false);
}, [query, config, onSearch]);
// 处理建议选择
const handleSuggestionSelect = useCallback((suggestion: string) => {
setQuery(suggestion);
setShowSuggestions(false);
// 自动执行搜索
const request: SearchRequest = {
query: suggestion,
config,
page_size: 9,
page_offset: 0,
};
onSearch(request);
}, [config, onSearch]);
// 处理相关性阈值变化
const handleThresholdChange = useCallback((threshold: string) => {
onConfigChange({
...config,
relevance_threshold: threshold as any,
});
}, [config, onConfigChange]);
// 切换高级筛选
const toggleAdvancedFilters = useCallback(() => {
setShowAdvancedFilters(!showAdvancedFilters);
}, [showAdvancedFilters]);
return (
<div className="space-y-6">
{/* 搜索输入区域 - 现代化设计 */}
<div className="card p-6 animate-fade-in">
<div className="flex items-center gap-3 mb-4">
<div className="icon-container primary w-8 h-8">
<Search className="w-4 h-4" />
</div>
<h3 className="text-heading-4 text-high-emphasis"></h3>
</div>
<div className="relative">
<div className="flex gap-3">
<div className="flex-1 relative">
<input
type="text"
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
placeholder="输入搜索关键词,如:休闲搭配、牛仔裤、正式风格..."
className="form-input pr-12"
disabled={isLoading}
/>
<div className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400">
<Sparkles className="w-5 h-5" />
</div>
</div>
<button
onClick={handleSearch}
disabled={isLoading || query.trim().length === 0}
className="btn btn-primary px-6 hover-lift"
>
{isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
</>
) : (
<>
<Search className="w-4 h-4" />
</>
)}
</button>
</div>
{/* 搜索建议下拉框 - 美化设计 */}
{showSuggestions && suggestions.length > 0 && (
<div className="absolute top-full left-0 right-16 mt-2 bg-white border border-gray-200 rounded-xl shadow-xl z-10 max-h-48 overflow-y-auto animate-fade-in">
{suggestions.map((suggestion, index) => (
<button
key={index}
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center gap-3 transition-colors duration-150 first:rounded-t-xl last:rounded-b-xl"
onClick={() => handleSuggestionSelect(suggestion)}
>
<Search className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-700">{suggestion}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* 快速设置 - 现代化设计 */}
<div className="card p-6 animate-slide-in-up">
<div className="flex items-center gap-3 mb-4">
<div className="icon-container warning w-8 h-8">
<Filter className="w-4 h-4" />
</div>
<h3 className="text-heading-4 text-high-emphasis"></h3>
</div>
<div className="space-y-4">
<div className="form-group">
<label className="form-label"></label>
<div className="relative">
<select
value={config.relevance_threshold}
onChange={(e) => handleThresholdChange(e.target.value)}
className="form-input appearance-none pr-10"
disabled={isLoading}
>
{RELEVANCE_THRESHOLD_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none" />
</div>
</div>
<button
onClick={toggleAdvancedFilters}
className={`btn w-full justify-between ${showAdvancedFilters ? 'btn-primary' : 'btn-secondary'} hover-lift`}
disabled={isLoading}
>
<div className="flex items-center gap-2">
<Filter className="w-4 h-4" />
</div>
<ChevronDown
className={`w-4 h-4 transition-transform duration-200 ${showAdvancedFilters ? 'rotate-180' : ''}`}
/>
</button>
</div>
</div>
{/* 高级筛选面板 - 动画展开 */}
{showAdvancedFilters && (
<div className="animate-slide-in-up">
<FilterPanel
config={config}
onConfigChange={onConfigChange}
showAdvanced={showAdvancedFilters}
onToggleAdvanced={toggleAdvancedFilters}
/>
</div>
)}
</div>
);
};
export default OutfitSearchPanel;

View File

@@ -0,0 +1,174 @@
import React, { useCallback } from 'react';
import { SearchResultsProps } from '../../types/outfitSearch';
import { OutfitCard } from './OutfitCard';
import OutfitSearchService from '../../services/outfitSearchService';
import { Search, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
/**
* 搜索结果展示组件
* 遵循 Tauri 开发规范的组件设计原则
*/
export const SearchResults: React.FC<SearchResultsProps> = ({
results,
totalSize,
currentPage,
pageSize,
onPageChange,
onItemSelect,
isLoading,
}) => {
// 计算分页信息
const totalPages = OutfitSearchService.getTotalPages(totalSize, pageSize);
const hasNextPage = OutfitSearchService.hasMoreResults(
{ results, total_size: totalSize } as any,
currentPage,
pageSize
);
const hasPreviousPage = currentPage > 1;
// 处理页面变化
const handlePageChange = useCallback((page: number) => {
if (page >= 1 && page <= totalPages) {
onPageChange(page);
}
}, [totalPages, onPageChange]);
// 生成页码数组
const getPageNumbers = useCallback(() => {
const pages: number[] = [];
const maxVisiblePages = 5;
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
const start = Math.max(1, currentPage - 2);
const end = Math.min(totalPages, start + maxVisiblePages - 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
}
return pages;
}, [currentPage, totalPages]);
if (isLoading) {
return (
<div className="card h-96 flex items-center justify-center animate-fade-in">
<div className="text-center space-y-4">
<div className="flex justify-center">
<Loader2 className="w-12 h-12 text-primary-500 animate-spin" />
</div>
<p className="text-lg font-medium text-gray-600">...</p>
<p className="text-sm text-gray-400">AI正在为您寻找最佳搭配</p>
</div>
</div>
);
}
if (results.length === 0) {
return (
<div className="card h-96 flex items-center justify-center animate-fade-in">
<div className="text-center space-y-6 max-w-md">
<div className="flex justify-center">
<div className="icon-container gray w-20 h-20">
<Search className="w-10 h-10" />
</div>
</div>
<div className="space-y-3">
<h3 className="text-heading-3 text-high-emphasis"></h3>
<p className="text-body text-medium-emphasis">
AI分析
</p>
</div>
<div className="flex flex-wrap gap-2 justify-center">
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-sm"></span>
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-sm"></span>
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-sm"></span>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* 结果统计 - 现代化设计 */}
<div className="card p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-sm font-medium text-gray-900">
<span className="text-primary-600">{totalSize}</span>
</span>
</div>
<div className="h-4 w-px bg-gray-300" />
<span className="text-sm text-gray-500">
{currentPage} {totalPages}
</span>
</div>
</div>
</div>
{/* 结果网格 - 响应式布局 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{results.map((result, index) => (
<div key={result.id || index} className="animate-slide-in-up" style={{ animationDelay: `${index * 100}ms` }}>
<OutfitCard
result={result}
onSelect={onItemSelect}
showScore={true}
/>
</div>
))}
</div>
{/* 分页控件 - 现代化设计 */}
{totalPages > 1 && (
<div className="card p-4">
<div className="flex items-center justify-center gap-2">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={!hasPreviousPage}
className="btn btn-secondary btn-sm hover-lift disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<div className="flex items-center gap-1 mx-4">
{getPageNumbers().map((page) => (
<button
key={page}
onClick={() => handlePageChange(page)}
className={`w-10 h-10 rounded-lg font-medium text-sm transition-all duration-200 hover-lift ${
page === currentPage
? 'bg-gradient-primary text-white shadow-lg shadow-primary-500/25'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
{page}
</button>
))}
</div>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={!hasNextPage}
className="btn btn-secondary btn-sm hover-lift disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
);
};
export default SearchResults;

View File

@@ -0,0 +1,301 @@
import React, { useEffect, useCallback, useState } from 'react';
import { useOutfitSearchStore, useOutfitSearchActions, useOutfitSearchSelectors } from '../store/outfitSearchStore';
import { OutfitSearchPanel } from '../components/outfit/OutfitSearchPanel';
import { SearchResults } from '../components/outfit/SearchResults';
import { ImageUploader } from '../components/outfit/ImageUploader';
import { LLMChat } from '../components/outfit/LLMChat';
import { SearchRequest } from '../types/outfitSearch';
import { Search, Image, MessageCircle, RotateCcw, Sparkles } from 'lucide-react';
/**
* 服装搭配主页面
* 遵循 Tauri 开发规范的页面组件设计原则
*/
export const OutfitMatch: React.FC = () => {
const [activeTab, setActiveTab] = useState<'search' | 'analyze' | 'chat'>('search');
// 状态管理
const {
searchConfig,
searchResults,
updateSearchConfig,
executeSearch,
askLLM,
setSelectedImage,
clearErrors,
} = useOutfitSearchStore();
// 辅助函数
const { searchFromAnalysis, resetAll } = useOutfitSearchActions();
// 选择器
const { useSearchState, useAnalysisState, useLLMState } = useOutfitSearchSelectors();
// 获取各种状态
const searchState = useSearchState();
const analysisState = useAnalysisState();
const llmState = useLLMState();
// 页面初始化
useEffect(() => {
// 清除之前的错误
clearErrors();
// 可以在这里加载默认搜索结果
// quickSearch('model');
}, [clearErrors]);
// 处理搜索
const handleSearch = useCallback((request: SearchRequest) => {
executeSearch(request);
}, [executeSearch]);
// 处理图像选择
const handleImageSelect = useCallback((imagePath: string | null) => {
setSelectedImage(imagePath);
}, [setSelectedImage]);
// 处理图像分析完成
const handleAnalysisComplete = useCallback((_result: any) => {
// 分析完成后自动切换到搜索标签并执行搜索
setActiveTab('search');
searchFromAnalysis();
}, [searchFromAnalysis]);
// 处理LLM问答
const handleLLMQuery = useCallback((request: any) => {
askLLM(request);
}, [askLLM]);
// 处理页面变化
const handlePageChange = useCallback((page: number) => {
const lastQuery = searchResults.length > 0 ? 'model' : 'model'; // 这里应该从历史记录获取
const request: SearchRequest = {
query: lastQuery,
config: searchConfig,
page_size: 9,
page_offset: (page - 1) * 9,
};
executeSearch(request);
}, [searchConfig, executeSearch, searchResults.length]);
// 处理搜索结果项选择
const handleResultSelect = useCallback((result: any) => {
console.log('Selected result:', result);
// 这里可以实现详情查看或其他操作
}, []);
// 处理重置
const handleReset = useCallback(() => {
resetAll();
setActiveTab('search');
}, [resetAll]);
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-blue-50/30 to-gray-50">
<div className="content-container">
{/* 页面头部 - 使用设计系统样式 */}
<div className="page-header mb-8 animate-fade-in">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<div className="icon-container primary w-12 h-12">
<Sparkles className="w-6 h-6" />
</div>
<h1 className="text-heading-1 text-gradient-primary font-bold">
</h1>
</div>
<p className="text-body text-medium-emphasis max-w-2xl">
AI分析AI为您推荐最佳搭配
</p>
</div>
</div>
{/* 标签导航 - 现代化设计 + 响应式 */}
<div className="card mb-8 p-2 animate-slide-in-up">
<div className="flex gap-2 flex-col sm:flex-row">
<button
onClick={() => setActiveTab('search')}
className={`flex-1 flex items-center justify-center gap-3 px-4 sm:px-6 py-3 sm:py-4 rounded-xl font-medium transition-all duration-300 relative overflow-hidden ${
activeTab === 'search'
? 'bg-gradient-primary text-white shadow-lg shadow-primary-500/25'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<Search className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="text-sm sm:text-base"></span>
{activeTab === 'search' && (
<div className="absolute inset-0 bg-white opacity-10 animate-pulse" />
)}
</button>
<button
onClick={() => setActiveTab('analyze')}
className={`flex-1 flex items-center justify-center gap-3 px-4 sm:px-6 py-3 sm:py-4 rounded-xl font-medium transition-all duration-300 relative overflow-hidden ${
activeTab === 'analyze'
? 'bg-gradient-primary text-white shadow-lg shadow-primary-500/25'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<Image className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="text-sm sm:text-base"></span>
{analysisState.isAnalyzing && (
<div className="w-2 h-2 bg-current rounded-full animate-pulse ml-2" />
)}
{activeTab === 'analyze' && (
<div className="absolute inset-0 bg-white opacity-10 animate-pulse" />
)}
</button>
<button
onClick={() => setActiveTab('chat')}
className={`flex-1 flex items-center justify-center gap-3 px-4 sm:px-6 py-3 sm:py-4 rounded-xl font-medium transition-all duration-300 relative overflow-hidden ${
activeTab === 'chat'
? 'bg-gradient-primary text-white shadow-lg shadow-primary-500/25'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<MessageCircle className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="text-sm sm:text-base">AI顾问</span>
{llmState.isAsking && (
<div className="w-2 h-2 bg-current rounded-full animate-pulse ml-2" />
)}
{activeTab === 'chat' && (
<div className="absolute inset-0 bg-white opacity-10 animate-pulse" />
)}
</button>
</div>
</div>
{/* 主要内容区域 - 响应式布局 */}
<div className="min-h-[600px]">
{activeTab === 'search' && (
<div className="grid grid-cols-1 lg:grid-cols-[400px_1fr] gap-6 lg:gap-8 animate-fade-in">
<div className="space-y-6 order-2 lg:order-1">
<OutfitSearchPanel
config={searchConfig}
onConfigChange={updateSearchConfig}
onSearch={handleSearch}
isLoading={searchState.isSearching}
/>
</div>
<div className="min-h-[400px] sm:min-h-[600px] order-1 lg:order-2">
<SearchResults
results={searchState.results}
totalSize={searchState.results.length}
currentPage={searchState.currentPage}
pageSize={9}
onPageChange={handlePageChange}
onItemSelect={handleResultSelect}
isLoading={searchState.isSearching}
/>
</div>
</div>
)}
{activeTab === 'analyze' && (
<div className="grid grid-cols-1 lg:grid-cols-[400px_1fr] gap-6 lg:gap-8 animate-fade-in">
<div className="space-y-6 order-2 lg:order-1">
<ImageUploader
onImageSelect={handleImageSelect}
onAnalysisComplete={handleAnalysisComplete}
isAnalyzing={analysisState.isAnalyzing}
selectedImage={analysisState.selectedImage}
/>
{/* 分析结果展示 - 美化设计 + 响应式 */}
{analysisState.result && (
<div className="card p-4 sm:p-6 animate-slide-in-up">
<div className="flex items-center gap-3 mb-4">
<div className="icon-container success w-6 h-6 sm:w-8 sm:h-8">
<Sparkles className="w-3 h-3 sm:w-4 sm:h-4" />
</div>
<h3 className="text-lg sm:text-xl font-semibold text-high-emphasis"></h3>
</div>
<div className="space-y-4">
<div className="p-3 sm:p-4 bg-blue-50 rounded-xl border border-blue-100">
<h4 className="text-sm font-semibold text-blue-900 mb-2"></h4>
<p className="text-sm text-blue-800">{analysisState.result.style_description}</p>
</div>
<div className="p-3 sm:p-4 bg-green-50 rounded-xl border border-green-100">
<h4 className="text-sm font-semibold text-green-900 mb-3"></h4>
<div className="space-y-2">
{analysisState.result.products.map((product, index) => (
<div key={index} className="flex items-center gap-2 text-sm text-green-800">
<div className="w-2 h-2 bg-green-500 rounded-full flex-shrink-0" />
<span className="font-medium">{product.category}</span>
<span className="text-green-600">-</span>
<span className="break-words">{product.description}</span>
</div>
))}
</div>
</div>
<button
onClick={searchFromAnalysis}
className="btn btn-primary w-full hover-lift text-sm sm:text-base"
disabled={searchState.isSearching}
>
{searchState.isSearching ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
...
</>
) : (
<>
<Search className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
)}
</div>
<div className="min-h-[400px] sm:min-h-[600px] order-1 lg:order-2">
{searchState.results.length > 0 ? (
<SearchResults
results={searchState.results}
totalSize={searchState.results.length}
currentPage={searchState.currentPage}
pageSize={9}
onPageChange={handlePageChange}
onItemSelect={handleResultSelect}
isLoading={searchState.isSearching}
/>
) : (
<div className="card h-full flex flex-col items-center justify-center text-center p-6 sm:p-12 animate-fade-in">
<div className="icon-container purple w-12 h-12 sm:w-16 sm:h-16 mb-4 sm:mb-6">
<Image className="w-6 h-6 sm:w-8 sm:h-8" />
</div>
<h3 className="text-xl sm:text-2xl font-bold text-high-emphasis mb-2 sm:mb-3"></h3>
<p className="text-sm sm:text-base text-medium-emphasis max-w-md">
AI将自动分析并推荐相似的搭配方案
</p>
</div>
)}
</div>
</div>
)}
{activeTab === 'chat' && (
<div className="max-w-4xl mx-auto animate-fade-in">
<LLMChat
onAskLLM={handleLLMQuery}
response={llmState.response}
isLoading={llmState.isAsking}
error={llmState.llmError}
/>
</div>
)}
</div>
</div>
</div>
);
};
export default OutfitMatch;

View File

@@ -0,0 +1,288 @@
import { invoke } from '@tauri-apps/api/core';
import {
AnalyzeImageRequest,
AnalyzeImageResponse,
SearchRequest,
SearchResponse,
LLMQueryRequest,
LLMQueryResponse,
SearchConfig,
OutfitSearchConfigInfo,
} from '../types/outfitSearch';
/**
* 服装搭配搜索API服务
* 遵循 Tauri 开发规范的API服务设计原则
*/
export class OutfitSearchService {
/**
* 分析服装图像
*/
static async analyzeOutfitImage(request: AnalyzeImageRequest): Promise<AnalyzeImageResponse> {
try {
const response = await invoke<AnalyzeImageResponse>('analyze_outfit_image', { request });
return response;
} catch (error) {
console.error('Failed to analyze outfit image:', error);
throw new Error(`图像分析失败: ${error}`);
}
}
/**
* 搜索相似服装
*/
static async searchSimilarOutfits(request: SearchRequest): Promise<SearchResponse> {
try {
const response = await invoke<SearchResponse>('search_similar_outfits', { request });
return response;
} catch (error) {
console.error('Failed to search similar outfits:', error);
throw new Error(`搜索失败: ${error}`);
}
}
/**
* LLM问答
*/
static async askLLMOutfitAdvice(request: LLMQueryRequest): Promise<LLMQueryResponse> {
try {
const response = await invoke<LLMQueryResponse>('ask_llm_outfit_advice', { request });
return response;
} catch (error) {
console.error('Failed to get LLM outfit advice:', error);
throw new Error(`LLM问答失败: ${error}`);
}
}
/**
* 获取搜索建议
*/
static async getSearchSuggestions(query: string): Promise<string[]> {
try {
const suggestions = await invoke<string[]>('get_outfit_search_suggestions', { query });
return suggestions;
} catch (error) {
console.error('Failed to get search suggestions:', error);
return [];
}
}
/**
* 基于分析结果生成搜索配置
*/
static async generateSearchConfigFromAnalysis(
analysisResult: any
): Promise<SearchConfig> {
try {
const config = await invoke<SearchConfig>('generate_search_config_from_analysis', {
analysisResult,
});
return config;
} catch (error) {
console.error('Failed to generate search config from analysis:', error);
throw new Error(`生成搜索配置失败: ${error}`);
}
}
/**
* 验证图像文件
*/
static async validateOutfitImage(imagePath: string): Promise<boolean> {
try {
const isValid = await invoke<boolean>('validate_outfit_image', { imagePath });
return isValid;
} catch (error) {
console.error('Failed to validate outfit image:', error);
return false;
}
}
/**
* 获取支持的图像格式
*/
static async getSupportedImageFormats(): Promise<string[]> {
try {
const formats = await invoke<string[]>('get_supported_image_formats');
return formats;
} catch (error) {
console.error('Failed to get supported image formats:', error);
return ['jpg', 'jpeg', 'png', 'webp'];
}
}
/**
* 获取默认搜索配置
*/
static async getDefaultSearchConfig(): Promise<SearchConfig> {
try {
const config = await invoke<SearchConfig>('get_default_search_config');
return config;
} catch (error) {
console.error('Failed to get default search config:', error);
throw new Error(`获取默认配置失败: ${error}`);
}
}
/**
* 获取全局配置信息
*/
static async getOutfitSearchConfig(): Promise<OutfitSearchConfigInfo> {
try {
const config = await invoke<OutfitSearchConfigInfo>('get_outfit_search_config');
return config;
} catch (error) {
console.error('Failed to get outfit search config:', error);
throw new Error(`获取配置信息失败: ${error}`);
}
}
/**
* 执行快速搜索(使用默认配置)
*/
static async quickSearch(query: string, pageSize: number = 9): Promise<SearchResponse> {
try {
const defaultConfig = await this.getDefaultSearchConfig();
const request: SearchRequest = {
query,
config: defaultConfig,
page_size: pageSize,
page_offset: 0,
};
return await this.searchSimilarOutfits(request);
} catch (error) {
console.error('Failed to perform quick search:', error);
throw new Error(`快速搜索失败: ${error}`);
}
}
/**
* 执行分页搜索
*/
static async searchWithPagination(
request: SearchRequest,
page: number,
pageSize: number = 9
): Promise<SearchResponse> {
try {
const paginatedRequest: SearchRequest = {
...request,
page_size: pageSize,
page_offset: (page - 1) * pageSize,
};
return await this.searchSimilarOutfits(paginatedRequest);
} catch (error) {
console.error('Failed to perform paginated search:', error);
throw new Error(`分页搜索失败: ${error}`);
}
}
/**
* 批量验证图像文件
*/
static async validateMultipleImages(imagePaths: string[]): Promise<boolean[]> {
try {
const validationPromises = imagePaths.map(path => this.validateOutfitImage(path));
const results = await Promise.all(validationPromises);
return results;
} catch (error) {
console.error('Failed to validate multiple images:', error);
return imagePaths.map(() => false);
}
}
/**
* 获取搜索统计信息
*/
static async getSearchStats(response: SearchResponse): Promise<{
totalResults: number;
searchTime: number;
averageScore: number;
topCategories: string[];
}> {
const totalResults = response.total_size;
const searchTime = response.search_time_ms;
// 计算平均评分
const averageScore = response.results.length > 0
? response.results.reduce((sum, result) => sum + result.relevance_score, 0) / response.results.length
: 0;
// 统计热门类别
const categoryCount: Record<string, number> = {};
response.results.forEach(result => {
result.products.forEach(product => {
categoryCount[product.category] = (categoryCount[product.category] || 0) + 1;
});
});
const topCategories = Object.entries(categoryCount)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([category]) => category);
return {
totalResults,
searchTime,
averageScore,
topCategories,
};
}
/**
* 格式化搜索时间
*/
static formatSearchTime(timeMs: number): string {
if (timeMs < 1000) {
return `${timeMs}ms`;
} else if (timeMs < 60000) {
return `${(timeMs / 1000).toFixed(1)}s`;
} else {
return `${Math.floor(timeMs / 60000)}m ${Math.floor((timeMs % 60000) / 1000)}s`;
}
}
/**
* 格式化相关性评分
*/
static formatRelevanceScore(score: number): string {
return `${(score * 100).toFixed(1)}%`;
}
/**
* 检查是否有更多结果
*/
static hasMoreResults(response: SearchResponse, currentPage: number, pageSize: number): boolean {
const currentOffset = (currentPage - 1) * pageSize;
return currentOffset + response.results.length < response.total_size;
}
/**
* 计算总页数
*/
static getTotalPages(totalSize: number, pageSize: number): number {
return Math.ceil(totalSize / pageSize);
}
/**
* 生成搜索摘要
*/
static generateSearchSummary(response: SearchResponse, query: string): string {
const { total_size, search_time_ms, results } = response;
const timeStr = this.formatSearchTime(search_time_ms);
if (total_size === 0) {
return `未找到与"${query}"相关的结果`;
}
const avgScore = results.length > 0
? results.reduce((sum, r) => sum + r.relevance_score, 0) / results.length
: 0;
return `找到 ${total_size} 个相关结果,用时 ${timeStr},平均相关度 ${this.formatRelevanceScore(avgScore)}`;
}
}
/**
* 导出默认实例
*/
export default OutfitSearchService;

View File

@@ -0,0 +1,326 @@
import { create } from 'zustand';
import {
OutfitSearchState,
SearchRequest,
DEFAULT_SEARCH_CONFIG,
} from '../types/outfitSearch';
import OutfitSearchService from '../services/outfitSearchService';
/**
* 服装搭配搜索状态管理
* 遵循 Tauri 开发规范的状态管理模式
*/
export const useOutfitSearchStore = create<OutfitSearchState>((set, _get) => ({
// 搜索状态
searchConfig: DEFAULT_SEARCH_CONFIG,
searchResults: [],
isSearching: false,
searchError: null,
// 分析状态
analysisResult: null,
isAnalyzing: false,
analysisError: null,
// LLM问答状态
llmResponse: null,
isAsking: false,
llmError: null,
// UI状态
selectedImage: null,
showAdvancedFilters: false,
currentPage: 1,
// 历史记录
searchHistory: [],
// 操作方法
updateSearchConfig: (config) => {
set((state) => ({
searchConfig: { ...state.searchConfig, ...config },
}));
},
executeSearch: async (request) => {
set({ isSearching: true, searchError: null });
try {
const response = await OutfitSearchService.searchSimilarOutfits(request);
set({
searchResults: response.results,
isSearching: false,
currentPage: Math.floor(request.page_offset / request.page_size) + 1,
});
// 添加到搜索历史
const historyItem = {
id: Date.now().toString(),
query: request.query,
config: request.config,
results_count: response.total_size,
search_time_ms: response.search_time_ms,
created_at: new Date().toISOString(),
};
set((state) => ({
searchHistory: [historyItem, ...state.searchHistory.slice(0, 9)], // 保留最近10条
}));
} catch (error) {
console.error('Search failed:', error);
set({
isSearching: false,
searchError: error instanceof Error ? error.message : '搜索失败',
searchResults: [],
});
}
},
analyzeImage: async (request) => {
set({ isAnalyzing: true, analysisError: null });
try {
const response = await OutfitSearchService.analyzeOutfitImage(request);
set({
analysisResult: response.result,
isAnalyzing: false,
});
// 基于分析结果生成搜索配置
try {
const generatedConfig = await OutfitSearchService.generateSearchConfigFromAnalysis(
response.result
);
set((state) => ({
searchConfig: { ...state.searchConfig, ...generatedConfig },
}));
} catch (configError) {
console.warn('Failed to generate search config from analysis:', configError);
}
} catch (error) {
console.error('Image analysis failed:', error);
set({
isAnalyzing: false,
analysisError: error instanceof Error ? error.message : '图像分析失败',
analysisResult: null,
});
}
},
askLLM: async (request) => {
set({ isAsking: true, llmError: null });
try {
const response = await OutfitSearchService.askLLMOutfitAdvice(request);
set({
llmResponse: response,
isAsking: false,
});
} catch (error) {
console.error('LLM query failed:', error);
set({
isAsking: false,
llmError: error instanceof Error ? error.message : 'LLM问答失败',
llmResponse: null,
});
}
},
clearResults: () => {
set({
searchResults: [],
searchError: null,
currentPage: 1,
});
},
clearErrors: () => {
set({
searchError: null,
analysisError: null,
llmError: null,
});
},
setSelectedImage: (imagePath) => {
set({ selectedImage: imagePath });
// 如果清除了图像,也清除分析结果
if (!imagePath) {
set({ analysisResult: null, analysisError: null });
}
},
toggleAdvancedFilters: () => {
set((state) => ({
showAdvancedFilters: !state.showAdvancedFilters,
}));
},
loadSearchHistory: async () => {
// 这里可以从本地存储或数据库加载搜索历史
// 暂时使用空实现
try {
// const history = await loadSearchHistoryFromStorage();
// set({ searchHistory: history });
} catch (error) {
console.error('Failed to load search history:', error);
}
},
}));
/**
* 搜索相关的辅助函数
*/
export const useOutfitSearchActions = () => {
const store = useOutfitSearchStore();
return {
// 快速搜索
quickSearch: async (query: string) => {
const request: SearchRequest = {
query,
config: store.searchConfig,
page_size: 9,
page_offset: 0,
};
await store.executeSearch(request);
},
// 分页搜索
searchWithPagination: async (page: number, pageSize: number = 9) => {
const request: SearchRequest = {
query: store.searchHistory[0]?.query || 'model',
config: store.searchConfig,
page_size: pageSize,
page_offset: (page - 1) * pageSize,
};
await store.executeSearch(request);
},
// 基于分析结果搜索
searchFromAnalysis: async () => {
if (!store.analysisResult) return;
try {
const generatedConfig = await OutfitSearchService.generateSearchConfigFromAnalysis(
store.analysisResult
);
const request: SearchRequest = {
query: store.analysisResult.style_description || 'model',
config: generatedConfig,
page_size: 9,
page_offset: 0,
};
await store.executeSearch(request);
} catch (error) {
console.error('Failed to search from analysis:', error);
}
},
// 重置所有状态
resetAll: () => {
store.clearResults();
store.clearErrors();
store.setSelectedImage(null);
store.updateSearchConfig(DEFAULT_SEARCH_CONFIG);
useOutfitSearchStore.setState({
analysisResult: null,
llmResponse: null,
showAdvancedFilters: false,
currentPage: 1,
});
},
// 导出搜索配置
exportSearchConfig: () => {
return JSON.stringify(store.searchConfig, null, 2);
},
// 导入搜索配置
importSearchConfig: (configJson: string) => {
try {
const config = JSON.parse(configJson);
store.updateSearchConfig(config);
return true;
} catch (error) {
console.error('Failed to import search config:', error);
return false;
}
},
// 获取搜索统计
getSearchStats: () => {
const { searchHistory, searchResults } = store;
return {
totalSearches: searchHistory.length,
averageResultsCount: searchHistory.length > 0
? searchHistory.reduce((sum, item) => sum + item.results_count, 0) / searchHistory.length
: 0,
averageSearchTime: searchHistory.length > 0
? searchHistory.reduce((sum, item) => sum + item.search_time_ms, 0) / searchHistory.length
: 0,
currentResultsCount: searchResults.length,
};
},
};
};
/**
* 选择器函数
*/
export const useOutfitSearchSelectors = () => {
return {
// 获取当前搜索状态
useSearchState: () => useOutfitSearchStore((state) => ({
isSearching: state.isSearching,
searchError: state.searchError,
results: state.searchResults,
currentPage: state.currentPage,
})),
// 获取分析状态
useAnalysisState: () => useOutfitSearchStore((state) => ({
isAnalyzing: state.isAnalyzing,
analysisError: state.analysisError,
result: state.analysisResult,
selectedImage: state.selectedImage,
})),
// 获取LLM状态
useLLMState: () => useOutfitSearchStore((state) => ({
isAsking: state.isAsking,
llmError: state.llmError,
response: state.llmResponse,
})),
// 获取UI状态
useUIState: () => useOutfitSearchStore((state) => ({
showAdvancedFilters: state.showAdvancedFilters,
searchConfig: state.searchConfig,
})),
// 检查是否有活动筛选
useHasActiveFilters: () => useOutfitSearchStore((state) => {
const { searchConfig } = state;
return (
searchConfig.categories.length > 0 ||
searchConfig.environments.length > 0 ||
Object.values(searchConfig.color_filters).some(f => f.enabled) ||
Object.values(searchConfig.design_styles).some(styles => styles.length > 0)
);
}),
};
};
export default useOutfitSearchStore;

View File

@@ -39,6 +39,53 @@
--error-500: #ef4444;
--error-600: #dc2626;
/* 兼容性变量 - 支持旧的命名格式 */
--color-primary-50: var(--primary-50);
--color-primary-100: var(--primary-100);
--color-primary-200: var(--primary-200);
--color-primary-300: var(--primary-300);
--color-primary-400: var(--primary-400);
--color-primary-500: var(--primary-500);
--color-primary-600: var(--primary-600);
--color-primary-700: var(--primary-700);
--color-primary-800: var(--primary-800);
--color-primary-900: var(--primary-900);
--color-gray-50: var(--gray-50);
--color-gray-100: var(--gray-100);
--color-gray-200: var(--gray-200);
--color-gray-300: var(--gray-300);
--color-gray-400: var(--gray-400);
--color-gray-500: var(--gray-500);
--color-gray-600: var(--gray-600);
--color-gray-700: var(--gray-700);
--color-gray-800: var(--gray-800);
--color-gray-900: var(--gray-900);
--color-green-50: var(--success-50);
--color-green-100: #d1fae5;
--color-green-500: var(--success-500);
--color-green-600: var(--success-600);
--color-green-800: #065f46;
--color-yellow-50: var(--warning-50);
--color-yellow-100: #fef3c7;
--color-yellow-500: var(--warning-500);
--color-yellow-600: var(--warning-600);
--color-yellow-800: #92400e;
--color-red-50: var(--error-50);
--color-red-100: #fee2e2;
--color-red-500: var(--error-500);
--color-red-600: var(--error-600);
--color-red-800: #991b1b;
--color-blue-50: var(--primary-50);
--color-blue-100: var(--primary-100);
--color-blue-500: var(--primary-500);
--color-blue-600: var(--primary-600);
--color-blue-800: var(--primary-800);
/* 阴影系统 */
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
@@ -264,12 +311,13 @@
/* 页面头部样式 */
.page-header {
background: linear-gradient(135deg, white 0%, rgba(59, 130, 246, 0.05) 50%, white 100%);
border-radius: 1rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
border: 1px solid rgba(229, 231, 235, 0.5);
padding: 1.5rem;
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.page-header::before {
@@ -486,10 +534,6 @@
/* 深色模式支持 */
@media (prefers-color-scheme: dark) {
.page-header {
background: linear-gradient(135deg, #1f2937 0%, rgba(59, 130, 246, 0.1) 50%, #1f2937 100%);
border-color: rgba(75, 85, 99, 0.5);
}
.stat-card,
.content-card {
@@ -1081,11 +1125,19 @@ main::-webkit-scrollbar-thumb:hover {
/* 渐变背景 */
.bg-gradient-primary {
background: linear-gradient(135deg, rgb(var(--color-primary-500)), rgb(var(--color-primary-600)));
background: linear-gradient(135deg, var(--primary-500), var(--primary-600));
}
.bg-gradient-secondary {
background: linear-gradient(135deg, rgb(var(--color-gray-100)), rgb(var(--color-gray-200)));
background: linear-gradient(135deg, var(--gray-100), var(--gray-200));
}
/* 文本渐变 */
.text-gradient-primary {
background: linear-gradient(135deg, var(--primary-500), var(--primary-600));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.bg-gradient-success {

View File

@@ -0,0 +1,287 @@
/**
* 服装搭配搜索相关类型定义
* 遵循 Tauri 开发规范的类型定义模式
*/
// HSV颜色模型
export interface ColorHSV {
hue: number; // 色相 (0-1)
saturation: number; // 饱和度 (0-1)
value: number; // 明度 (0-1)
}
// 相关性阈值
export type RelevanceThreshold = 'LOWEST' | 'LOW' | 'MEDIUM' | 'HIGH';
// 颜色过滤器
export interface ColorFilter {
enabled: boolean;
color: ColorHSV;
hue_threshold: number;
saturation_threshold: number;
value_threshold: number;
}
// 搜索配置
export interface SearchConfig {
relevance_threshold: RelevanceThreshold;
environments: string[];
categories: string[];
color_filters: Record<string, ColorFilter>;
design_styles: Record<string, string[]>;
max_keywords: number;
}
// 搜索请求
export interface SearchRequest {
query: string;
config: SearchConfig;
page_size: number;
page_offset: number;
}
// 产品信息
export interface ProductInfo {
category: string;
description: string;
color_pattern: ColorHSV;
design_styles: string[];
}
// 搜索结果项
export interface SearchResult {
id: string;
image_url: string;
style_description: string;
environment_tags: string[];
products: ProductInfo[];
relevance_score: number;
}
// 搜索响应
export interface SearchResponse {
results: SearchResult[];
total_size: number;
next_page_token?: string;
search_time_ms: number;
searched_at: string;
}
// 产品分析结果
export interface ProductAnalysis {
category: string;
description: string;
color_pattern: ColorHSV;
design_styles: string[];
color_pattern_match_dress: number;
color_pattern_match_environment: number;
}
// Gemini分析结果
export interface OutfitAnalysisResult {
environment_tags: string[];
environment_color_pattern: ColorHSV;
dress_color_pattern: ColorHSV;
style_description: string;
products: ProductAnalysis[];
}
// 图像分析请求
export interface AnalyzeImageRequest {
image_path: string;
image_name: string;
}
// 图像分析响应
export interface AnalyzeImageResponse {
result: OutfitAnalysisResult;
analysis_time_ms: number;
analyzed_at: string;
}
// LLM问答请求
export interface LLMQueryRequest {
user_input: string;
session_id?: string;
}
// LLM问答响应
export interface LLMQueryResponse {
answer: string;
related_results: SearchResult[];
response_time_ms: number;
responded_at: string;
}
// 搜索历史
export interface SearchHistory {
id: string;
query: string;
config: SearchConfig;
results_count: number;
search_time_ms: number;
created_at: string;
}
// 全局配置信息
export interface OutfitSearchConfigInfo {
google_project_id: string;
vertex_ai_app_id: string;
storage_bucket_name: string;
data_store_id: string;
}
// 前端状态管理接口
export interface OutfitSearchState {
// 搜索状态
searchConfig: SearchConfig;
searchResults: SearchResult[];
isSearching: boolean;
searchError: string | null;
// 分析状态
analysisResult: OutfitAnalysisResult | null;
isAnalyzing: boolean;
analysisError: string | null;
// LLM问答状态
llmResponse: LLMQueryResponse | null;
isAsking: boolean;
llmError: string | null;
// UI状态
selectedImage: string | null;
showAdvancedFilters: boolean;
currentPage: number;
// 历史记录
searchHistory: SearchHistory[];
// 操作方法
updateSearchConfig: (config: Partial<SearchConfig>) => void;
executeSearch: (request: SearchRequest) => Promise<void>;
analyzeImage: (request: AnalyzeImageRequest) => Promise<void>;
askLLM: (request: LLMQueryRequest) => Promise<void>;
clearResults: () => void;
clearErrors: () => void;
setSelectedImage: (imagePath: string | null) => void;
toggleAdvancedFilters: () => void;
loadSearchHistory: () => Promise<void>;
}
// 组件Props类型
export interface OutfitSearchPanelProps {
config: SearchConfig;
onConfigChange: (config: SearchConfig) => void;
onSearch: (request: SearchRequest) => void;
isLoading: boolean;
}
export interface ColorPickerProps {
color: ColorHSV;
onChange: (color: ColorHSV) => void;
disabled?: boolean;
}
export interface FilterPanelProps {
config: SearchConfig;
onConfigChange: (config: SearchConfig) => void;
showAdvanced: boolean;
onToggleAdvanced: () => void;
}
export interface SearchResultsProps {
results: SearchResult[];
totalSize: number;
currentPage: number;
pageSize: number;
onPageChange: (page: number) => void;
onItemSelect?: (result: SearchResult) => void;
isLoading: boolean;
}
export interface OutfitCardProps {
result: SearchResult;
onSelect?: (result: SearchResult) => void;
showScore?: boolean;
}
export interface ImageUploaderProps {
onImageSelect: (imagePath: string | null) => void;
onAnalysisComplete: (result: OutfitAnalysisResult) => void;
isAnalyzing: boolean;
selectedImage: string | null;
}
export interface LLMChatProps {
onAskLLM: (request: LLMQueryRequest) => void;
response: LLMQueryResponse | null;
isLoading: boolean;
error: string | null;
}
// 默认值和常量
export const DEFAULT_SEARCH_CONFIG: SearchConfig = {
relevance_threshold: 'HIGH',
environments: [],
categories: [],
color_filters: {},
design_styles: {},
max_keywords: 10,
};
export const DEFAULT_COLOR_FILTER: ColorFilter = {
enabled: false,
color: { hue: 0.0, saturation: 0.0, value: 0.0 },
hue_threshold: 0.05,
saturation_threshold: 0.05,
value_threshold: 0.20,
};
export const RELEVANCE_THRESHOLD_OPTIONS = [
{ value: 'LOWEST' as const, label: '最低', description: '显示更多相关结果' },
{ value: 'LOW' as const, label: '低', description: '包含较多相关结果' },
{ value: 'MEDIUM' as const, label: '中等', description: '平衡相关性和数量' },
{ value: 'HIGH' as const, label: '高', description: '只显示高度相关结果' },
];
export const SUPPORTED_IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'webp'];
export const COMMON_CATEGORIES = [
'上装', '下装', '连衣裙', '外套', '鞋子', '配饰'
];
export const COMMON_ENVIRONMENTS = [
'Outdoor', 'Indoor', 'City street', 'Building facade', 'Natural setting'
];
export const COMMON_DESIGN_STYLES = [
'休闲', '正式', '运动', '街头', '简约', '复古', '时尚', '优雅'
];
// 工具函数类型
export interface ColorUtils {
hsvToHex: (color: ColorHSV) => string;
hexToHsv: (hex: string) => ColorHSV;
rgbToHsv: (r: number, g: number, b: number) => ColorHSV;
hsvToRgb: (color: ColorHSV) => [number, number, number];
colorDistance: (color1: ColorHSV, color2: ColorHSV) => number;
colorSimilarity: (color1: ColorHSV, color2: ColorHSV) => number;
}
// 错误类型
export interface OutfitSearchError {
code: string;
message: string;
details?: any;
}
// 分页信息
export interface PaginationInfo {
currentPage: number;
pageSize: number;
totalSize: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}

View File

@@ -0,0 +1,300 @@
import { ColorHSV } from '../types/outfitSearch';
/**
* 颜色工具函数
* 遵循 Tauri 开发规范的工具函数设计原则
*/
/**
* HSV颜色转换为十六进制字符串
*/
export function hsvToHex(color: ColorHSV): string {
const [r, g, b] = hsvToRgb(color);
return `#${Math.round(r).toString(16).padStart(2, '0')}${Math.round(g).toString(16).padStart(2, '0')}${Math.round(b).toString(16).padStart(2, '0')}`.toUpperCase();
}
/**
* 十六进制字符串转换为HSV颜色
*/
export function hexToHsv(hex: string): ColorHSV {
const cleanHex = hex.replace('#', '');
if (cleanHex.length !== 6) {
throw new Error('Invalid hex color format');
}
const r = parseInt(cleanHex.substring(0, 2), 16);
const g = parseInt(cleanHex.substring(2, 4), 16);
const b = parseInt(cleanHex.substring(4, 6), 16);
return rgbToHsv(r, g, b);
}
/**
* RGB值转换为HSV颜色
*/
export function rgbToHsv(r: number, g: number, b: number): ColorHSV {
r = r / 255;
g = g / 255;
b = b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
let hue = 0;
if (delta !== 0) {
if (max === r) {
hue = ((g - b) / delta) % 6;
} else if (max === g) {
hue = (b - r) / delta + 2;
} else {
hue = (r - g) / delta + 4;
}
hue = hue / 6;
}
const saturation = max === 0 ? 0 : delta / max;
const value = max;
return {
hue: Math.max(0, Math.min(1, hue)),
saturation: Math.max(0, Math.min(1, saturation)),
value: Math.max(0, Math.min(1, value)),
};
}
/**
* HSV颜色转换为RGB值
*/
export function hsvToRgb(color: ColorHSV): [number, number, number] {
const { hue, saturation, value } = color;
const c = value * saturation;
const x = c * (1 - Math.abs(((hue * 6) % 2) - 1));
const m = value - c;
let r = 0, g = 0, b = 0;
const hueSegment = Math.floor(hue * 6);
switch (hueSegment) {
case 0:
r = c; g = x; b = 0;
break;
case 1:
r = x; g = c; b = 0;
break;
case 2:
r = 0; g = c; b = x;
break;
case 3:
r = 0; g = x; b = c;
break;
case 4:
r = x; g = 0; b = c;
break;
case 5:
r = c; g = 0; b = x;
break;
}
return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255),
];
}
/**
* 计算两个颜色的距离
*/
export function colorDistance(color1: ColorHSV, color2: ColorHSV): number {
// 色相环形距离计算
const hueDiff = Math.abs(color1.hue - color2.hue);
const hueDistance = Math.min(hueDiff, 1 - hueDiff);
// 饱和度和明度线性距离
const satDistance = Math.abs(color1.saturation - color2.saturation);
const valDistance = Math.abs(color1.value - color2.value);
// 加权距离计算色相50%饱和度30%明度20%
return hueDistance * 0.5 + satDistance * 0.3 + valDistance * 0.2;
}
/**
* 计算颜色相似度 (0-11表示完全相同)
*/
export function colorSimilarity(color1: ColorHSV, color2: ColorHSV): number {
return 1 - colorDistance(color1, color2);
}
/**
* 判断颜色是否在指定阈值范围内匹配
*/
export function colorsMatch(
color1: ColorHSV,
color2: ColorHSV,
hueThreshold: number = 0.05,
satThreshold: number = 0.05,
valThreshold: number = 0.20
): boolean {
const hueDiff = Math.abs(color1.hue - color2.hue);
const hueDistance = Math.min(hueDiff, 1 - hueDiff);
const satDiff = Math.abs(color1.saturation - color2.saturation);
const valDiff = Math.abs(color1.value - color2.value);
return hueDistance <= hueThreshold &&
satDiff <= satThreshold &&
valDiff <= valThreshold;
}
/**
* 生成颜色的互补色
*/
export function getComplementaryColor(color: ColorHSV): ColorHSV {
return {
hue: (color.hue + 0.5) % 1,
saturation: color.saturation,
value: color.value,
};
}
/**
* 生成颜色的类似色(相邻色)
*/
export function getAnalogousColors(color: ColorHSV, count: number = 2): ColorHSV[] {
const colors: ColorHSV[] = [];
const step = 0.083; // 30度 / 360度 = 0.083
for (let i = 1; i <= count; i++) {
colors.push({
hue: (color.hue + step * i) % 1,
saturation: color.saturation,
value: color.value,
});
colors.push({
hue: (color.hue - step * i + 1) % 1,
saturation: color.saturation,
value: color.value,
});
}
return colors;
}
/**
* 生成颜色的三元色
*/
export function getTriadicColors(color: ColorHSV): ColorHSV[] {
return [
{
hue: (color.hue + 0.333) % 1,
saturation: color.saturation,
value: color.value,
},
{
hue: (color.hue + 0.667) % 1,
saturation: color.saturation,
value: color.value,
},
];
}
/**
* 调整颜色亮度
*/
export function adjustBrightness(color: ColorHSV, factor: number): ColorHSV {
return {
hue: color.hue,
saturation: color.saturation,
value: Math.max(0, Math.min(1, color.value * factor)),
};
}
/**
* 调整颜色饱和度
*/
export function adjustSaturation(color: ColorHSV, factor: number): ColorHSV {
return {
hue: color.hue,
saturation: Math.max(0, Math.min(1, color.saturation * factor)),
value: color.value,
};
}
/**
* 获取颜色的可读性文本颜色(黑色或白色)
*/
export function getContrastTextColor(color: ColorHSV): string {
const [r, g, b] = hsvToRgb(color);
// 计算相对亮度
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? '#000000' : '#FFFFFF';
}
/**
* 验证HSV颜色值的有效性
*/
export function isValidHSV(color: ColorHSV): boolean {
return (
typeof color.hue === 'number' && color.hue >= 0 && color.hue <= 1 &&
typeof color.saturation === 'number' && color.saturation >= 0 && color.saturation <= 1 &&
typeof color.value === 'number' && color.value >= 0 && color.value <= 1
);
}
/**
* 创建默认HSV颜色
*/
export function createDefaultHSV(): ColorHSV {
return { hue: 0, saturation: 0, value: 0.5 };
}
/**
* 从CSS颜色名称转换为HSV
*/
export function cssColorToHsv(cssColor: string): ColorHSV {
// 创建临时元素来获取计算后的颜色
const div = document.createElement('div');
div.style.color = cssColor;
document.body.appendChild(div);
const computedColor = window.getComputedStyle(div).color;
document.body.removeChild(div);
// 解析 rgb(r, g, b) 格式
const rgbMatch = computedColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (rgbMatch) {
const r = parseInt(rgbMatch[1]);
const g = parseInt(rgbMatch[2]);
const b = parseInt(rgbMatch[3]);
return rgbToHsv(r, g, b);
}
// 如果解析失败,返回默认颜色
return createDefaultHSV();
}
/**
* 颜色工具对象
*/
export const ColorUtils = {
hsvToHex,
hexToHsv,
rgbToHsv,
hsvToRgb,
colorDistance,
colorSimilarity,
colorsMatch,
getComplementaryColor,
getAnalogousColors,
getTriadicColors,
adjustBrightness,
adjustSaturation,
getContrastTextColor,
isValidHSV,
createDefaultHSV,
cssColorToHsv,
};

View File

@@ -0,0 +1,355 @@
# 服装搭配智能搜索系统设计文档
## 概述
基于promptx/outfit-match的Python实现设计一个集成到Tauri桌面应用的服装搭配智能搜索系统。该系统的核心功能是用户上传服装图片 → AI分析提取特征 → 在数据库中搜索相似商品 → 提供LLM驱动的情景搭配建议。
## 核心功能流程
### 1. 图像分析流程
```
用户上传图片 → Gemini API分析 → 提取颜色/风格/类别 → 生成搜索条件 → 显示解析结果
```
### 2. 智能搜索流程
```
解析结果 → 构建搜索过滤器 → Vertex AI Search → 返回相似商品 → 分页展示结果
```
### 3. LLM问答流程
```
用户输入情景 → RAG检索相关数据 → LLM生成搭配建议 → 展示推荐结果
```
## 系统架构设计
### 前端架构 (React + TypeScript)
```
src/pages/OutfitSearch.tsx # 主搜索页面
src/components/outfit/
├── ImageUploader.tsx # 图片上传组件
├── AnalysisResult.tsx # AI分析结果展示
├── SearchPanel.tsx # 搜索配置面板
├── SearchResults.tsx # 搜索结果展示
├── ColorPicker.tsx # 颜色选择器
├── FilterPanel.tsx # 筛选条件面板
└── LLMChat.tsx # LLM问答组件
```
### 后端架构 (Rust)
```
src/data/models/
├── outfit_search.rs # 搜索相关数据模型
└── gemini_analysis.rs # Gemini分析结果模型
src/business/services/
├── gemini_service.rs # Gemini API集成服务
├── outfit_search_service.rs # 搜索引擎服务
└── vertex_ai_service.rs # Vertex AI Search集成
src/presentation/commands/
└── outfit_search_commands.rs # Tauri命令接口
```
## 核心数据模型
### Gemini分析结果模型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutfitAnalysisResult {
pub environment_tags: Vec<String>,
pub environment_color_pattern: ColorHSV,
pub dress_color_pattern: ColorHSV,
pub style_description: String,
pub products: Vec<ProductAnalysis>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProductAnalysis {
pub category: String,
pub description: String,
pub color_pattern: ColorHSV,
pub design_styles: Vec<String>,
pub color_pattern_match_dress: f64,
pub color_pattern_match_environment: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColorHSV {
pub hue: f64, // 色相 (0-1)
pub saturation: f64, // 饱和度 (0-1)
pub value: f64, // 明度 (0-1)
}
```
### 搜索配置模型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchConfig {
pub relevance_threshold: RelevanceThreshold,
pub environments: Vec<String>,
pub categories: Vec<String>,
pub color_filters: HashMap<String, ColorFilter>,
pub design_styles: HashMap<String, Vec<String>>,
pub max_keywords: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColorFilter {
pub enabled: bool,
pub color: ColorHSV,
pub hue_threshold: f64,
pub saturation_threshold: f64,
pub value_threshold: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RelevanceThreshold {
LOWEST,
LOW,
MEDIUM,
HIGH,
}
```
### 搜索结果模型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResponse {
pub results: Vec<SearchResult>,
pub total_size: usize,
pub next_page_token: Option<String>,
pub search_time_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub id: String,
pub image_url: String,
pub style_description: String,
pub environment_tags: Vec<String>,
pub products: Vec<ProductInfo>,
pub relevance_score: f64,
}
```
## API接口设计
### Tauri命令接口
```rust
// 图像分析
#[tauri::command]
pub async fn analyze_outfit_image(
state: State<'_, AppState>,
image_path: String,
) -> Result<OutfitAnalysisResult, String>;
// 智能搜索
#[tauri::command]
pub async fn search_similar_outfits(
state: State<'_, AppState>,
config: SearchConfig,
) -> Result<SearchResponse, String>;
// LLM问答
#[tauri::command]
pub async fn ask_llm_outfit_advice(
state: State<'_, AppState>,
user_input: String,
) -> Result<String, String>;
// 获取搜索建议
#[tauri::command]
pub async fn get_search_suggestions(
state: State<'_, AppState>,
query: String,
) -> Result<Vec<String>, String>;
```
## 核心算法实现
### HSV颜色匹配算法
```rust
impl ColorHSV {
pub fn from_rgb_hex(hex: &str) -> Result<Self> {
// RGB转HSV实现
}
pub fn to_rgb_hex(&self) -> String {
// HSV转RGB实现
}
pub fn is_in_range(&self, other: &ColorHSV, thresholds: &ColorThresholds) -> bool {
let hue_diff = (self.hue - other.hue).abs();
let sat_diff = (self.saturation - other.saturation).abs();
let val_diff = (self.value - other.value).abs();
hue_diff <= thresholds.hue &&
sat_diff <= thresholds.saturation &&
val_diff <= thresholds.value
}
}
```
### 搜索过滤器构建
```rust
impl SearchFilterBuilder {
pub fn build_filters(config: &SearchConfig) -> String {
let mut filters = Vec::new();
// 类别过滤
if !config.categories.is_empty() {
for category in &config.categories {
let mut inner_filters = vec![
format!("products.category: ANY(\"{}\")", category)
];
// 颜色过滤
if let Some(color_filter) = config.color_filters.get(category) {
if color_filter.enabled {
inner_filters.extend(Self::build_color_filters(color_filter));
}
}
// 设计风格过滤
if let Some(styles) = config.design_styles.get(category) {
if !styles.is_empty() {
let styles_str = styles.iter()
.map(|s| format!("\"{}\"", s))
.collect::<Vec<_>>()
.join(",");
inner_filters.push(format!("products.design_styles: ANY({})", styles_str));
}
}
filters.push(format!("({})", inner_filters.join(" AND ")));
}
}
// 环境标签过滤
if !config.environments.is_empty() {
let env_str = config.environments.iter()
.map(|e| format!("\"{}\"", e))
.collect::<Vec<_>>()
.join(",");
filters.push(format!("environment_tags: ANY({})", env_str));
}
filters.join(" AND ")
}
}
```
## 用户界面设计
### 主搜索页面布局
```typescript
export const OutfitSearch: React.FC = () => {
return (
<div className="outfit-search-container">
<div className="search-panel">
<ImageUploader onAnalysisComplete={handleAnalysis} />
<SearchPanel config={searchConfig} onConfigChange={updateConfig} />
<LLMChat onAskLLM={handleLLMQuery} />
</div>
<div className="results-panel">
<SearchResults
results={searchResults}
onPageChange={handlePageChange}
/>
</div>
</div>
);
};
```
### 搜索配置面板
```typescript
export const SearchPanel: React.FC<SearchPanelProps> = ({ config, onConfigChange }) => {
return (
<div className="search-config">
<div className="relevance-threshold">
<label></label>
<select value={config.relevance_threshold} onChange={handleThresholdChange}>
<option value="LOWEST"></option>
<option value="LOW"></option>
<option value="MEDIUM"></option>
<option value="HIGH"></option>
</select>
</div>
<div className="category-filters">
{config.categories.map(category => (
<CategoryFilter
key={category}
category={category}
colorFilter={config.color_filters[category]}
designStyles={config.design_styles[category]}
onFilterChange={handleFilterChange}
/>
))}
</div>
<button onClick={handleSearch}></button>
</div>
);
};
```
## 集成方案
### 与现有系统集成
1. **导航系统集成**: 搜索功能作为顶部导航栏的独立页面,类似现有的"模特管理"、"AI分类设置"等
2. **AI分类系统复用**: 复用现有的Gemini API集成和提示词生成逻辑
3. **配置管理**: project_id等配置作为全局环境变量或应用配置不依赖具体项目
4. **状态管理**: 使用现有的Zustand状态管理模式
### 配置管理
```rust
// 全局配置结构
#[derive(Debug, Clone)]
pub struct OutfitSearchConfig {
pub google_project_id: String, // "gen-lang-client-0413414134"
pub vertex_ai_app_id: String, // "jeans-search_1751353769585"
pub storage_bucket_name: String, // "fashion_image_block"
pub data_store_id: String, // "jeans_pattern_data_store"
pub cloudflare_project_id: String, // "67720b647ff2b55cf37ba3ef9e677083"
pub cloudflare_gateway_id: String, // "bowong-dev"
}
impl Default for OutfitSearchConfig {
fn default() -> Self {
Self {
google_project_id: "gen-lang-client-0413414134".to_string(),
vertex_ai_app_id: "jeans-search_1751353769585".to_string(),
storage_bucket_name: "fashion_image_block".to_string(),
data_store_id: "jeans_pattern_data_store".to_string(),
cloudflare_project_id: "67720b647ff2b55cf37ba3ef9e677083".to_string(),
cloudflare_gateway_id: "bowong-dev".to_string(),
}
}
}
```
### 部署配置
1. **环境变量**: 配置Google Cloud访问令牌、Cloudflare网关等
2. **依赖管理**: 添加必要的Rust crates和npm包
3. **构建配置**: 更新Tauri配置以支持网络访问权限
## 开发优先级
### P0 (核心功能)
- [ ] Gemini API集成和图像分析
- [ ] 基础搜索界面和结果展示
- [ ] HSV颜色匹配算法
### P1 (完整功能)
- [ ] Vertex AI Search集成
- [ ] 高级搜索过滤器
- [ ] LLM问答功能
### P2 (优化功能)
- [ ] 搜索历史和偏好
- [ ] 性能优化和缓存
- [ ] 用户体验改进