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

@@ -276,7 +276,9 @@ pub fn run() {
commands::outfit_commands::delete_outfit_matching,
commands::outfit_commands::smart_outfit_matching,
commands::outfit_commands::increment_outfit_matching_wear_count,
commands::outfit_commands::save_outfit_image
commands::outfit_commands::save_outfit_image,
commands::outfit_commands::generate_outfit_recommendations,
commands::outfit_commands::debug_outfit_items_stats
])
.setup(|app| {
// 初始化日志系统

View File

@@ -6,6 +6,7 @@
use tauri::{command, State};
use std::sync::Arc;
use serde_json::Value;
use serde::{Serialize, Deserialize};
use crate::app_state::AppState;
use crate::business::services::outfit_analysis_service::OutfitAnalysisService;
@@ -235,6 +236,124 @@ pub async fn create_outfit_items_from_analysis(
Ok(created_items)
}
/// 生成智能搭配推荐
#[command]
pub async fn generate_outfit_recommendations(
state: State<'_, AppState>,
request: GenerateRecommendationRequest,
) -> Result<Vec<OutfitRecommendation>, String> {
let database = state.get_database();
let item_service = create_item_service(database.clone())?;
let matching_service = create_matching_service(database)?;
// 1. 获取项目中的所有服装单品
let query_options = OutfitItemQueryOptions {
project_id: Some(request.project_id.clone()),
category: None,
styles: None,
color_similarity_threshold: None,
target_color: None,
brand: None,
tags: None,
limit: Some(100),
offset: Some(0),
};
let items = item_service.get_items(query_options).await
.map_err(|e| format!("获取服装单品失败: {}", e))?;
println!("🔍 项目 {} 中找到 {} 件服装单品", request.project_id, items.len());
for (i, item) in items.iter().enumerate() {
println!(" {}. {} - {} ({})", i + 1, item.name, item.category.to_chinese(), item.id);
}
if items.len() < 2 {
return Err(format!("至少需要2件服装单品才能生成搭配推荐当前项目 {} 中只有 {}", request.project_id, items.len()));
}
// 2. 生成搭配组合
let combinations = generate_outfit_combinations(&items, &request);
println!("🔄 生成了 {} 个搭配组合", combinations.len());
// 3. 评估每个组合的搭配效果
let mut recommendations = Vec::new();
for (i, combination) in combinations.iter().enumerate() {
println!(" 组合 {}: {} 件单品", i + 1, combination.len());
for item in combination {
println!(" - {} ({})", item.name, item.category.to_chinese());
}
if let Some(recommendation) = evaluate_outfit_combination(combination.clone(), &request) {
println!(" ✅ 评分: {:.2}", recommendation.score);
recommendations.push(recommendation);
} else {
println!(" ❌ 评估失败或评分过低");
}
}
// 4. 按评分排序并限制数量
recommendations.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
recommendations.truncate(request.max_recommendations);
// 5. 过滤低分推荐
recommendations.retain(|rec| rec.score >= request.min_score_threshold);
println!("🎯 生成了 {} 个搭配推荐", recommendations.len());
Ok(recommendations)
}
/// 调试:获取项目服装单品统计
#[command]
pub async fn debug_outfit_items_stats(
state: State<'_, AppState>,
project_id: String,
) -> Result<String, String> {
let database = state.get_database();
let item_service = create_item_service(database)?;
// 获取指定项目的所有服装单品
let query_options = OutfitItemQueryOptions {
project_id: Some(project_id.clone()),
category: None,
styles: None,
color_similarity_threshold: None,
target_color: None,
brand: None,
tags: None,
limit: Some(1000),
offset: Some(0),
};
let items = item_service.get_items(query_options).await
.map_err(|e| format!("获取服装单品失败: {}", e))?;
let mut result = format!("📊 项目 {} 的服装单品统计\n总计: {}\n\n", project_id, items.len());
if items.is_empty() {
result.push_str("❌ 该项目中没有任何服装单品\n");
result.push_str("💡 建议:\n");
result.push_str("1. 先上传服装图片进行AI分析\n");
result.push_str("2. 从分析结果创建服装单品\n");
result.push_str("3. 或手动添加服装单品\n");
} else {
for (i, item) in items.iter().enumerate() {
result.push_str(&format!("{}. {} - {} (ID: {})\n",
i + 1, item.name, item.category.to_chinese(), item.id));
}
if items.len() < 2 {
result.push_str("\n⚠️ 需要至少2件服装单品才能生成搭配推荐\n");
} else {
result.push_str("\n✅ 服装单品数量足够,可以生成搭配推荐\n");
}
}
println!("{}", result);
Ok(result)
}
/// 获取服装单品列表
#[command]
pub async fn list_outfit_items(
@@ -572,3 +691,407 @@ fn parse_color_from_value(value: &Value) -> Option<crate::data::models::outfit_a
None
}
}
/// 生成推荐请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerateRecommendationRequest {
pub project_id: String,
pub max_recommendations: usize,
pub min_score_threshold: f64,
pub occasion_filter: Option<String>,
pub season_filter: Option<String>,
pub style_filter: Option<String>,
}
/// 搭配推荐结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutfitRecommendation {
pub id: String,
pub items: Vec<RecommendationItem>,
pub score: f64,
pub style_description: String,
pub occasion_tags: Vec<String>,
pub season_tags: Vec<String>,
pub color_harmony_score: f64,
pub style_consistency_score: f64,
pub created_at: String,
}
/// 推荐中的服装单品
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecommendationItem {
pub id: String,
pub name: String,
pub category: String,
pub brand: Option<String>,
pub image_urls: Vec<String>,
pub color_primary: crate::data::models::outfit_analysis::ColorHSV,
pub styles: Vec<String>,
pub tags: Vec<String>,
}
/// 生成搭配组合
fn generate_outfit_combinations(items: &[OutfitItem], request: &GenerateRecommendationRequest) -> Vec<Vec<OutfitItem>> {
let mut combinations = Vec::new();
// 按类别分组
let mut tops = Vec::new();
let mut bottoms = Vec::new();
let mut dresses = Vec::new();
let mut outerwear = Vec::new();
let mut footwear = Vec::new();
let mut accessories = Vec::new();
for item in items {
match item.category {
crate::data::models::outfit_item::OutfitCategory::Top => tops.push(item.clone()),
crate::data::models::outfit_item::OutfitCategory::Bottom => bottoms.push(item.clone()),
crate::data::models::outfit_item::OutfitCategory::Dress => dresses.push(item.clone()),
crate::data::models::outfit_item::OutfitCategory::Outerwear => outerwear.push(item.clone()),
crate::data::models::outfit_item::OutfitCategory::Footwear => footwear.push(item.clone()),
crate::data::models::outfit_item::OutfitCategory::Accessory => accessories.push(item.clone()),
_ => {}
}
}
println!("📦 服装分类统计:");
println!(" 上装: {}", tops.len());
println!(" 下装: {}", bottoms.len());
println!(" 连衣裙: {}", dresses.len());
println!(" 外套: {}", outerwear.len());
println!(" 鞋类: {}", footwear.len());
println!(" 配饰: {}", accessories.len());
// 生成基本搭配组合
// 1. 上装 + 下装 + 鞋子 (+ 可选外套/配饰)
for top in &tops {
for bottom in &bottoms {
for shoe in &footwear {
let mut combination = vec![top.clone(), bottom.clone(), shoe.clone()];
// 可选添加外套
if !outerwear.is_empty() && combinations.len() < request.max_recommendations * 2 {
for outer in &outerwear {
let mut combo_with_outer = combination.clone();
combo_with_outer.push(outer.clone());
combinations.push(combo_with_outer);
}
}
// 可选添加配饰
if !accessories.is_empty() && combination.len() < 5 {
for accessory in accessories.iter().take(2) {
combination.push(accessory.clone());
}
}
combinations.push(combination);
if combinations.len() >= request.max_recommendations * 3 {
break;
}
}
if combinations.len() >= request.max_recommendations * 3 {
break;
}
}
if combinations.len() >= request.max_recommendations * 3 {
break;
}
}
// 2. 连衣裙 + 鞋子 (+ 可选外套/配饰)
for dress in &dresses {
for shoe in &footwear {
let mut combination = vec![dress.clone(), shoe.clone()];
// 可选添加外套
if !outerwear.is_empty() {
for outer in outerwear.iter().take(1) {
combination.push(outer.clone());
}
}
// 可选添加配饰
if !accessories.is_empty() && combination.len() < 4 {
for accessory in accessories.iter().take(2) {
combination.push(accessory.clone());
}
}
combinations.push(combination);
if combinations.len() >= request.max_recommendations * 3 {
break;
}
}
if combinations.len() >= request.max_recommendations * 3 {
break;
}
}
combinations
}
/// 评估搭配组合
fn evaluate_outfit_combination(combination: Vec<OutfitItem>, request: &GenerateRecommendationRequest) -> Option<OutfitRecommendation> {
if combination.len() < 2 {
return None;
}
// 计算色彩和谐度
let color_harmony_score = calculate_color_harmony(&combination);
// 计算风格一致性
let style_consistency_score = calculate_style_consistency(&combination);
// 计算综合评分
let overall_score = (color_harmony_score * 0.4 + style_consistency_score * 0.6).clamp(0.0, 1.0);
// 过滤低分组合
if overall_score < request.min_score_threshold {
return None;
}
// 生成风格描述
let style_description = generate_style_description(&combination);
// 生成场合标签
let occasion_tags = generate_occasion_tags(&combination);
// 生成季节标签
let season_tags = generate_season_tags(&combination);
// 应用筛选条件
if let Some(occasion_filter) = &request.occasion_filter {
if !occasion_tags.iter().any(|tag| tag.contains(occasion_filter)) {
return None;
}
}
if let Some(season_filter) = &request.season_filter {
if !season_tags.iter().any(|tag| tag.contains(season_filter)) {
return None;
}
}
if let Some(style_filter) = &request.style_filter {
if !style_description.to_lowercase().contains(&style_filter.to_lowercase()) {
return None;
}
}
// 转换为推荐格式
let recommendation_items: Vec<RecommendationItem> = combination.iter().map(|item| {
RecommendationItem {
id: item.id.clone(),
name: item.name.clone(),
category: item.category.to_chinese().to_string(),
brand: item.brand.clone(),
image_urls: item.image_urls.clone(),
color_primary: item.color_primary.clone(),
styles: item.styles.iter().map(|s| s.to_string()).collect(),
tags: item.tags.clone(),
}
}).collect();
Some(OutfitRecommendation {
id: uuid::Uuid::new_v4().to_string(),
items: recommendation_items,
score: overall_score,
style_description,
occasion_tags,
season_tags,
color_harmony_score,
style_consistency_score,
created_at: chrono::Utc::now().to_rfc3339(),
})
}
/// 计算色彩和谐度
fn calculate_color_harmony(combination: &[OutfitItem]) -> f64 {
if combination.len() < 2 {
return 0.5;
}
let mut harmony_scores = Vec::new();
// 计算每对颜色之间的和谐度
for i in 0..combination.len() {
for j in (i + 1)..combination.len() {
let color1 = &combination[i].color_primary;
let color2 = &combination[j].color_primary;
// 计算色相差异
let hue_diff = (color1.hue - color2.hue).abs();
let hue_diff = hue_diff.min(1.0 - hue_diff); // 处理环形色相
// 计算饱和度和明度的相似性
let sat_similarity = 1.0 - (color1.saturation - color2.saturation).abs();
let val_similarity = 1.0 - (color1.value - color2.value).abs();
// 和谐度评分规则
let harmony_score = if hue_diff < 0.05 {
// 同色系:高和谐度
0.9 * sat_similarity * val_similarity
} else if hue_diff < 0.15 {
// 邻近色:中高和谐度
0.8 * sat_similarity * val_similarity
} else if (hue_diff - 0.5).abs() < 0.1 {
// 互补色:中等和谐度
0.7 * sat_similarity
} else if (hue_diff - 0.33).abs() < 0.1 || (hue_diff - 0.67).abs() < 0.1 {
// 三角色:中等和谐度
0.6 * sat_similarity
} else {
// 其他:较低和谐度
0.4 * sat_similarity * val_similarity
};
harmony_scores.push(harmony_score);
}
}
// 返回平均和谐度
if harmony_scores.is_empty() {
0.5
} else {
harmony_scores.iter().sum::<f64>() / harmony_scores.len() as f64
}
}
/// 计算风格一致性
fn calculate_style_consistency(combination: &[OutfitItem]) -> f64 {
if combination.len() < 2 {
return 0.5;
}
// 收集所有风格
let mut all_styles = std::collections::HashSet::new();
let mut style_counts = std::collections::HashMap::new();
for item in combination {
for style in &item.styles {
let style_str = style.to_string();
all_styles.insert(style_str.clone());
*style_counts.entry(style_str).or_insert(0) += 1;
}
}
if all_styles.is_empty() {
return 0.5;
}
// 计算风格重叠度
let total_items = combination.len();
let mut consistency_score = 0.0;
for (_, count) in style_counts {
let overlap_ratio = count as f64 / total_items as f64;
consistency_score += overlap_ratio * overlap_ratio; // 平方加权
}
// 归一化到0-1范围
consistency_score.clamp(0.0, 1.0)
}
/// 生成风格描述
fn generate_style_description(combination: &[OutfitItem]) -> String {
let mut style_counts = std::collections::HashMap::new();
for item in combination {
for style in &item.styles {
let style_str = style.to_string();
*style_counts.entry(style_str).or_insert(0) += 1;
}
}
if style_counts.is_empty() {
return "混搭风格".to_string();
}
// 找到最主要的风格
let dominant_style = style_counts.iter()
.max_by_key(|(_, count)| *count)
.map(|(style, _)| style.clone())
.unwrap_or_else(|| "混搭".to_string());
// 根据组合特点生成描述
let categories: Vec<String> = combination.iter()
.map(|item| item.category.to_chinese().to_string())
.collect();
if categories.contains(&"连衣裙".to_string()) {
format!("{}连衣裙搭配", dominant_style)
} else if categories.contains(&"外套".to_string()) {
format!("{}外套搭配", dominant_style)
} else {
format!("{}搭配", dominant_style)
}
}
/// 生成场合标签
fn generate_occasion_tags(combination: &[OutfitItem]) -> Vec<String> {
let mut tags = Vec::new();
// 根据风格推断场合
let styles: Vec<String> = combination.iter()
.flat_map(|item| item.styles.iter().map(|s| s.to_string()))
.collect();
if styles.iter().any(|s| s.contains("正式") || s.contains("商务")) {
tags.push("工作".to_string());
tags.push("正式".to_string());
}
if styles.iter().any(|s| s.contains("休闲") || s.contains("街头")) {
tags.push("休闲".to_string());
tags.push("日常".to_string());
}
if styles.iter().any(|s| s.contains("运动")) {
tags.push("运动".to_string());
tags.push("健身".to_string());
}
if styles.iter().any(|s| s.contains("优雅") || s.contains("时尚")) {
tags.push("约会".to_string());
tags.push("聚会".to_string());
}
// 如果没有明确的场合,添加通用标签
if tags.is_empty() {
tags.push("日常".to_string());
tags.push("休闲".to_string());
}
tags
}
/// 生成季节标签
fn generate_season_tags(combination: &[OutfitItem]) -> Vec<String> {
let mut tags = Vec::new();
// 根据类别和材质推断季节
let categories: Vec<String> = combination.iter()
.map(|item| item.category.to_chinese().to_string())
.collect();
let has_outerwear = categories.contains(&"外套".to_string());
let has_dress = categories.contains(&"连衣裙".to_string());
// 简单的季节推断逻辑
if has_outerwear {
tags.push("秋季".to_string());
tags.push("冬季".to_string());
} else if has_dress {
tags.push("春季".to_string());
tags.push("夏季".to_string());
} else {
// 通用搭配,适合多个季节
tags.push("春季".to_string());
tags.push("秋季".to_string());
}
tags
}

