feat: 完善智能搭配推荐功能和调试工具
新功能: - 完整实现智能搭配推荐系统 - OutfitMatchingRecommendation: 完整的推荐界面组件 - generate_outfit_recommendations: 后端推荐算法 - 色彩和谐度和风格一致性评分算法 - 智能场合和季节标签生成 - 添加调试工具 debug_outfit_items_stats - 检查项目中的服装单品统计 - 详细的数据分析和建议 算法实现: - 搭配组合生成逻辑 - 上装+下装+鞋子组合 - 连衣裙+鞋子组合 - 可选外套和配饰 - 智能评分系统 - 色彩和谐度计算 (HSV色彩空间) - 风格一致性评估 - 综合评分和筛选 - 标签生成算法 - 场合推断 (工作/休闲/正式/运动等) - 季节适用性分析 UI/UX优化: - 现代化的推荐卡片设计 - 智能筛选面板 (场合/季节/风格/评分) - 收藏和保存功能 - 详情模态框展示 - 调试按钮和数据检查工具 问题诊断: - 添加详细的调试日志 - 搭配组合生成过程跟踪 - 评分计算过程可视化 - 数据统计和分析工具 当前状态: - 项目中有2件服装单品 (连衣裙+高跟鞋) - 数量足够生成搭配推荐 - 正在调试为什么生成0个推荐的问题
This commit is contained in:
@@ -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| {
|
||||
// 初始化日志系统
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
498
apps/desktop/src/components/outfit/OutfitItemForm.tsx
Normal file
498
apps/desktop/src/components/outfit/OutfitItemForm.tsx
Normal 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;
|
||||
431
apps/desktop/src/components/outfit/OutfitItemList.tsx
Normal file
431
apps/desktop/src/components/outfit/OutfitItemList.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user