feat: 完善智能搭配推荐功能和调试工具

新功能:
- 完整实现智能搭配推荐系统
  - OutfitMatchingRecommendation: 完整的推荐界面组件
  - generate_outfit_recommendations: 后端推荐算法
  - 色彩和谐度和风格一致性评分算法
  - 智能场合和季节标签生成
- 添加调试工具 debug_outfit_items_stats
  - 检查项目中的服装单品统计
  - 详细的数据分析和建议

 算法实现:
- 搭配组合生成逻辑
  - 上装+下装+鞋子组合
  - 连衣裙+鞋子组合
  - 可选外套和配饰
- 智能评分系统
  - 色彩和谐度计算 (HSV色彩空间)
  - 风格一致性评估
  - 综合评分和筛选
- 标签生成算法
  - 场合推断 (工作/休闲/正式/运动等)
  - 季节适用性分析

 UI/UX优化:
- 现代化的推荐卡片设计
- 智能筛选面板 (场合/季节/风格/评分)
- 收藏和保存功能
- 详情模态框展示
- 调试按钮和数据检查工具

 问题诊断:
- 添加详细的调试日志
- 搭配组合生成过程跟踪
- 评分计算过程可视化
- 数据统计和分析工具

 当前状态:
- 项目中有2件服装单品 (连衣裙+高跟鞋)
- 数量足够生成搭配推荐
- 正在调试为什么生成0个推荐的问题
This commit is contained in:
imeepos
2025-07-17 19:47:51 +08:00
parent ebe4a24bc0
commit 42836784b4
6 changed files with 2139 additions and 11 deletions

View File

