feat: 服装搭配页面UI美化和UX改进
UI优化内容: - 重新设计页面头部,使用优雅的渐变背景和现代化图标 - 优化标签导航,采用卡片式设计和平滑动画效果 - 美化搜索面板,改进输入框、筛选器和按钮的视觉设计 - 重构图片上传组件,添加拖拽区域样式和上传进度动画 - 优化搜索结果展示,使用网格布局和悬停效果 - 改进AI分析结果展示,采用卡片式布局和颜色编码 - 增强LLM聊天界面,现代化消息气泡和打字动画 响应式设计: - 实现移动优先的响应式布局 - 优化平板端和桌面端适配 - 修复1200px宽度下的左右布局显示问题 - 添加触摸友好的交互元素 用户体验提升: - 统一设计语言和视觉风格 - 添加流畅的页面切换和组件加载动画 - 优化加载状态、错误提示和空状态设计 - 改进信息层次和视觉可读性 技术改进: - 使用Tailwind CSS类替代内联样式 - 统一使用Lucide React图标库 - 完善CSS变量和设计令牌系统 - 添加兼容性变量支持旧的命名格式 符合promptx/frontend-developer规定的前端开发规范,确保界面美观、操作流畅、动画优美,符合用户操作习惯和大众审美习惯。
This commit is contained in:
416
apps/desktop/src/components/outfit/ColorPicker.tsx
Normal file
416
apps/desktop/src/components/outfit/ColorPicker.tsx
Normal 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;
|
||||
505
apps/desktop/src/components/outfit/FilterPanel.tsx
Normal file
505
apps/desktop/src/components/outfit/FilterPanel.tsx
Normal 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;
|
||||
218
apps/desktop/src/components/outfit/ImageUploader.tsx
Normal file
218
apps/desktop/src/components/outfit/ImageUploader.tsx
Normal 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;
|
||||
279
apps/desktop/src/components/outfit/LLMChat.tsx
Normal file
279
apps/desktop/src/components/outfit/LLMChat.tsx
Normal 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;
|
||||
368
apps/desktop/src/components/outfit/OutfitCard.tsx
Normal file
368
apps/desktop/src/components/outfit/OutfitCard.tsx
Normal 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;
|
||||
220
apps/desktop/src/components/outfit/OutfitSearchPanel.tsx
Normal file
220
apps/desktop/src/components/outfit/OutfitSearchPanel.tsx
Normal 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;
|
||||
174
apps/desktop/src/components/outfit/SearchResults.tsx
Normal file
174
apps/desktop/src/components/outfit/SearchResults.tsx
Normal 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;
|
||||
301
apps/desktop/src/pages/OutfitMatch.tsx
Normal file
301
apps/desktop/src/pages/OutfitMatch.tsx
Normal 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;
|
||||
288
apps/desktop/src/services/outfitSearchService.ts
Normal file
288
apps/desktop/src/services/outfitSearchService.ts
Normal 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;
|
||||
326
apps/desktop/src/store/outfitSearchStore.ts
Normal file
326
apps/desktop/src/store/outfitSearchStore.ts
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
287
apps/desktop/src/types/outfitSearch.ts
Normal file
287
apps/desktop/src/types/outfitSearch.ts
Normal 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;
|
||||
}
|
||||
300
apps/desktop/src/utils/colorUtils.ts
Normal file
300
apps/desktop/src/utils/colorUtils.ts
Normal 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-1,1表示完全相同)
|
||||
*/
|
||||
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,
|
||||
};
|
||||
355
docs/outfit-matching-search-system.md
Normal file
355
docs/outfit-matching-search-system.md
Normal 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 (优化功能)
|
||||
- [ ] 搜索历史和偏好
|
||||
- [ ] 性能优化和缓存
|
||||
- [ ] 用户体验改进
|
||||
Reference in New Issue
Block a user