View File

@@ -0,0 +1,498 @@
import React, { useState, useEffect } from 'react';
import {
XMarkIcon,
PhotoIcon,
PlusIcon,
XCircleIcon
} 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 OutfitItemFormData {
name: string;
category: string;
description: string;
brand: string;
size: string;
price: string;
purchase_date: string;
design_styles: string[];
tags: string[];
notes: string;
}
interface OutfitItemFormProps {
projectId: string;
item?: OutfitItem;
isOpen: boolean;
onClose: () => void;
onSave: () => void;
}
const OutfitItemForm: React.FC<OutfitItemFormProps> = ({
projectId,
item,
isOpen,
onClose,
onSave
}) => {
const [formData, setFormData] = useState<OutfitItemFormData>({
name: '',
category: '',
description: '',
brand: '',
size: '',
price: '',
purchase_date: '',
design_styles: [],
tags: [],
notes: ''
});
const [loading, setLoading] = useState(false);
const [newStyle, setNewStyle] = useState('');
const [newTag, setNewTag] = useState('');
const { addNotification } = useNotifications();
// 常用类别选项
const categoryOptions = [
'上衣', '下装', '外套', '连衣裙', '鞋子',
'包包', '配饰', '内衣', '运动装', '正装'
];
// 常用尺寸选项
const sizeOptions = [
'XS', 'S', 'M', 'L', 'XL', 'XXL',
'34', '35', '36', '37', '38', '39', '40', '41', '42', '43', '44'
];
// 初始化表单数据
useEffect(() => {
if (item) {
setFormData({
name: item.name || '',
category: item.category || '',
description: item.description || '',
brand: item.brand || '',
size: item.size || '',
price: item.price ? item.price.toString() : '',
purchase_date: item.purchase_date || '',
design_styles: item.design_styles || [],
tags: item.tags || [],
notes: item.notes || ''
});
} else {
setFormData({
name: '',
category: '',
description: '',
brand: '',
size: '',
price: '',
purchase_date: '',
design_styles: [],
tags: [],
notes: ''
});
}
}, [item, isOpen]);
// 处理表单提交
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
addNotification({
type: 'error',
title: '验证失败',
message: '请输入服装单品名称'
});
return;
}
if (!formData.category.trim()) {
addNotification({
type: 'error',
title: '验证失败',
message: '请选择服装类别'
});
return;
}
setLoading(true);
try {
const requestData = {
project_id: projectId,
analysis_id: item?.analysis_id || null,
name: formData.name.trim(),
category: formData.category.trim(),
description: formData.description.trim() || null,
brand: formData.brand.trim() || null,
size: formData.size.trim() || null,
price: formData.price ? parseFloat(formData.price) : null,
purchase_date: formData.purchase_date || null,
design_styles: formData.design_styles.length > 0 ? formData.design_styles : null,
tags: formData.tags.length > 0 ? formData.tags : null,
notes: formData.notes.trim() || null
};
if (item) {
// 更新现有单品
await invoke('update_outfit_item', {
id: item.id,
request: requestData
});
addNotification({
type: 'success',
title: '更新成功',
message: '服装单品信息已更新'
});
} else {
// 创建新单品
await invoke('create_outfit_item', {
request: requestData
});
addNotification({
type: 'success',
title: '创建成功',
message: '服装单品已创建'
});
}
onSave();
onClose();
} catch (error) {
console.error('保存服装单品失败:', error);
addNotification({
type: 'error',
title: '保存失败',
message: error instanceof Error ? error.message : '保存服装单品失败'
});
} finally {
setLoading(false);
}
};
// 添加设计风格
const addDesignStyle = () => {
if (newStyle.trim() && !formData.design_styles.includes(newStyle.trim())) {
setFormData(prev => ({
...prev,
design_styles: [...prev.design_styles, newStyle.trim()]
}));
setNewStyle('');
}
};
// 移除设计风格
const removeDesignStyle = (style: string) => {
setFormData(prev => ({
...prev,
design_styles: prev.design_styles.filter(s => s !== style)
}));
};
// 添加标签
const addTag = () => {
if (newTag.trim() && !formData.tags.includes(newTag.trim())) {
setFormData(prev => ({
...prev,
tags: [...prev.tags, newTag.trim()]
}));
setNewTag('');
}
};
// 移除标签
const removeTag = (tag: string) => {
setFormData(prev => ({
...prev,
tags: prev.tags.filter(t => t !== tag)
}));
};
if (!isOpen) return null;
return (
<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-2xl sm:w-full">
<form onSubmit={handleSubmit}>
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">
{item ? '编辑服装单品' : '添加服装单品'}
</h3>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<div className="space-y-4">
{/* 基本信息 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
*
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="请输入服装名称"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
*
</label>
<select
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
required
>
<option value=""></option>
{categoryOptions.map(category => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="请输入服装描述"
/>
</div>
{/* 详细信息 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
value={formData.brand}
onChange={(e) => setFormData(prev => ({ ...prev, brand: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="请输入品牌"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={formData.size}
onChange={(e) => setFormData(prev => ({ ...prev, size: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value=""></option>
{sizeOptions.map(size => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
(¥)
</label>
<input
type="number"
step="0.01"
min="0"
value={formData.price}
onChange={(e) => setFormData(prev => ({ ...prev, price: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="0.00"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="date"
value={formData.purchase_date}
onChange={(e) => setFormData(prev => ({ ...prev, purchase_date: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* 设计风格 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newStyle}
onChange={(e) => setNewStyle(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addDesignStyle())}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="输入设计风格,按回车添加"
/>
<button
type="button"
onClick={addDesignStyle}
className="px-3 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700"
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
<div className="flex flex-wrap gap-2">
{formData.design_styles.map((style, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 text-sm bg-blue-100 text-blue-800 rounded-full"
>
{style}
<button
type="button"
onClick={() => removeDesignStyle(style)}
className="ml-1 text-blue-600 hover:text-blue-800"
>
<XCircleIcon className="h-3 w-3" />
</button>
</span>
))}
</div>
</div>
{/* 标签 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="输入标签,按回车添加"
/>
<button
type="button"
onClick={addTag}
className="px-3 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700"
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 text-sm bg-gray-100 text-gray-800 rounded-full"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-1 text-gray-600 hover:text-gray-800"
>
<XCircleIcon className="h-3 w-3" />
</button>
</span>
))}
</div>
</div>
{/* 备注 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="请输入备注信息"
/>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="submit"
disabled={loading}
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<div className="animate-spin -ml-1 mr-2 h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div>
...
</>
) : (
item ? '更新' : '创建'
)}
</button>
<button
type="button"
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default OutfitItemForm;

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;

View File

@@ -0,0 +1,604 @@
import React, { useState, useEffect } from 'react';
import {
SparklesIcon,
HeartIcon,
ShareIcon,
BookmarkIcon,
EyeIcon,
PlusIcon,
ArrowPathIcon,
AdjustmentsHorizontalIcon
} from '@heroicons/react/24/outline';
import { HeartIcon as HeartSolidIcon, BookmarkIcon as BookmarkSolidIcon } from '@heroicons/react/24/solid';
import { invoke } from '@tauri-apps/api/core';
import { useNotifications } from '../NotificationSystem';
interface OutfitItem {
id: string;
name: string;
category: string;
brand?: string;
image_urls: string[];
color_primary: any;
styles: string[];
tags: string[];
}
interface OutfitRecommendation {
id: string;
items: OutfitItem[];
score: number;
style_description: string;
occasion_tags: string[];
season_tags: string[];
color_harmony_score: number;
style_consistency_score: number;
created_at: string;
}
interface OutfitMatchingRecommendationProps {
projectId: string;
onSaveMatching?: (recommendation: OutfitRecommendation) => void;
}
const OutfitMatchingRecommendation: React.FC<OutfitMatchingRecommendationProps> = ({
projectId,
onSaveMatching
}) => {
const [recommendations, setRecommendations] = useState<OutfitRecommendation[]>([]);
const [loading, setLoading] = useState(false);
const [selectedRecommendation, setSelectedRecommendation] = useState<OutfitRecommendation | null>(null);
const [favorites, setFavorites] = useState<Set<string>>(new Set());
const [savedItems, setSavedItems] = useState<Set<string>>(new Set());
const [filters, setFilters] = useState({
occasion: '',
season: '',
style: '',
minScore: 0.7
});
const [showFilters, setShowFilters] = useState(false);
const { addNotification } = useNotifications();
// 检查服装单品数据
const checkItemsData = async () => {
try {
const stats = await invoke('debug_outfit_items_stats', {
projectId: projectId
});
console.log('📊 服装单品统计:', stats);
addNotification({
type: 'info',
title: '数据检查完成',
message: '请查看控制台输出'
});
} catch (error) {
console.error('检查数据失败:', error);
}
};
// 生成搭配推荐
const generateRecommendations = async () => {
try {
setLoading(true);
// 先检查数据
await checkItemsData();
const request = {
project_id: projectId,
max_recommendations: 12,
min_score_threshold: filters.minScore,
occasion_filter: filters.occasion || null,
season_filter: filters.season || null,
style_filter: filters.style || null
};
const result = await invoke('generate_outfit_recommendations', { request });
setRecommendations(result as OutfitRecommendation[]);
if ((result as OutfitRecommendation[]).length === 0) {
addNotification({
type: 'info',
title: '暂无推荐',
message: '请先添加更多服装单品,或调整筛选条件'
});
} else {
addNotification({
type: 'success',
title: '推荐生成成功',
message: `为您生成了 ${(result as OutfitRecommendation[]).length} 个搭配推荐`
});
}
} catch (error) {
console.error('生成搭配推荐失败:', error);
addNotification({
type: 'error',
title: '生成失败',
message: error instanceof Error ? error.message : '生成搭配推荐失败'
});
} finally {
setLoading(false);
}
};
// 保存搭配
const saveMatching = async (recommendation: OutfitRecommendation) => {
try {
const request = {
project_id: projectId,
matching_name: `AI推荐搭配 - ${recommendation.style_description}`,
matching_type: 'AI_Generated',
item_ids: recommendation.items.map(item => item.id),
occasion_tags: recommendation.occasion_tags,
season_tags: recommendation.season_tags,
style_description: recommendation.style_description
};
await invoke('create_outfit_matching', { request });
setSavedItems(prev => new Set([...prev, recommendation.id]));
addNotification({
type: 'success',
title: '保存成功',
message: '搭配已保存到我的搭配'
});
if (onSaveMatching) {
onSaveMatching(recommendation);
}
} catch (error) {
console.error('保存搭配失败:', error);
addNotification({
type: 'error',
title: '保存失败',
message: error instanceof Error ? error.message : '保存搭配失败'
});
}
};
// 切换收藏状态
const toggleFavorite = (recommendationId: string) => {
setFavorites(prev => {
const newFavorites = new Set(prev);
if (newFavorites.has(recommendationId)) {
newFavorites.delete(recommendationId);
} else {
newFavorites.add(recommendationId);
}
return newFavorites;
});
};
// 获取评分颜色
const getScoreColor = (score: number) => {
if (score >= 0.9) return 'text-green-600 bg-green-100';
if (score >= 0.8) return 'text-blue-600 bg-blue-100';
if (score >= 0.7) return 'text-yellow-600 bg-yellow-100';
return 'text-gray-600 bg-gray-100';
};
// 格式化评分
const formatScore = (score: number) => {
return Math.round(score * 100);
};
// 过滤推荐
const filteredRecommendations = recommendations.filter(rec => {
if (filters.occasion && !rec.occasion_tags.includes(filters.occasion)) return false;
if (filters.season && !rec.season_tags.includes(filters.season)) return false;
if (filters.style && !rec.style_description.toLowerCase().includes(filters.style.toLowerCase())) return false;
return rec.score >= filters.minScore;
});
useEffect(() => {
if (projectId) {
generateRecommendations();
}
}, [projectId]);
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 items-center gap-4">
<button
onClick={generateRecommendations}
disabled={loading}
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<ArrowPathIcon className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : (
<>
<SparklesIcon className="h-4 w-4 mr-2" />
</>
)}
</button>
<button
onClick={() => setShowFilters(!showFilters)}
className="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<AdjustmentsHorizontalIcon className="h-4 w-4 mr-2" />
</button>
<button
onClick={checkItemsData}
className="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
🔍
</button>
</div>
<div className="text-sm text-gray-600">
{filteredRecommendations.length}
</div>
</div>
{/* 筛选面板 */}
{showFilters && (
<div className="bg-gray-50 rounded-lg p-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={filters.occasion}
onChange={(e) => setFilters(prev => ({ ...prev, occasion: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value=""></option>
<option value="工作"></option>
<option value="休闲"></option>
<option value="正式"></option>
<option value="运动"></option>
<option value="约会"></option>
<option value="聚会"></option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={filters.season}
onChange={(e) => setFilters(prev => ({ ...prev, season: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value=""></option>
<option value="春季"></option>
<option value="夏季"></option>
<option value="秋季"></option>
<option value="冬季"></option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
value={filters.style}
onChange={(e) => setFilters(prev => ({ ...prev, style: e.target.value }))}
placeholder="输入风格关键词"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={filters.minScore}
onChange={(e) => setFilters(prev => ({ ...prev, minScore: parseFloat(e.target.value) }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value={0.5}>50</option>
<option value={0.6}>60</option>
<option value={0.7}>70</option>
<option value={0.8}>80</option>
<option value={0.9}>90</option>
</select>
</div>
</div>
<div className="flex justify-end">
<button
onClick={generateRecommendations}
className="px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-md hover:bg-primary-700"
>
</button>
</div>
</div>
)}
{/* 推荐列表 */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
<p className="text-gray-600">AI正在为您生成搭配推荐...</p>
</div>
</div>
) : filteredRecommendations.length === 0 ? (
<div className="text-center py-12">
<SparklesIcon className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-gray-500 mb-4">
</p>
<button
onClick={generateRecommendations}
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"
>
<SparklesIcon className="h-4 w-4 mr-2" />
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredRecommendations.map((recommendation) => (
<div
key={recommendation.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 p-4">
<div className="grid grid-cols-2 gap-2 h-full">
{recommendation.items.slice(0, 4).map((item, index) => (
<div
key={item.id}
className="bg-white rounded-lg border border-gray-200 overflow-hidden"
>
{item.image_urls.length > 0 ? (
<img
src={`file://${item.image_urls[0]}`}
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 bg-gray-50">
<span className="text-xs text-gray-400 text-center p-1">
{item.name}
</span>
</div>
)}
</div>
))}
</div>
{/* 操作按钮 */}
<div className="absolute top-2 right-2 flex space-x-1">
<button
onClick={() => toggleFavorite(recommendation.id)}
className="p-1.5 bg-white rounded-full shadow-md hover:shadow-lg transition-shadow"
>
{favorites.has(recommendation.id) ? (
<HeartSolidIcon className="w-3 h-3 text-red-500" />
) : (
<HeartIcon className="w-3 h-3 text-gray-600" />
)}
</button>
<button
onClick={() => setSelectedRecommendation(recommendation)}
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 className="absolute bottom-2 left-2">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getScoreColor(recommendation.score)}`}>
{formatScore(recommendation.score)}
</span>
</div>
</div>
{/* 搭配信息 */}
<div className="p-4">
<h4 className="font-medium text-gray-900 mb-2 line-clamp-1">
{recommendation.style_description}
</h4>
<div className="space-y-2 mb-3">
{recommendation.occasion_tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{recommendation.occasion_tags.slice(0, 2).map((tag, index) => (
<span
key={index}
className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full"
>
{tag}
</span>
))}
</div>
)}
<div className="text-xs text-gray-500">
{recommendation.items.length}
</div>
</div>
{/* 操作按钮 */}
<div className="flex space-x-2">
<button
onClick={() => saveMatching(recommendation)}
disabled={savedItems.has(recommendation.id)}
className="flex-1 px-3 py-2 text-sm font-medium rounded-md border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{savedItems.has(recommendation.id) ? (
<>
<BookmarkSolidIcon className="w-4 h-4 inline mr-1" />
</>
) : (
<>
<BookmarkIcon className="w-4 h-4 inline mr-1" />
</>
)}
</button>
<button
onClick={() => setSelectedRecommendation(recommendation)}
className="px-3 py-2 text-sm font-medium text-primary-700 bg-primary-100 rounded-md hover:bg-primary-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* 详情模态框 */}
{selectedRecommendation && (
<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-2xl 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-4">
<div>
<span className="font-medium text-gray-700">:</span>
<p className="mt-1 text-gray-600">{selectedRecommendation.style_description}</p>
</div>
<div>
<span className="font-medium text-gray-700">:</span>
<span className={`ml-2 px-2 py-1 text-sm font-medium rounded-full ${getScoreColor(selectedRecommendation.score)}`}>
{formatScore(selectedRecommendation.score)}
</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">
{formatScore(selectedRecommendation.color_harmony_score)}
</span>
</div>
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">
{formatScore(selectedRecommendation.style_consistency_score)}
</span>
</div>
</div>
{selectedRecommendation.occasion_tags.length > 0 && (
<div>
<span className="font-medium text-gray-700">:</span>
<div className="mt-1 flex flex-wrap gap-1">
{selectedRecommendation.occasion_tags.map((tag, index) => (
<span
key={index}
className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
{selectedRecommendation.season_tags.length > 0 && (
<div>
<span className="font-medium text-gray-700">:</span>
<div className="mt-1 flex flex-wrap gap-1">
{selectedRecommendation.season_tags.map((tag, index) => (
<span
key={index}
className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
<div>
<span className="font-medium text-gray-700">:</span>
<div className="mt-2 grid grid-cols-2 gap-2">
{selectedRecommendation.items.map((item) => (
<div
key={item.id}
className="flex items-center space-x-2 p-2 bg-gray-50 rounded-md"
>
{item.image_urls.length > 0 ? (
<img
src={`file://${item.image_urls[0]}`}
alt={item.name}
className="w-8 h-8 object-cover rounded"
/>
) : (
<div className="w-8 h-8 bg-gray-200 rounded flex items-center justify-center">
<span className="text-xs text-gray-400"></span>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{item.name}
</p>
<p className="text-xs text-gray-500">
{item.category}
</p>
</div>
</div>
))}
</div>
</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={() => saveMatching(selectedRecommendation)}
disabled={savedItems.has(selectedRecommendation.id)}
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{savedItems.has(selectedRecommendation.id) ? '已保存' : '保存搭配'}
</button>
<button
type="button"
onClick={() => setSelectedRecommendation(null)}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default OutfitMatchingRecommendation;

View File

@@ -13,15 +13,41 @@ import '../styles/animations.css';
// 导入组件
import ImageUploader from '../components/outfit/ImageUploader';
import OutfitAnalysisResult from '../components/outfit/OutfitAnalysisResult';
import OutfitItemList from '../components/outfit/OutfitItemList';
import OutfitItemForm from '../components/outfit/OutfitItemForm';
import OutfitMatchingRecommendation from '../components/outfit/OutfitMatchingRecommendation';
import { useNotifications } from '../components/NotificationSystem';
interface OutfitMatchProps {
projectId?: string;
}
// 定义OutfitItem接口
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;
}
const OutfitMatch: React.FC<OutfitMatchProps> = ({ projectId }) => {
const [activeTab, setActiveTab] = useState<'upload' | 'analysis' | 'items' | 'matching'>('upload');
const [isUploading, setIsUploading] = useState(false);
const [showItemForm, setShowItemForm] = useState(false);
const [editingItem, setEditingItem] = useState<OutfitItem | null>(null);
const { addNotification } = useNotifications();
// 处理图像上传
@@ -95,6 +121,29 @@ const OutfitMatch: React.FC<OutfitMatchProps> = ({ projectId }) => {
}
};
// 处理创建服装单品
const handleCreateItem = () => {
setEditingItem(null);
setShowItemForm(true);
};
// 处理编辑服装单品
const handleEditItem = (item: OutfitItem) => {
setEditingItem(item);
setShowItemForm(true);
};
// 处理表单关闭
const handleFormClose = () => {
setShowItemForm(false);
setEditingItem(null);
};
// 处理表单保存
const handleFormSave = () => {
// 表单保存后刷新列表这个会在OutfitItemList组件中自动处理
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@@ -209,11 +258,13 @@ const OutfitMatch: React.FC<OutfitMatchProps> = ({ projectId }) => {
</p>
</div>
<div className="bg-gray-50 rounded-lg p-8 text-center">
<PlusIcon className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<p className="text-gray-500"></p>
<p className="text-sm text-gray-400 mt-2"></p>
</div>
{projectId && (
<OutfitItemList
projectId={projectId}
onCreateItem={handleCreateItem}
onEditItem={handleEditItem}
/>
)}
</div>
)}
@@ -229,11 +280,19 @@ const OutfitMatch: React.FC<OutfitMatchProps> = ({ projectId }) => {
</p>
</div>
<div className="bg-gray-50 rounded-lg p-8 text-center">
<SparklesIcon className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<p className="text-gray-500"></p>
<p className="text-sm text-gray-400 mt-2"></p>
</div>
{projectId && (
<OutfitMatchingRecommendation
projectId={projectId}
onSaveMatching={(recommendation) => {
console.log('保存搭配推荐:', recommendation);
addNotification({
type: 'success',
title: '搭配已保存',
message: '您可以在我的搭配中查看保存的搭配'
});
}}
/>
)}
</div>
)}
</div>
@@ -331,6 +390,17 @@ const OutfitMatch: React.FC<OutfitMatchProps> = ({ projectId }) => {
</div>
</div>
</div>
{/* 服装单品表单 */}
{projectId && (
<OutfitItemForm
projectId={projectId}
item={editingItem || undefined}
isOpen={showItemForm}
onClose={handleFormClose}
onSave={handleFormSave}
/>
)}
</div>
);
};