@@ -0,0 +1,431 @@
import React, { useState, useEffect } from 'react';
import {
PlusIcon,
PencilIcon,
TrashIcon,
EyeIcon,
MagnifyingGlassIcon,
FunnelIcon
} from '@heroicons/react/24/outline';
import { invoke } from '@tauri-apps/api/core';
import { useNotifications } from '../NotificationSystem';
interface OutfitItem {
id: string;
project_id: string;
analysis_id?: string;
name: string;
category: string;
description?: string;
color_pattern?: any;
design_styles?: string[];
brand?: string;
size?: string;
price?: number;
purchase_date?: string;
image_path?: string;
tags?: string[];
notes?: string;
created_at: string;
updated_at: string;
}
interface OutfitItemListProps {
projectId: string;
onCreateItem?: () => void;
onEditItem?: (item: OutfitItem) => void;
}
const OutfitItemList: React.FC<OutfitItemListProps> = ({
projectId,
onCreateItem,
onEditItem
}) => {
const [items, setItems] = useState<OutfitItem[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [selectedItem, setSelectedItem] = useState<OutfitItem | null>(null);
const { addNotification } = useNotifications();
// 加载服装单品列表
const loadItems = async () => {
try {
setLoading(true);
const options = {
project_id: projectId,
category: selectedCategory || null,
limit: 100,
offset: 0
};
const result = await invoke('list_outfit_items', { options });
setItems(result as OutfitItem[]);
} catch (error) {
console.error('加载服装单品失败:', error);
addNotification({
type: 'error',
title: '加载失败',
message: '无法加载服装单品列表'
});
} finally {
setLoading(false);
}
};
// 删除服装单品
const handleDeleteItem = async (itemId: string) => {
if (!window.confirm('确定要删除这个服装单品吗?')) {
return;
}
try {
await invoke('delete_outfit_item', { id: itemId });
addNotification({
type: 'success',
title: '删除成功',
message: '服装单品已删除'
});
// 重新加载列表
loadItems();
} catch (error) {
console.error('删除服装单品失败:', error);
addNotification({
type: 'error',
title: '删除失败',
message: error instanceof Error ? error.message : '删除服装单品失败'
});
}
};
// 过滤服装单品
const filteredItems = items.filter(item => {
const matchesSearch = !searchTerm ||
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.brand?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = !selectedCategory || item.category === selectedCategory;
return matchesSearch && matchesCategory;
});
// 获取所有类别
const categories = Array.from(new Set(items.map(item => item.category))).filter(Boolean);
// 格式化价格
const formatPrice = (price?: number) => {
if (!price) return '';
return `¥${price.toFixed(2)}`;
};
// 格式化时间
const formatTime = (timeString: string) => {
return new Date(timeString).toLocaleString('zh-CN');
};
useEffect(() => {
if (projectId) {
loadItems();
}
}, [projectId, selectedCategory]);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<span className="ml-2 text-gray-600">...</span>
</div>
);
}
return (
<div className="space-y-6">
{/* 工具栏 */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex flex-col sm:flex-row gap-4 flex-1">
{/* 搜索框 */}
<div className="relative flex-1 max-w-md">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="搜索服装单品..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* 类别筛选 */}
<div className="relative">
<FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="pl-10 pr-8 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500 appearance-none bg-white"
>
<option value=""></option>
{categories.map(category => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
</div>
{/* 添加按钮 */}
<button
onClick={onCreateItem}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<PlusIcon className="h-4 w-4 mr-2" />
</button>
</div>
{/* 统计信息 */}
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center justify-between text-sm text-gray-600">
<span> {filteredItems.length} </span>
{searchTerm || selectedCategory ? (
<span> {items.length} </span>
) : null}
</div>
</div>
{/* 服装单品列表 */}
{filteredItems.length === 0 ? (
<div className="text-center py-12">
<div className="mx-auto h-12 w-12 text-gray-400 mb-4">
<PlusIcon className="h-full w-full" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
{searchTerm || selectedCategory ? '没有找到匹配的服装单品' : '暂无服装单品'}
</h3>
<p className="text-gray-500 mb-4">
{searchTerm || selectedCategory ? '请尝试调整搜索条件' : '请先上传图片进行AI分析或手动添加服装单品'}
</p>
{!searchTerm && !selectedCategory && (
<button
onClick={onCreateItem}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<PlusIcon className="h-4 w-4 mr-2" />
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredItems.map((item) => (
<div
key={item.id}
className="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
>
{/* 图片预览 */}
<div className="aspect-square bg-gray-100 relative">
{item.image_path ? (
<img
src={`file://${item.image_path}`}
alt={item.name}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="text-gray-400 text-center">
<div className="w-12 h-12 mx-auto mb-2 bg-gray-200 rounded-full flex items-center justify-center">
<PlusIcon className="w-6 h-6" />
</div>
<span className="text-sm"></span>
</div>
</div>
)}
<div className="absolute top-2 right-2 flex space-x-1">
<button
onClick={() => setSelectedItem(item)}
className="p-1.5 bg-white rounded-full shadow-md hover:shadow-lg transition-shadow"
>
<EyeIcon className="w-3 h-3 text-gray-600" />
</button>
</div>
</div>
{/* 单品信息 */}
<div className="p-4">
<div className="flex items-start justify-between mb-2">
<h4 className="font-medium text-gray-900 truncate flex-1">
{item.name}
</h4>
<span className="ml-2 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-full">
{item.category}
</span>
</div>
{item.brand && (
<p className="text-sm text-gray-600 mb-1">
: {item.brand}
</p>
)}
{item.price && (
<p className="text-sm font-medium text-green-600 mb-2">
{formatPrice(item.price)}
</p>
)}
{item.description && (
<p className="text-sm text-gray-500 mb-3 line-clamp-2">
{item.description}
</p>
)}
<p className="text-xs text-gray-400 mb-3">
: {formatTime(item.created_at)}
</p>
{/* 操作按钮 */}
<div className="flex space-x-2">
<button
onClick={() => onEditItem?.(item)}
className="flex-1 px-3 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
<PencilIcon className="w-4 h-4 inline mr-1" />
</button>
<button
onClick={() => handleDeleteItem(item.id)}
className="px-3 py-2 text-sm font-medium text-red-700 bg-red-100 rounded-md hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* 详情模态框 */}
{selectedItem && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:text-left w-full">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
</h3>
<div className="space-y-3">
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{selectedItem.name}</span>
</div>
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{selectedItem.category}</span>
</div>
{selectedItem.brand && (
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{selectedItem.brand}</span>
</div>
)}
{selectedItem.size && (
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{selectedItem.size}</span>
</div>
)}
{selectedItem.price && (
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{formatPrice(selectedItem.price)}</span>
</div>
)}
{selectedItem.description && (
<div>
<span className="font-medium text-gray-700">:</span>
<p className="mt-1 text-gray-600">{selectedItem.description}</p>
</div>
)}
{selectedItem.design_styles && selectedItem.design_styles.length > 0 && (
<div>
<span className="font-medium text-gray-700">:</span>
<div className="mt-1 flex flex-wrap gap-1">
{selectedItem.design_styles.map((style, index) => (
<span
key={index}
className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full"
>
{style}
</span>
))}
</div>
</div>
)}
{selectedItem.tags && selectedItem.tags.length > 0 && (
<div>
<span className="font-medium text-gray-700">:</span>
<div className="mt-1 flex flex-wrap gap-1">
{selectedItem.tags.map((tag, index) => (
<span
key={index}
className="px-2 py-1 text-xs bg-gray-100 text-gray-800 rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{formatTime(selectedItem.created_at)}</span>
</div>
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{formatTime(selectedItem.updated_at)}</span>
</div>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
onClick={() => setSelectedItem(null)}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
>
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default OutfitItemList;