fix: 移除错误代码

This commit is contained in:
imeepos
2025-07-17 20:59:45 +08:00
parent e34701bb54
commit 121f2ebc5d
24 changed files with 1 additions and 7623 deletions

View File

@@ -22,9 +22,6 @@ pub mod template_matching_result_service;
pub mod export_record_service;
pub mod video_generation_service;
pub mod jianying_export;
pub mod outfit_analysis_service;
pub mod outfit_item_service;
pub mod outfit_matching_service;
#[cfg(test)]
pub mod tests;

View File

@@ -1,314 +0,0 @@
use crate::data::models::outfit_analysis::{
OutfitAnalysis, CreateOutfitAnalysisRequest, UpdateOutfitAnalysisRequest,
OutfitAnalysisQueryOptions, OutfitAnalysisStats, AnalysisStatus, ImageAnalysisResult,
OutfitProduct, ColorHSV
};
use crate::data::repositories::outfit_analysis_repository::OutfitAnalysisRepository;
use crate::business::errors::BusinessError;
use crate::infrastructure::gemini_service::GeminiService;
use anyhow::Result;
use std::sync::Arc;
use std::path::Path;
use tokio::fs;
use base64::{Engine as _, engine::general_purpose};
/// 服装分析业务服务
/// 遵循 Tauri 开发规范的业务逻辑层设计原则
pub struct OutfitAnalysisService {
repository: Arc<OutfitAnalysisRepository>,
gemini_service: Arc<GeminiService>,
}
impl OutfitAnalysisService {
/// 创建新的服装分析服务实例
pub fn new(repository: Arc<OutfitAnalysisRepository>, gemini_service: Arc<GeminiService>) -> Self {
Self { repository, gemini_service }
}
/// 创建服装分析记录
pub async fn create_analysis(&self, request: CreateOutfitAnalysisRequest) -> Result<OutfitAnalysis> {
// 验证输入数据
self.validate_create_request(&request)?;
// 检查图像文件是否存在
if !Path::new(&request.image_path).exists() {
return Err(BusinessError::InvalidInput(format!("图像文件不存在: {}", request.image_path)).into());
}
// 创建分析记录
let analysis = self.repository.create(&request)?;
Ok(analysis)
}
/// 开始分析图像
pub async fn start_analysis(&self, analysis_id: &str) -> Result<()> {
// 获取分析记录
let analysis = self.repository.get_by_id(analysis_id)?
.ok_or_else(|| BusinessError::NotFound(format!("分析记录不存在: {}", analysis_id)))?;
// 检查状态
if analysis.analysis_status != AnalysisStatus::Pending {
return Err(BusinessError::InvalidState(format!("分析记录状态不正确: {:?}", analysis.analysis_status)).into());
}
// 更新状态为处理中
self.repository.update(analysis_id, &UpdateOutfitAnalysisRequest {
analysis_status: Some(AnalysisStatus::Processing),
analysis_result: None,
error_message: None,
})?;
// 异步执行分析
let repository = Arc::clone(&self.repository);
let gemini_service = Arc::clone(&self.gemini_service);
let analysis_clone = analysis.clone();
tokio::spawn(async move {
let mut gemini_service_mut = (*gemini_service).clone();
let result = Self::perform_analysis(&mut gemini_service_mut, &analysis_clone).await;
match result {
Ok(analysis_result) => {
let _ = repository.update(&analysis_clone.id, &UpdateOutfitAnalysisRequest {
analysis_status: Some(AnalysisStatus::Completed),
analysis_result: Some(analysis_result),
error_message: None,
});
}
Err(error) => {
let _ = repository.update(&analysis_clone.id, &UpdateOutfitAnalysisRequest {
analysis_status: Some(AnalysisStatus::Failed),
analysis_result: None,
error_message: Some(error.to_string()),
});
}
}
});
Ok(())
}
/// 执行图像分析
async fn perform_analysis(gemini_service: &mut GeminiService, analysis: &OutfitAnalysis) -> Result<ImageAnalysisResult> {
// 读取图像文件
let image_data = fs::read(&analysis.image_path).await?;
let image_base64 = general_purpose::STANDARD.encode(&image_data);
// 构建分析提示词
let prompt = Self::build_analysis_prompt();
// 调用Gemini API进行分析
let response = gemini_service.analyze_image_with_prompt(&image_base64, &prompt).await?;
// 解析响应
let analysis_result = Self::parse_analysis_response(&response)?;
Ok(analysis_result)
}
/// 构建分析提示词
fn build_analysis_prompt() -> String {
r#"
你是一个专业的服装搭配分析师。请分析这张图片中的服装搭配并按照以下JSON格式返回结果
{
"environment_tags": ["环境标签1", "环境标签2"],
"environment_color_pattern": {"hue": 0.5, "saturation": 0.3, "value": 0.8},
"dress_color_pattern": {"hue": 0.6, "saturation": 0.4, "value": 0.7},
"style_description": "整体风格描述",
"products": [
{
"category": "服装类别",
"description": "服装描述",
"color_pattern": {"hue": 0.4, "saturation": 0.5, "value": 0.6},
"design_styles": ["设计风格1", "设计风格2"],
"color_pattern_match_dress": 0.8,
"color_pattern_match_environment": 0.7
}
]
}
注意:
1. HSV颜色值范围都是0.0-1.0
2. 匹配度范围是0.0-1.0
3. 环境标签用英文
4. 设计风格标签用英文
5. 类别和描述用中文
"#.to_string()
}
/// 解析分析响应
fn parse_analysis_response(response: &str) -> Result<ImageAnalysisResult> {
// 尝试从响应中提取JSON
let json_start = response.find('{').unwrap_or(0);
let json_end = response.rfind('}').map(|i| i + 1).unwrap_or(response.len());
let json_str = &response[json_start..json_end];
// 解析JSON
let parsed: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| BusinessError::InvalidInput(format!("解析分析结果失败: {}", e)))?;
// 构建分析结果
let environment_tags = parsed["environment_tags"]
.as_array()
.unwrap_or(&Vec::new())
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
let environment_color = Self::parse_color_hsv(&parsed["environment_color_pattern"])?;
let dress_color = Self::parse_color_hsv(&parsed["dress_color_pattern"])?;
let style_description = parsed["style_description"]
.as_str()
.unwrap_or("未知风格")
.to_string();
let products = parsed["products"]
.as_array()
.unwrap_or(&Vec::new())
.iter()
.filter_map(|product| Self::parse_outfit_product(product).ok())
.collect();
Ok(ImageAnalysisResult {
environment_tags,
environment_color_pattern: environment_color,
dress_color_pattern: dress_color,
style_description,
products,
})
}
/// 解析颜色HSV值
fn parse_color_hsv(color_value: &serde_json::Value) -> Result<ColorHSV> {
let hue = color_value["hue"].as_f64().unwrap_or(0.0);
let saturation = color_value["saturation"].as_f64().unwrap_or(0.0);
let value = color_value["value"].as_f64().unwrap_or(0.0);
Ok(ColorHSV::new(hue, saturation, value))
}
/// 解析服装产品信息
fn parse_outfit_product(product_value: &serde_json::Value) -> Result<OutfitProduct> {
let category = product_value["category"]
.as_str()
.unwrap_or("未知类别")
.to_string();
let description = product_value["description"]
.as_str()
.unwrap_or("无描述")
.to_string();
let color_pattern = Self::parse_color_hsv(&product_value["color_pattern"])?;
let design_styles = product_value["design_styles"]
.as_array()
.unwrap_or(&Vec::new())
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
let color_pattern_match_dress = product_value["color_pattern_match_dress"]
.as_f64()
.unwrap_or(0.0);
let color_pattern_match_environment = product_value["color_pattern_match_environment"]
.as_f64()
.unwrap_or(0.0);
Ok(OutfitProduct {
category,
description,
color_pattern,
design_styles,
color_pattern_match_dress,
color_pattern_match_environment,
})
}
/// 获取分析记录列表
pub async fn get_analyses(&self, options: OutfitAnalysisQueryOptions) -> Result<Vec<OutfitAnalysis>> {
Ok(self.repository.list(&options)?)
}
/// 根据ID获取分析记录
pub async fn get_analysis_by_id(&self, id: &str) -> Result<Option<OutfitAnalysis>> {
if id.trim().is_empty() {
return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into());
}
Ok(self.repository.get_by_id(id)?)
}
/// 删除分析记录
pub async fn delete_analysis(&self, id: &str) -> Result<()> {
if id.trim().is_empty() {
return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into());
}
self.repository.delete(id)?;
Ok(())
}
/// 获取分析统计信息
pub async fn get_analysis_stats(&self, project_id: Option<&str>) -> Result<OutfitAnalysisStats> {
Ok(self.repository.get_stats(project_id)?)
}
/// 验证创建请求
fn validate_create_request(&self, request: &CreateOutfitAnalysisRequest) -> Result<()> {
if request.project_id.trim().is_empty() {
return Err(BusinessError::InvalidInput("项目ID不能为空".to_string()).into());
}
if request.image_path.trim().is_empty() {
return Err(BusinessError::InvalidInput("图像路径不能为空".to_string()).into());
}
if request.image_name.trim().is_empty() {
return Err(BusinessError::InvalidInput("图像名称不能为空".to_string()).into());
}
// 检查文件扩展名
let path = Path::new(&request.image_path);
if let Some(extension) = path.extension() {
let ext = extension.to_string_lossy().to_lowercase();
if !matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp") {
return Err(BusinessError::InvalidInput(format!("不支持的图像格式: {}", ext)).into());
}
} else {
return Err(BusinessError::InvalidInput("无法识别图像文件格式".to_string()).into());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_color_hsv() {
let color_json = serde_json::json!({
"hue": 0.5,
"saturation": 0.8,
"value": 0.9
});
let color = OutfitAnalysisService::parse_color_hsv(&color_json).unwrap();
assert_eq!(color.hue, 0.5);
assert_eq!(color.saturation, 0.8);
assert_eq!(color.value, 0.9);
}
#[test]
fn test_build_analysis_prompt() {
let prompt = OutfitAnalysisService::build_analysis_prompt();
assert!(prompt.contains("服装搭配分析师"));
assert!(prompt.contains("JSON格式"));
}
}

View File

@@ -1,516 +0,0 @@
use crate::data::models::outfit_item::{
OutfitItem, CreateOutfitItemRequest, UpdateOutfitItemRequest,
OutfitItemQueryOptions, OutfitItemStats, OutfitCategory, OutfitStyle
};
use crate::data::models::outfit_analysis::ColorHSV;
use crate::data::repositories::outfit_item_repository::OutfitItemRepository;
use crate::business::errors::BusinessError;
use anyhow::Result;
use std::sync::Arc;
/// 服装单品业务服务
/// 遵循 Tauri 开发规范的业务逻辑层设计原则
pub struct OutfitItemService {
repository: Arc<OutfitItemRepository>,
}
impl OutfitItemService {
/// 创建新的服装单品服务实例
pub fn new(repository: Arc<OutfitItemRepository>) -> Self {
Self { repository }
}
/// 创建服装单品
pub async fn create_item(&self, request: CreateOutfitItemRequest) -> Result<OutfitItem> {
// 验证输入数据
self.validate_create_request(&request)?;
// 创建服装单品
let item = self.repository.create(&request)?;
Ok(item)
}
/// 批量创建服装单品(从分析结果)
pub async fn create_items_from_analysis(&self,
project_id: &str,
analysis_id: &str,
products: &[crate::data::models::outfit_analysis::OutfitProduct]
) -> Result<Vec<OutfitItem>> {
let mut created_items = Vec::new();
for (index, product) in products.iter().enumerate() {
let request = CreateOutfitItemRequest {
project_id: project_id.to_string(),
analysis_id: Some(analysis_id.to_string()),
name: format!("{} #{}", product.category, index + 1),
category: OutfitCategory::from_string(&product.category),
brand: None,
model: None,
color_primary: product.color_pattern.clone(),
color_secondary: None,
styles: product.design_styles.iter()
.map(|s| OutfitStyle::from_string(s))
.collect(),
design_elements: product.design_styles.clone(),
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: Vec::new(),
tags: vec![product.category.clone()],
notes: Some(product.description.clone()),
};
match self.repository.create(&request) {
Ok(item) => created_items.push(item),
Err(e) => {
eprintln!("创建服装单品失败: {}", e);
// 继续处理其他单品,不中断整个流程
}
}
}
Ok(created_items)
}
/// 获取服装单品列表
pub async fn get_items(&self, options: OutfitItemQueryOptions) -> Result<Vec<OutfitItem>> {
Ok(self.repository.list(&options)?)
}
/// 根据ID获取服装单品
pub async fn get_item_by_id(&self, id: &str) -> Result<Option<OutfitItem>> {
if id.trim().is_empty() {
return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into());
}
Ok(self.repository.get_by_id(id)?)
}
/// 更新服装单品
pub async fn update_item(&self, id: &str, request: UpdateOutfitItemRequest) -> Result<()> {
if id.trim().is_empty() {
return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into());
}
// 验证更新请求
self.validate_update_request(&request)?;
// 检查单品是否存在
if self.repository.get_by_id(id)?.is_none() {
return Err(BusinessError::NotFound(format!("服装单品不存在: {}", id)).into());
}
self.repository.update(id, &request)?;
Ok(())
}
/// 删除服装单品
pub async fn delete_item(&self, id: &str) -> Result<()> {
if id.trim().is_empty() {
return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into());
}
// 检查单品是否存在
if self.repository.get_by_id(id)?.is_none() {
return Err(BusinessError::NotFound(format!("服装单品不存在: {}", id)).into());
}
self.repository.delete(id)?;
Ok(())
}
/// 根据颜色搜索服装单品
pub async fn search_by_color(&self,
project_id: &str,
target_color: &ColorHSV,
threshold: f64
) -> Result<Vec<OutfitItem>> {
if project_id.trim().is_empty() {
return Err(BusinessError::InvalidInput("项目ID不能为空".to_string()).into());
}
if threshold < 0.0 || threshold > 1.0 {
return Err(BusinessError::InvalidInput("相似度阈值必须在0.0-1.0之间".to_string()).into());
}
Ok(self.repository.search_by_color(project_id, target_color, threshold)?)
}
/// 根据类别搜索服装单品
pub async fn search_by_category(&self,
project_id: &str,
category: &OutfitCategory
) -> Result<Vec<OutfitItem>> {
if project_id.trim().is_empty() {
return Err(BusinessError::InvalidInput("项目ID不能为空".to_string()).into());
}
let options = OutfitItemQueryOptions {
project_id: Some(project_id.to_string()),
category: Some(category.clone()),
styles: None,
color_similarity_threshold: None,
target_color: None,
brand: None,
tags: None,
limit: None,
offset: None,
};
Ok(self.repository.list(&options)?)
}
/// 根据风格搜索服装单品
pub async fn search_by_styles(&self,
project_id: &str,
styles: &[OutfitStyle]
) -> Result<Vec<OutfitItem>> {
if project_id.trim().is_empty() {
return Err(BusinessError::InvalidInput("项目ID不能为空".to_string()).into());
}
if styles.is_empty() {
return Err(BusinessError::InvalidInput("风格列表不能为空".to_string()).into());
}
let options = OutfitItemQueryOptions {
project_id: Some(project_id.to_string()),
category: None,
styles: Some(styles.to_vec()),
color_similarity_threshold: None,
target_color: None,
brand: None,
tags: None,
limit: None,
offset: None,
};
Ok(self.repository.list(&options)?)
}
/// 智能推荐搭配单品
pub async fn recommend_matching_items(&self,
project_id: &str,
base_item_id: &str,
max_results: Option<u32>
) -> Result<Vec<OutfitItem>> {
// 获取基础单品
let base_item = self.repository.get_by_id(base_item_id)?
.ok_or_else(|| BusinessError::NotFound(format!("基础单品不存在: {}", base_item_id)))?;
// 获取项目中的所有单品
let all_items = self.repository.list(&OutfitItemQueryOptions {
project_id: Some(project_id.to_string()),
category: None,
styles: None,
color_similarity_threshold: None,
target_color: None,
brand: None,
tags: None,
limit: None,
offset: None,
})?;
// 过滤掉基础单品本身
let candidate_items: Vec<OutfitItem> = all_items.into_iter()
.filter(|item| item.id != base_item_id)
.collect();
// 计算匹配度并排序
let mut scored_items: Vec<(OutfitItem, f64)> = candidate_items.into_iter()
.map(|item| {
let score = self.calculate_matching_score(&base_item, &item);
(item, score)
})
.collect();
// 按匹配度降序排序
scored_items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
// 限制结果数量
let limit = max_results.unwrap_or(10) as usize;
let recommended_items: Vec<OutfitItem> = scored_items.into_iter()
.take(limit)
.map(|(item, _)| item)
.collect();
Ok(recommended_items)
}
/// 计算两个单品的匹配度
fn calculate_matching_score(&self, item1: &OutfitItem, item2: &OutfitItem) -> f64 {
let mut score = 0.0;
// 颜色匹配度 (权重: 0.4)
let color_similarity = item1.color_primary.similarity(&item2.color_primary);
score += color_similarity * 0.4;
// 风格匹配度 (权重: 0.3)
let style_similarity = self.calculate_style_similarity(&item1.styles, &item2.styles);
score += style_similarity * 0.3;
// 类别互补性 (权重: 0.3)
let category_compatibility = self.calculate_category_compatibility(&item1.category, &item2.category);
score += category_compatibility * 0.3;
score.clamp(0.0, 1.0)
}
/// 计算风格相似度
fn calculate_style_similarity(&self, styles1: &[OutfitStyle], styles2: &[OutfitStyle]) -> f64 {
if styles1.is_empty() || styles2.is_empty() {
return 0.5; // 默认中等相似度
}
let common_styles = styles1.iter()
.filter(|style1| styles2.contains(style1))
.count();
let total_unique_styles = styles1.len() + styles2.len() - common_styles;
if total_unique_styles == 0 {
1.0
} else {
common_styles as f64 / total_unique_styles as f64
}
}
/// 计算类别兼容性
fn calculate_category_compatibility(&self, category1: &OutfitCategory, category2: &OutfitCategory) -> f64 {
match (category1, category2) {
// 相同类别,兼容性较低(避免重复)
(a, b) if a == b => 0.2,
// 上装与下装,兼容性高
(OutfitCategory::Top, OutfitCategory::Bottom) |
(OutfitCategory::Bottom, OutfitCategory::Top) => 0.9,
// 连衣裙与配饰,兼容性高
(OutfitCategory::Dress, OutfitCategory::Accessory) |
(OutfitCategory::Accessory, OutfitCategory::Dress) => 0.8,
// 外套与其他类别,兼容性中等
(OutfitCategory::Outerwear, _) |
(_, OutfitCategory::Outerwear) => 0.7,
// 鞋类与其他类别,兼容性中等
(OutfitCategory::Footwear, _) |
(_, OutfitCategory::Footwear) => 0.6,
// 配饰与其他类别,兼容性中等
(OutfitCategory::Accessory, _) |
(_, OutfitCategory::Accessory) => 0.6,
// 其他组合,默认兼容性
_ => 0.5,
}
}
/// 获取服装单品统计信息
pub async fn get_item_stats(&self, project_id: Option<&str>) -> Result<OutfitItemStats> {
Ok(self.repository.get_stats(project_id)?)
}
/// 验证创建请求
fn validate_create_request(&self, request: &CreateOutfitItemRequest) -> Result<()> {
if request.project_id.trim().is_empty() {
return Err(BusinessError::InvalidInput("项目ID不能为空".to_string()).into());
}
if request.name.trim().is_empty() {
return Err(BusinessError::InvalidInput("服装名称不能为空".to_string()).into());
}
Ok(())
}
/// 验证更新请求
fn validate_update_request(&self, request: &UpdateOutfitItemRequest) -> Result<()> {
if let Some(name) = &request.name {
if name.trim().is_empty() {
return Err(BusinessError::InvalidInput("服装名称不能为空".to_string()).into());
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::models::outfit_analysis::ColorHSV;
#[test]
fn test_calculate_style_similarity() {
let service = OutfitItemService::new(Arc::new(
// Mock repository - in real tests, use a proper mock
unsafe { std::mem::zeroed() }
));
let styles1 = vec![OutfitStyle::Casual, OutfitStyle::Trendy];
let styles2 = vec![OutfitStyle::Casual, OutfitStyle::Formal];
let similarity = service.calculate_style_similarity(&styles1, &styles2);
assert!(similarity > 0.0 && similarity < 1.0);
}
#[test]
fn test_calculate_category_compatibility() {
let service = OutfitItemService::new(Arc::new(
// Mock repository - in real tests, use a proper mock
unsafe { std::mem::zeroed() }
));
let compatibility = service.calculate_category_compatibility(
&OutfitCategory::Top,
&OutfitCategory::Bottom
);
assert_eq!(compatibility, 0.9);
let same_category = service.calculate_category_compatibility(
&OutfitCategory::Top,
&OutfitCategory::Top
);
assert_eq!(same_category, 0.2);
}
#[test]
fn test_calculate_matching_score() {
let service = OutfitItemService::new(Arc::new(
unsafe { std::mem::zeroed() }
));
let item1 = OutfitItem {
id: "1".to_string(),
project_id: "test".to_string(),
analysis_id: None,
name: "红色T恤".to_string(),
category: OutfitCategory::Top,
brand: None,
model: None,
color_primary: ColorHSV::new(0.0, 0.8, 0.9), // 红色
color_secondary: None,
styles: vec![OutfitStyle::Casual],
design_elements: vec![],
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: vec![],
tags: vec![],
notes: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let item2 = OutfitItem {
id: "2".to_string(),
project_id: "test".to_string(),
analysis_id: None,
name: "蓝色裤子".to_string(),
category: OutfitCategory::Bottom,
brand: None,
model: None,
color_primary: ColorHSV::new(0.6, 0.7, 0.8), // 蓝色
color_secondary: None,
styles: vec![OutfitStyle::Casual],
design_elements: vec![],
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: vec![],
tags: vec![],
notes: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let score = service.calculate_matching_score(&item1, &item2);
// 应该有一定的匹配度(相同风格,不同类别)
assert!(score > 0.0 && score <= 1.0);
// 由于风格相同且类别互补,分数应该比较高
assert!(score > 0.5);
}
#[test]
fn test_validate_create_request() {
let service = OutfitItemService::new(Arc::new(
unsafe { std::mem::zeroed() }
));
// 测试有效请求
let valid_request = CreateOutfitItemRequest {
project_id: "test_project".to_string(),
analysis_id: None,
name: "测试T恤".to_string(),
category: OutfitCategory::Top,
brand: None,
model: None,
color_primary: ColorHSV::new(0.5, 0.8, 0.9),
color_secondary: None,
styles: vec![OutfitStyle::Casual],
design_elements: vec![],
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: vec![],
tags: vec![],
notes: None,
};
assert!(service.validate_create_request(&valid_request).is_ok());
// 测试无效请求 - 空项目ID
let invalid_request = CreateOutfitItemRequest {
project_id: "".to_string(),
..valid_request.clone()
};
assert!(service.validate_create_request(&invalid_request).is_err());
// 测试无效请求 - 空名称
let invalid_request = CreateOutfitItemRequest {
name: "".to_string(),
..valid_request.clone()
};
assert!(service.validate_create_request(&invalid_request).is_err());
}
#[test]
fn test_validate_update_request() {
let service = OutfitItemService::new(Arc::new(
unsafe { std::mem::zeroed() }
));
// 测试有效更新请求
let valid_request = UpdateOutfitItemRequest {
name: Some("新名称".to_string()),
category: Some(OutfitCategory::Bottom),
brand: None,
model: None,
color_primary: None,
color_secondary: None,
styles: None,
design_elements: None,
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: None,
tags: None,
notes: None,
};
assert!(service.validate_update_request(&valid_request).is_ok());
// 测试无效更新请求 - 空名称
let invalid_request = UpdateOutfitItemRequest {
name: Some("".to_string()),
..valid_request.clone()
};
assert!(service.validate_update_request(&invalid_request).is_err());
}
}

View File

@@ -1,963 +0,0 @@
use crate::data::models::outfit_matching::{
OutfitMatching, CreateOutfitMatchingRequest, UpdateOutfitMatchingRequest,
OutfitMatchingQueryOptions, OutfitMatchingStats, MatchingType,
MatchingScoreDetails, MatchingSuggestion, MatchingOutfitItem,
SmartMatchingRequest, SmartMatchingResult, ConfidenceLevel
};
use crate::data::models::outfit_item::{OutfitItem, OutfitCategory, OutfitStyle};
use crate::data::models::outfit_analysis::ColorHSV;
use crate::data::repositories::outfit_matching_repository::OutfitMatchingRepository;
use crate::data::repositories::outfit_item_repository::OutfitItemRepository;
use crate::business::errors::BusinessError;
use anyhow::Result;
use std::sync::Arc;
use std::collections::HashMap;
/// 服装搭配业务服务
/// 遵循 Tauri 开发规范的业务逻辑层设计原则
pub struct OutfitMatchingService {
repository: Arc<OutfitMatchingRepository>,
item_repository: Arc<OutfitItemRepository>,
}
impl OutfitMatchingService {
/// 创建新的服装搭配服务实例
pub fn new(
repository: Arc<OutfitMatchingRepository>,
item_repository: Arc<OutfitItemRepository>
) -> Self {
Self { repository, item_repository }
}
/// 创建服装搭配
pub async fn create_matching(&self, request: CreateOutfitMatchingRequest) -> Result<OutfitMatching> {
// 验证输入数据
self.validate_create_request(&request)?;
// 检查项目中是否存在相同名称的搭配
let existing_matchings = self.repository.list(&OutfitMatchingQueryOptions {
project_id: Some(request.project_id.clone()),
filters: None,
sort_by: None,
sort_order: None,
limit: None,
offset: None,
})?;
if existing_matchings.iter().any(|m| m.matching_name == request.matching_name) {
return Err(BusinessError::DuplicateName(request.matching_name).into());
}
// 检查所有单品是否存在
let mut items = Vec::new();
for item_id in &request.item_ids {
let item = self.item_repository.get_by_id(item_id)?
.ok_or_else(|| BusinessError::NotFound(format!("服装单品不存在: {}", item_id)))?;
items.push(item);
}
// 创建搭配记录
let matching = self.repository.create(&request)?;
// 计算搭配评分和建议
let (score_details, suggestions) = self.calculate_matching_score_and_suggestions(&items);
// 构建搭配单品列表
let matching_items = self.build_matching_items(&items);
// 提取色彩搭配方案
let color_palette = self.extract_color_palette(&items);
// 更新搭配记录
let mut update_request = UpdateOutfitMatchingRequest {
matching_name: None,
matching_type: None,
item_ids: None,
occasion_tags: None,
season_tags: None,
style_description: None,
is_favorite: None,
};
// 更新搭配记录(在实际实现中,这些字段应该直接在创建时设置)
// 这里模拟更新操作
self.repository.update(&matching.id, &update_request)?;
// 返回完整的搭配记录
let updated_matching = self.repository.get_by_id(&matching.id)?
.ok_or_else(|| BusinessError::NotFound(format!("搭配记录不存在: {}", matching.id)))?;
Ok(updated_matching)
}
/// 获取服装搭配列表
pub async fn get_matchings(&self, options: OutfitMatchingQueryOptions) -> Result<Vec<OutfitMatching>> {
Ok(self.repository.list(&options)?)
}
/// 根据ID获取服装搭配
pub async fn get_matching_by_id(&self, id: &str) -> Result<Option<OutfitMatching>> {
if id.trim().is_empty() {
return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into());
}
Ok(self.repository.get_by_id(id)?)
}
/// 更新服装搭配
pub async fn update_matching(&self, id: &str, request: UpdateOutfitMatchingRequest) -> Result<()> {
if id.trim().is_empty() {
return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into());
}
// 检查搭配是否存在
let matching = self.repository.get_by_id(id)?
.ok_or_else(|| BusinessError::NotFound(format!("搭配记录不存在: {}", id)))?;
// 检查名称是否重复
if let Some(name) = &request.matching_name {
if name != &matching.matching_name {
let existing_matchings = self.repository.list(&OutfitMatchingQueryOptions {
project_id: Some(matching.project_id.clone()),
filters: None,
sort_by: None,
sort_order: None,
limit: None,
offset: None,
})?;
if existing_matchings.iter().any(|m| m.matching_name == *name) {
return Err(BusinessError::DuplicateName(name.clone()).into());
}
}
}
// 更新搭配记录
self.repository.update(id, &request)?;
Ok(())
}
/// 删除服装搭配
pub async fn delete_matching(&self, id: &str) -> Result<()> {
if id.trim().is_empty() {
return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into());
}
// 检查搭配是否存在
if self.repository.get_by_id(id)?.is_none() {
return Err(BusinessError::NotFound(format!("搭配记录不存在: {}", id)).into());
}
self.repository.delete(id)?;
Ok(())
}
/// 增加穿着次数
pub async fn increment_wear_count(&self, id: &str) -> Result<()> {
if id.trim().is_empty() {
return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into());
}
// 检查搭配是否存在
if self.repository.get_by_id(id)?.is_none() {
return Err(BusinessError::NotFound(format!("搭配记录不存在: {}", id)).into());
}
self.repository.increment_wear_count(id)?;
Ok(())
}
/// 智能搭配推荐
pub async fn smart_matching(&self, request: SmartMatchingRequest) -> Result<SmartMatchingResult> {
// 验证请求
if request.project_id.trim().is_empty() {
return Err(BusinessError::InvalidInput("项目ID不能为空".to_string()).into());
}
// 获取项目中的所有单品
let all_items = self.item_repository.list(&crate::data::models::outfit_item::OutfitItemQueryOptions {
project_id: Some(request.project_id.clone()),
category: None,
styles: None,
color_similarity_threshold: None,
target_color: None,
brand: None,
tags: None,
limit: None,
offset: None,
})?;
// 过滤掉排除的单品
let candidate_items: Vec<OutfitItem> = if let Some(exclude_ids) = &request.exclude_item_ids {
all_items.into_iter()
.filter(|item| !exclude_ids.contains(&item.id))
.collect()
} else {
all_items
};
// 如果指定了基础单品,则基于它进行搭配
let mut base_item: Option<OutfitItem> = None;
if let Some(base_id) = &request.base_item_id {
base_item = self.item_repository.get_by_id(base_id)?;
if base_item.is_none() {
return Err(BusinessError::NotFound(format!("基础单品不存在: {}", base_id)).into());
}
}
// 生成搭配组合
let matchings = self.generate_outfit_combinations(
&candidate_items,
base_item.as_ref(),
request.preferred_styles.as_deref(),
request.target_occasion.as_deref(),
request.target_season.as_deref(),
request.color_preferences.as_deref(),
request.max_results.unwrap_or(5)
)?;
// 构建推荐结果
let result = SmartMatchingResult {
recommended_matchings: matchings,
reasoning: "基于您的偏好和搭配规则生成的推荐搭配".to_string(),
confidence_score: 0.85, // 示例置信度
};
Ok(result)
}
/// 生成服装搭配组合
fn generate_outfit_combinations(
&self,
items: &[OutfitItem],
base_item: Option<&OutfitItem>,
preferred_styles: Option<&[OutfitStyle]>,
target_occasion: Option<&str>,
target_season: Option<&str>,
color_preferences: Option<&[ColorHSV]>,
max_results: u32
) -> Result<Vec<OutfitMatching>> {
// 按类别分组单品
let mut items_by_category: HashMap<OutfitCategory, Vec<&OutfitItem>> = HashMap::new();
for item in items {
items_by_category.entry(item.category.clone())
.or_insert_with(Vec::new)
.push(item);
}
// 生成搭配组合(简化版)
let mut matchings = Vec::new();
// 如果有基础单品,则基于它生成搭配
if let Some(base) = base_item {
// 为每个类别选择最匹配的单品
let mut matching_items = vec![base.clone()];
for (category, category_items) in &items_by_category {
// 跳过与基础单品相同类别的单品
if *category == base.category {
continue;
}
// 选择最匹配的单品
if let Some(best_match) = self.find_best_matching_item(
base,
category_items,
preferred_styles,
color_preferences
) {
matching_items.push(best_match.clone());
}
}
// 创建搭配
if matching_items.len() > 1 {
let matching = self.create_matching_from_items(
&matching_items,
MatchingType::StyleConsistent,
target_occasion,
target_season
);
matchings.push(matching);
}
} else {
// 没有基础单品,生成多种搭配组合
// 这里简化实现,实际应用中应该有更复杂的算法
// 尝试上装+下装的组合
if let (Some(tops), Some(bottoms)) = (
items_by_category.get(&OutfitCategory::Top),
items_by_category.get(&OutfitCategory::Bottom)
) {
for top in tops.iter().take(3) {
for bottom in bottoms.iter().take(3) {
let mut outfit_items = vec![(*top).clone(), (*bottom).clone()];
// 可能添加外套
if let Some(outerwears) = items_by_category.get(&OutfitCategory::Outerwear) {
if let Some(outerwear) = outerwears.first() {
outfit_items.push((*outerwear).clone());
}
}
// 可能添加鞋子
if let Some(footwears) = items_by_category.get(&OutfitCategory::Footwear) {
if let Some(footwear) = footwears.first() {
outfit_items.push((*footwear).clone());
}
}
let matching = self.create_matching_from_items(
&outfit_items,
MatchingType::StyleConsistent,
target_occasion,
target_season
);
matchings.push(matching);
if matchings.len() >= max_results as usize {
break;
}
}
if matchings.len() >= max_results as usize {
break;
}
}
}
// 尝试连衣裙+配饰的组合
if matchings.len() < max_results as usize {
if let Some(dresses) = items_by_category.get(&OutfitCategory::Dress) {
for dress in dresses.iter().take(2) {
let mut outfit_items = vec![(*dress).clone()];
// 添加配饰
if let Some(accessories) = items_by_category.get(&OutfitCategory::Accessory) {
if let Some(accessory) = accessories.first() {
outfit_items.push((*accessory).clone());
}
}
// 可能添加外套
if let Some(outerwears) = items_by_category.get(&OutfitCategory::Outerwear) {
if let Some(outerwear) = outerwears.first() {
outfit_items.push((*outerwear).clone());
}
}
let matching = self.create_matching_from_items(
&outfit_items,
MatchingType::StyleConsistent,
target_occasion,
target_season
);
matchings.push(matching);
if matchings.len() >= max_results as usize {
break;
}
}
}
}
}
// 限制结果数量
matchings.truncate(max_results as usize);
Ok(matchings)
}
/// 查找最匹配的单品
fn find_best_matching_item<'a>(
&self,
base_item: &OutfitItem,
candidates: &[&'a OutfitItem],
preferred_styles: Option<&[OutfitStyle]>,
color_preferences: Option<&[ColorHSV]>
) -> Option<&'a OutfitItem> {
if candidates.is_empty() {
return None;
}
// 计算每个候选项的匹配分数
let mut scored_candidates: Vec<(&OutfitItem, f64)> = candidates.iter()
.map(|item| {
let mut score = 0.0;
// 颜色匹配度 (权重: 0.4)
let color_similarity = base_item.color_primary.similarity(&item.color_primary);
score += color_similarity * 0.4;
// 风格匹配度 (权重: 0.3)
let style_similarity = self.calculate_style_similarity(&base_item.styles, &item.styles);
score += style_similarity * 0.3;
// 偏好风格匹配 (权重: 0.2)
if let Some(styles) = preferred_styles {
let preferred_match = item.styles.iter()
.filter(|style| styles.contains(style))
.count() as f64 / styles.len().max(1) as f64;
score += preferred_match * 0.2;
}
// 偏好颜色匹配 (权重: 0.1)
if let Some(colors) = color_preferences {
let mut best_color_match: f64 = 0.0;
for color in colors {
let match_score = item.color_primary.similarity(color);
best_color_match = best_color_match.max(match_score);
}
score += best_color_match * 0.1;
}
(*item, score)
})
.collect();
// 按分数降序排序
scored_candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
// 返回最高分的候选项
scored_candidates.first().map(move |(item, _)| *item)
}
/// 从单品列表创建搭配
fn create_matching_from_items(
&self,
items: &[OutfitItem],
matching_type: MatchingType,
occasion: Option<&str>,
season: Option<&str>
) -> OutfitMatching {
// 计算搭配评分和建议
let (score_details, suggestions) = self.calculate_matching_score_and_suggestions(items);
// 构建搭配单品列表
let matching_items = self.build_matching_items(items);
// 提取色彩搭配方案
let color_palette = self.extract_color_palette(items);
// 生成风格描述
let style_description = self.generate_style_description(items);
// 构建场合标签
let occasion_tags = if let Some(occ) = occasion {
vec![occ.to_string()]
} else {
Vec::new()
};
// 构建季节标签
let season_tags = if let Some(sea) = season {
vec![sea.to_string()]
} else {
Vec::new()
};
// 创建搭配记录
OutfitMatching {
id: uuid::Uuid::new_v4().to_string(),
project_id: items.first().map(|i| i.project_id.clone()).unwrap_or_default(),
matching_name: format!("搭配 {}", chrono::Utc::now().format("%Y%m%d%H%M%S")),
matching_type,
items: matching_items,
score_details,
suggestions,
occasion_tags,
season_tags,
style_description,
color_palette,
is_favorite: false,
wear_count: 0,
last_worn_date: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
/// 计算搭配评分和建议
fn calculate_matching_score_and_suggestions(&self, items: &[OutfitItem]) -> (MatchingScoreDetails, Vec<MatchingSuggestion>) {
// 计算颜色和谐度
let color_harmony_score = self.calculate_color_harmony(items);
// 计算风格一致性
let style_consistency_score = self.calculate_style_consistency(items);
// 计算比例协调度
let proportion_score = 0.75; // 简化实现
// 计算场合适宜度
let occasion_appropriateness = 0.8; // 简化实现
// 计算时尚度
let trend_factor = 0.7; // 简化实现
// 创建评分详情
let mut score_details = MatchingScoreDetails {
color_harmony_score,
style_consistency_score,
proportion_score,
occasion_appropriateness,
trend_factor,
overall_score: 0.0,
};
// 计算综合评分
score_details.calculate_overall_score();
// 生成搭配建议
let mut suggestions = Vec::new();
// 如果颜色和谐度较低,添加颜色建议
if color_harmony_score < 0.6 {
suggestions.push(MatchingSuggestion {
suggestion_type: "颜色调整".to_string(),
description: "考虑选择更协调的颜色组合,可以尝试使用互补色或类似色".to_string(),
priority: 4,
});
}
// 如果风格一致性较低,添加风格建议
if style_consistency_score < 0.6 {
suggestions.push(MatchingSuggestion {
suggestion_type: "风格优化".to_string(),
description: "搭配中的单品风格不够统一,建议选择风格更一致的单品".to_string(),
priority: 3,
});
}
(score_details, suggestions)
}
/// 计算颜色和谐度
fn calculate_color_harmony(&self, items: &[OutfitItem]) -> f64 {
if items.len() <= 1 {
return 1.0; // 单个单品默认和谐度为1.0
}
let mut total_similarity = 0.0;
let mut comparison_count = 0;
// 计算所有单品之间的颜色相似度
for i in 0..items.len() {
for j in i+1..items.len() {
let similarity = items[i].color_primary.similarity(&items[j].color_primary);
total_similarity += similarity;
comparison_count += 1;
}
}
if comparison_count == 0 {
return 1.0;
}
// 返回平均相似度
total_similarity / comparison_count as f64
}
/// 计算风格一致性
fn calculate_style_consistency(&self, items: &[OutfitItem]) -> f64 {
if items.len() <= 1 {
return 1.0; // 单个单品默认一致性为1.0
}
// 收集所有风格
let mut all_styles = Vec::new();
for item in items {
for style in &item.styles {
if !all_styles.contains(style) {
all_styles.push(style.clone());
}
}
}
if all_styles.is_empty() {
return 0.5; // 没有风格信息,返回中等一致性
}
// 计算每个单品与所有风格的匹配度
let mut total_consistency = 0.0;
for item in items {
let mut style_matches = 0;
for style in &all_styles {
if item.styles.contains(style) {
style_matches += 1;
}
}
let item_consistency = style_matches as f64 / all_styles.len() as f64;
total_consistency += item_consistency;
}
// 返回平均一致性
total_consistency / items.len() as f64
}
/// 构建搭配单品列表
fn build_matching_items(&self, items: &[OutfitItem]) -> Vec<MatchingOutfitItem> {
items.iter().map(|item| {
MatchingOutfitItem {
item_id: item.id.clone(),
item_name: item.name.clone(),
category: item.category.clone(),
color_primary: item.color_primary.clone(),
styles: item.styles.clone(),
role_in_outfit: self.determine_item_role(item),
image_url: item.image_urls.first().cloned(),
}
}).collect()
}
/// 确定单品在搭配中的角色
fn determine_item_role(&self, item: &OutfitItem) -> String {
match item.category {
OutfitCategory::Top | OutfitCategory::Bottom | OutfitCategory::Dress => "主角".to_string(),
OutfitCategory::Outerwear => "重点".to_string(),
OutfitCategory::Footwear | OutfitCategory::Accessory => "配角".to_string(),
_ => "点缀".to_string(),
}
}
/// 提取色彩搭配方案
fn extract_color_palette(&self, items: &[OutfitItem]) -> Vec<ColorHSV> {
let mut palette = Vec::new();
for item in items {
palette.push(item.color_primary.clone());
if let Some(secondary) = &item.color_secondary {
palette.push(secondary.clone());
}
}
// 去重
palette.dedup_by(|a, b| a.similarity(b) > 0.9);
palette
}
/// 生成风格描述
fn generate_style_description(&self, items: &[OutfitItem]) -> String {
if items.is_empty() {
return "未知风格".to_string();
}
// 统计风格出现频率
let mut style_counts: HashMap<OutfitStyle, usize> = HashMap::new();
for item in items {
for style in &item.styles {
*style_counts.entry(style.clone()).or_insert(0) += 1;
}
}
// 找出最常见的风格
let mut sorted_styles: Vec<(OutfitStyle, usize)> = style_counts.into_iter().collect();
sorted_styles.sort_by(|a, b| b.1.cmp(&a.1));
if sorted_styles.is_empty() {
return "简约搭配".to_string();
}
// 生成描述
let main_style = &sorted_styles[0].0;
let style_name = main_style.to_string();
if sorted_styles.len() == 1 || sorted_styles[0].1 > sorted_styles[1].1 {
format!("{}风格搭配", style_name)
} else {
let second_style = &sorted_styles[1].0;
format!("{}{}混搭风格", style_name, second_style.to_string())
}
}
/// 计算风格相似度
fn calculate_style_similarity(&self, styles1: &[OutfitStyle], styles2: &[OutfitStyle]) -> f64 {
if styles1.is_empty() || styles2.is_empty() {
return 0.5; // 默认中等相似度
}
let common_styles = styles1.iter()
.filter(|style1| styles2.contains(style1))
.count();
let total_unique_styles = styles1.len() + styles2.len() - common_styles;
if total_unique_styles == 0 {
1.0
} else {
common_styles as f64 / total_unique_styles as f64
}
}
/// 获取服装搭配统计信息
pub async fn get_matching_stats(&self, project_id: Option<&str>) -> Result<OutfitMatchingStats> {
Ok(self.repository.get_stats(project_id)?)
}
/// 验证创建请求
fn validate_create_request(&self, request: &CreateOutfitMatchingRequest) -> Result<()> {
if request.project_id.trim().is_empty() {
return Err(BusinessError::InvalidInput("项目ID不能为空".to_string()).into());
}
if request.matching_name.trim().is_empty() {
return Err(BusinessError::InvalidInput("搭配名称不能为空".to_string()).into());
}
if request.item_ids.is_empty() {
return Err(BusinessError::InvalidInput("搭配单品列表不能为空".to_string()).into());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::models::outfit_analysis::ColorHSV;
use crate::data::models::outfit_item::{OutfitCategory, OutfitStyle};
#[test]
fn test_calculate_color_harmony() {
// 创建测试用的服装单品
let items = vec![
OutfitItem {
id: "1".to_string(),
project_id: "test".to_string(),
analysis_id: None,
name: "红色T恤".to_string(),
category: OutfitCategory::Top,
brand: None,
model: None,
color_primary: ColorHSV::new(0.0, 0.8, 0.9), // 红色
color_secondary: None,
styles: vec![OutfitStyle::Casual],
design_elements: vec![],
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: vec![],
tags: vec![],
notes: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
},
OutfitItem {
id: "2".to_string(),
project_id: "test".to_string(),
analysis_id: None,
name: "红色裤子".to_string(),
category: OutfitCategory::Bottom,
brand: None,
model: None,
color_primary: ColorHSV::new(0.02, 0.75, 0.85), // 相似的红色
color_secondary: None,
styles: vec![OutfitStyle::Casual],
design_elements: vec![],
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: vec![],
tags: vec![],
notes: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
},
];
// 创建模拟的仓库
let matching_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() });
let item_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() });
let service = OutfitMatchingService::new(matching_repo, item_repo);
let harmony_score = service.calculate_color_harmony(&items);
// 相似颜色应该有较高的和谐度
assert!(harmony_score > 0.7);
}
#[test]
fn test_calculate_style_consistency() {
let items = vec![
OutfitItem {
id: "1".to_string(),
project_id: "test".to_string(),
analysis_id: None,
name: "休闲T恤".to_string(),
category: OutfitCategory::Top,
brand: None,
model: None,
color_primary: ColorHSV::new(0.0, 0.8, 0.9),
color_secondary: None,
styles: vec![OutfitStyle::Casual, OutfitStyle::Trendy],
design_elements: vec![],
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: vec![],
tags: vec![],
notes: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
},
OutfitItem {
id: "2".to_string(),
project_id: "test".to_string(),
analysis_id: None,
name: "休闲裤子".to_string(),
category: OutfitCategory::Bottom,
brand: None,
model: None,
color_primary: ColorHSV::new(0.6, 0.5, 0.8),
color_secondary: None,
styles: vec![OutfitStyle::Casual],
design_elements: vec![],
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: vec![],
tags: vec![],
notes: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
},
];
let matching_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() });
let item_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() });
let service = OutfitMatchingService::new(matching_repo, item_repo);
let consistency_score = service.calculate_style_consistency(&items);
// 有共同风格的单品应该有较高的一致性
assert!(consistency_score > 0.5);
}
#[test]
fn test_determine_item_role() {
let matching_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() });
let item_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() });
let service = OutfitMatchingService::new(matching_repo, item_repo);
let top_item = OutfitItem {
id: "1".to_string(),
project_id: "test".to_string(),
analysis_id: None,
name: "T恤".to_string(),
category: OutfitCategory::Top,
brand: None,
model: None,
color_primary: ColorHSV::new(0.0, 0.8, 0.9),
color_secondary: None,
styles: vec![],
design_elements: vec![],
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: vec![],
tags: vec![],
notes: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let role = service.determine_item_role(&top_item);
assert_eq!(role, "主角");
let accessory_item = OutfitItem {
category: OutfitCategory::Accessory,
..top_item.clone()
};
let accessory_role = service.determine_item_role(&accessory_item);
assert_eq!(accessory_role, "配角");
}
#[test]
fn test_generate_style_description() {
let items = vec![
OutfitItem {
id: "1".to_string(),
project_id: "test".to_string(),
analysis_id: None,
name: "休闲T恤".to_string(),
category: OutfitCategory::Top,
brand: None,
model: None,
color_primary: ColorHSV::new(0.0, 0.8, 0.9),
color_secondary: None,
styles: vec![OutfitStyle::Casual, OutfitStyle::Casual], // 重复的休闲风格
design_elements: vec![],
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: vec![],
tags: vec![],
notes: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
},
];
let matching_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() });
let item_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() });
let service = OutfitMatchingService::new(matching_repo, item_repo);
let description = service.generate_style_description(&items);
assert!(description.contains("休闲"));
}
#[test]
fn test_validate_create_request() {
let matching_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() });
let item_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() });
let service = OutfitMatchingService::new(matching_repo, item_repo);
// 测试有效请求
let valid_request = CreateOutfitMatchingRequest {
project_id: "test_project".to_string(),
matching_name: "测试搭配".to_string(),
matching_type: MatchingType::StyleConsistent,
item_ids: vec!["item1".to_string(), "item2".to_string()],
occasion_tags: vec![],
season_tags: vec![],
style_description: None,
};
assert!(service.validate_create_request(&valid_request).is_ok());
// 测试无效请求 - 空项目ID
let invalid_request = CreateOutfitMatchingRequest {
project_id: "".to_string(),
..valid_request.clone()
};
assert!(service.validate_create_request(&invalid_request).is_err());
// 测试无效请求 - 空搭配名称
let invalid_request = CreateOutfitMatchingRequest {
matching_name: "".to_string(),
..valid_request.clone()
};
assert!(service.validate_create_request(&invalid_request).is_err());
// 测试无效请求 - 空单品列表
let invalid_request = CreateOutfitMatchingRequest {
item_ids: vec![],
..valid_request.clone()
};
assert!(service.validate_create_request(&invalid_request).is_err());
}
}

View File

@@ -8,6 +8,3 @@ pub mod project_template_binding_repository;
pub mod template_matching_result_repository;
pub mod export_record_repository;
pub mod video_generation_repository;
pub mod outfit_analysis_repository;
pub mod outfit_item_repository;
pub mod outfit_matching_repository;

View File

@@ -1,263 +0,0 @@
use rusqlite::{Result, Row, OptionalExtension};
use std::sync::Arc;
use chrono::{DateTime, Utc};
use crate::data::models::outfit_analysis::{
OutfitAnalysis, CreateOutfitAnalysisRequest, UpdateOutfitAnalysisRequest,
OutfitAnalysisQueryOptions, OutfitAnalysisStats, AnalysisStatus, ImageAnalysisResult
};
use crate::infrastructure::database::Database;
/// 服装分析数据仓库
/// 遵循 Tauri 开发规范的数据访问层设计
pub struct OutfitAnalysisRepository {
database: Arc<Database>,
}
impl OutfitAnalysisRepository {
/// 创建新的服装分析仓库实例
pub fn new(database: Arc<Database>) -> Result<Self> {
Ok(OutfitAnalysisRepository { database })
}
/// 创建服装分析记录
pub fn create(&self, request: &CreateOutfitAnalysisRequest) -> Result<OutfitAnalysis> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let analysis = OutfitAnalysis {
id: uuid::Uuid::new_v4().to_string(),
project_id: request.project_id.clone(),
image_path: request.image_path.clone(),
image_name: request.image_name.clone(),
analysis_status: AnalysisStatus::Pending,
analysis_result: None,
error_message: None,
created_at: Utc::now(),
updated_at: Utc::now(),
analyzed_at: None,
};
conn.execute(
"INSERT INTO outfit_analyses (
id, project_id, image_path, image_name, analysis_status,
analysis_result, error_message, created_at, updated_at, analyzed_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
[
analysis.id.as_str(),
analysis.project_id.as_str(),
analysis.image_path.as_str(),
analysis.image_name.as_str(),
&serde_json::to_string(&analysis.analysis_status).unwrap(),
analysis.analysis_result.as_ref().map(|r| serde_json::to_string(r).unwrap()).as_deref().unwrap_or(""),
analysis.error_message.as_deref().unwrap_or(""),
analysis.created_at.to_rfc3339().as_str(),
analysis.updated_at.to_rfc3339().as_str(),
analysis.analyzed_at.as_ref().map(|d| d.to_rfc3339()).as_deref().unwrap_or(""),
],
)?;
Ok(analysis)
}
/// 根据ID获取服装分析记录
pub fn get_by_id(&self, id: &str) -> Result<Option<OutfitAnalysis>> {
let conn = self.database.get_read_connection();
let conn = conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, project_id, image_path, image_name, analysis_status,
analysis_result, error_message, created_at, updated_at, analyzed_at
FROM outfit_analyses WHERE id = ?1"
)?;
let analysis = stmt.query_row([id], |row| {
self.row_to_outfit_analysis(row)
}).optional()?;
Ok(analysis)
}
/// 更新服装分析记录
pub fn update(&self, id: &str, request: &UpdateOutfitAnalysisRequest) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let mut updates = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(status) = &request.analysis_status {
updates.push("analysis_status = ?");
params.push(Box::new(serde_json::to_string(status).unwrap()));
}
if let Some(result) = &request.analysis_result {
updates.push("analysis_result = ?");
params.push(Box::new(serde_json::to_string(result).unwrap()));
}
if let Some(error) = &request.error_message {
updates.push("error_message = ?");
params.push(Box::new(error.clone()));
}
if !updates.is_empty() {
updates.push("updated_at = ?");
params.push(Box::new(Utc::now().to_rfc3339()));
// 如果状态更新为已完成,设置分析时间
if let Some(AnalysisStatus::Completed) = &request.analysis_status {
updates.push("analyzed_at = ?");
params.push(Box::new(Utc::now().to_rfc3339()));
}
params.push(Box::new(id.to_string()));
let sql = format!(
"UPDATE outfit_analyses SET {} WHERE id = ?",
updates.join(", ")
);
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
conn.execute(&sql, param_refs.as_slice())?;
}
Ok(())
}
/// 删除服装分析记录
pub fn delete(&self, id: &str) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
conn.execute("DELETE FROM outfit_analyses WHERE id = ?1", [id])?;
Ok(())
}
/// 查询服装分析记录列表
pub fn list(&self, options: &OutfitAnalysisQueryOptions) -> Result<Vec<OutfitAnalysis>> {
let conn = self.database.get_read_connection();
let conn = conn.lock().unwrap();
let mut sql = "SELECT id, project_id, image_path, image_name, analysis_status,
analysis_result, error_message, created_at, updated_at, analyzed_at
FROM outfit_analyses WHERE 1=1".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(project_id) = &options.project_id {
sql.push_str(" AND project_id = ?");
params.push(Box::new(project_id.clone()));
}
if let Some(status) = &options.status {
sql.push_str(" AND analysis_status = ?");
params.push(Box::new(serde_json::to_string(status).unwrap()));
}
sql.push_str(" ORDER BY created_at DESC");
if let Some(limit) = options.limit {
sql.push_str(&format!(" LIMIT {}", limit));
}
if let Some(offset) = options.offset {
sql.push_str(&format!(" OFFSET {}", offset));
}
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let analysis_iter = stmt.query_map(param_refs.as_slice(), |row| {
self.row_to_outfit_analysis(row)
})?;
let mut analyses = Vec::new();
for analysis in analysis_iter {
analyses.push(analysis?);
}
Ok(analyses)
}
/// 获取服装分析统计信息
pub fn get_stats(&self, project_id: Option<&str>) -> Result<OutfitAnalysisStats> {
let conn = self.database.get_read_connection();
let conn = conn.lock().unwrap();
let mut sql = "SELECT analysis_status, COUNT(*) as count FROM outfit_analyses".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(project_id) = project_id {
sql.push_str(" WHERE project_id = ?");
params.push(Box::new(project_id.to_string()));
}
sql.push_str(" GROUP BY analysis_status");
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut total_count = 0u32;
let mut pending_count = 0u32;
let mut processing_count = 0u32;
let mut completed_count = 0u32;
let mut failed_count = 0u32;
let rows = stmt.query_map(param_refs.as_slice(), |row| {
let status_str: String = row.get(0)?;
let count: u32 = row.get(1)?;
Ok((status_str, count))
})?;
for row in rows {
let (status_str, count) = row?;
total_count += count;
if let Ok(status) = serde_json::from_str::<AnalysisStatus>(&status_str) {
match status {
AnalysisStatus::Pending => pending_count = count,
AnalysisStatus::Processing => processing_count = count,
AnalysisStatus::Completed => completed_count = count,
AnalysisStatus::Failed => failed_count = count,
}
}
}
Ok(OutfitAnalysisStats {
total_count,
pending_count,
processing_count,
completed_count,
failed_count,
})
}
/// 将数据库行转换为OutfitAnalysis对象
fn row_to_outfit_analysis(&self, row: &Row) -> Result<OutfitAnalysis> {
let analysis_status_str: String = row.get(4)?;
let analysis_status = serde_json::from_str(&analysis_status_str)
.unwrap_or(AnalysisStatus::Pending);
let analysis_result: Option<String> = row.get(5)?;
let analysis_result = analysis_result
.and_then(|s| serde_json::from_str::<ImageAnalysisResult>(&s).ok());
let created_at_str: String = row.get(7)?;
let updated_at_str: String = row.get(8)?;
let analyzed_at_str: Option<String> = row.get(9)?;
Ok(OutfitAnalysis {
id: row.get(0)?,
project_id: row.get(1)?,
image_path: row.get(2)?,
image_name: row.get(3)?,
analysis_status,
analysis_result,
error_message: row.get(6)?,
created_at: DateTime::parse_from_rfc3339(&created_at_str).unwrap().with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&updated_at_str).unwrap().with_timezone(&Utc),
analyzed_at: analyzed_at_str
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&Utc)),
})
}
}

View File

@@ -1,331 +0,0 @@
use rusqlite::{Result, Row, OptionalExtension};
use std::sync::Arc;
use chrono::{DateTime, Utc};
use crate::data::models::outfit_item::{
OutfitItem, CreateOutfitItemRequest, UpdateOutfitItemRequest,
OutfitItemQueryOptions, OutfitItemStats, OutfitCategory, OutfitStyle
};
use crate::data::models::outfit_analysis::ColorHSV;
use crate::infrastructure::database::Database;
/// 服装单品数据仓库
/// 遵循 Tauri 开发规范的数据访问层设计
pub struct OutfitItemRepository {
database: Arc<Database>,
}
impl OutfitItemRepository {
/// 创建新的服装单品仓库实例
pub fn new(database: Arc<Database>) -> Result<Self> {
Ok(OutfitItemRepository { database })
}
/// 创建服装单品
pub fn create(&self, request: &CreateOutfitItemRequest) -> Result<OutfitItem> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let item = OutfitItem {
id: uuid::Uuid::new_v4().to_string(),
project_id: request.project_id.clone(),
analysis_id: request.analysis_id.clone(),
name: request.name.clone(),
category: request.category.clone(),
brand: request.brand.clone(),
model: request.model.clone(),
color_primary: request.color_primary.clone(),
color_secondary: request.color_secondary.clone(),
styles: request.styles.clone(),
design_elements: request.design_elements.clone(),
size: request.size.clone(),
material: request.material.clone(),
price: request.price,
purchase_date: request.purchase_date,
image_urls: request.image_urls.clone(),
tags: request.tags.clone(),
notes: request.notes.clone(),
created_at: Utc::now(),
updated_at: Utc::now(),
};
conn.execute(
"INSERT INTO outfit_items (
id, project_id, analysis_id, name, category, brand, model,
color_primary, color_secondary, styles, design_elements,
size_info, material_info, price, purchase_date, image_urls,
tags, notes, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
[
item.id.as_str(),
item.project_id.as_str(),
item.analysis_id.as_deref().unwrap_or(""),
item.name.as_str(),
&serde_json::to_string(&item.category).unwrap(),
item.brand.as_deref().unwrap_or(""),
item.model.as_deref().unwrap_or(""),
&serde_json::to_string(&item.color_primary).unwrap(),
item.color_secondary.as_ref().map(|c| serde_json::to_string(c).unwrap()).as_deref().unwrap_or(""),
&serde_json::to_string(&item.styles).unwrap(),
&serde_json::to_string(&item.design_elements).unwrap(),
item.size.as_ref().map(|s| serde_json::to_string(s).unwrap()).as_deref().unwrap_or(""),
item.material.as_ref().map(|m| serde_json::to_string(m).unwrap()).as_deref().unwrap_or(""),
item.price.map(|p| p.to_string()).as_deref().unwrap_or(""),
item.purchase_date.as_ref().map(|d| d.to_rfc3339()).as_deref().unwrap_or(""),
&serde_json::to_string(&item.image_urls).unwrap(),
&serde_json::to_string(&item.tags).unwrap(),
item.notes.as_deref().unwrap_or(""),
item.created_at.to_rfc3339().as_str(),
item.updated_at.to_rfc3339().as_str(),
],
)?;
Ok(item)
}
/// 根据ID获取服装单品
pub fn get_by_id(&self, id: &str) -> Result<Option<OutfitItem>> {
let conn = self.database.get_read_connection();
let conn = conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, project_id, analysis_id, name, category, brand, model,
color_primary, color_secondary, styles, design_elements,
size_info, material_info, price, purchase_date, image_urls,
tags, notes, created_at, updated_at
FROM outfit_items WHERE id = ?1"
)?;
let item = stmt.query_row([id], |row| {
self.row_to_outfit_item(row)
}).optional()?;
Ok(item)
}
/// 更新服装单品
pub fn update(&self, id: &str, request: &UpdateOutfitItemRequest) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let mut updates = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(name) = &request.name {
updates.push("name = ?");
params.push(Box::new(name.clone()));
}
if let Some(category) = &request.category {
updates.push("category = ?");
params.push(Box::new(serde_json::to_string(category).unwrap()));
}
if let Some(brand) = &request.brand {
updates.push("brand = ?");
params.push(Box::new(brand.clone()));
}
if let Some(color_primary) = &request.color_primary {
updates.push("color_primary = ?");
params.push(Box::new(serde_json::to_string(color_primary).unwrap()));
}
if let Some(styles) = &request.styles {
updates.push("styles = ?");
params.push(Box::new(serde_json::to_string(styles).unwrap()));
}
if let Some(tags) = &request.tags {
updates.push("tags = ?");
params.push(Box::new(serde_json::to_string(tags).unwrap()));
}
if !updates.is_empty() {
updates.push("updated_at = ?");
params.push(Box::new(Utc::now().to_rfc3339()));
params.push(Box::new(id.to_string()));
let sql = format!(
"UPDATE outfit_items SET {} WHERE id = ?",
updates.join(", ")
);
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
conn.execute(&sql, param_refs.as_slice())?;
}
Ok(())
}
/// 删除服装单品
pub fn delete(&self, id: &str) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
conn.execute("DELETE FROM outfit_items WHERE id = ?1", [id])?;
Ok(())
}
/// 查询服装单品列表
pub fn list(&self, options: &OutfitItemQueryOptions) -> Result<Vec<OutfitItem>> {
let conn = self.database.get_read_connection();
let conn = conn.lock().unwrap();
let mut sql = "SELECT id, project_id, analysis_id, name, category, brand, model,
color_primary, color_secondary, styles, design_elements,
size_info, material_info, price, purchase_date, image_urls,
tags, notes, created_at, updated_at
FROM outfit_items WHERE 1=1".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(project_id) = &options.project_id {
sql.push_str(" AND project_id = ?");
params.push(Box::new(project_id.clone()));
}
if let Some(category) = &options.category {
sql.push_str(" AND category = ?");
params.push(Box::new(serde_json::to_string(category).unwrap()));
}
if let Some(brand) = &options.brand {
sql.push_str(" AND brand = ?");
params.push(Box::new(brand.clone()));
}
sql.push_str(" ORDER BY created_at DESC");
if let Some(limit) = options.limit {
sql.push_str(&format!(" LIMIT {}", limit));
}
if let Some(offset) = options.offset {
sql.push_str(&format!(" OFFSET {}", offset));
}
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let item_iter = stmt.query_map(param_refs.as_slice(), |row| {
self.row_to_outfit_item(row)
})?;
let mut items = Vec::new();
for item in item_iter {
items.push(item?);
}
Ok(items)
}
/// 根据颜色相似度搜索服装单品
pub fn search_by_color(&self, project_id: &str, target_color: &ColorHSV, threshold: f64) -> Result<Vec<OutfitItem>> {
let items = self.list(&OutfitItemQueryOptions {
project_id: Some(project_id.to_string()),
category: None,
styles: None,
color_similarity_threshold: None,
target_color: None,
brand: None,
tags: None,
limit: None,
offset: None,
})?;
let mut matching_items = Vec::new();
for item in items {
let similarity = item.color_primary.similarity(target_color);
if similarity >= threshold {
matching_items.push(item);
}
}
// 按相似度排序
matching_items.sort_by(|a, b| {
let sim_a = a.color_primary.similarity(target_color);
let sim_b = b.color_primary.similarity(target_color);
sim_b.partial_cmp(&sim_a).unwrap_or(std::cmp::Ordering::Equal)
});
Ok(matching_items)
}
/// 获取服装单品统计信息
pub fn get_stats(&self, project_id: Option<&str>) -> Result<OutfitItemStats> {
let conn = self.database.get_read_connection();
let conn = conn.lock().unwrap();
let mut sql = "SELECT COUNT(*) as total FROM outfit_items".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(project_id) = project_id {
sql.push_str(" WHERE project_id = ?");
params.push(Box::new(project_id.to_string()));
}
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let total_count: u32 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
Ok(OutfitItemStats {
total_count,
category_counts: std::collections::HashMap::new(),
style_counts: std::collections::HashMap::new(),
brand_counts: std::collections::HashMap::new(),
})
}
/// 将数据库行转换为OutfitItem对象
fn row_to_outfit_item(&self, row: &Row) -> Result<OutfitItem> {
let category_str: String = row.get(4)?;
let category = serde_json::from_str(&category_str).unwrap_or(OutfitCategory::Other);
let color_primary_str: String = row.get(7)?;
let color_primary = serde_json::from_str(&color_primary_str)
.unwrap_or(ColorHSV::new(0.0, 0.0, 0.0));
let color_secondary_str: Option<String> = row.get(8)?;
let color_secondary = color_secondary_str
.and_then(|s| serde_json::from_str(&s).ok());
let styles_str: String = row.get(9)?;
let styles = serde_json::from_str(&styles_str).unwrap_or_default();
let design_elements_str: String = row.get(10)?;
let design_elements = serde_json::from_str(&design_elements_str).unwrap_or_default();
let image_urls_str: String = row.get(15)?;
let image_urls = serde_json::from_str(&image_urls_str).unwrap_or_default();
let tags_str: String = row.get(16)?;
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
let created_at_str: String = row.get(18)?;
let updated_at_str: String = row.get(19)?;
Ok(OutfitItem {
id: row.get(0)?,
project_id: row.get(1)?,
analysis_id: row.get(2)?,
name: row.get(3)?,
category,
brand: row.get(5)?,
model: row.get(6)?,
color_primary,
color_secondary,
styles,
design_elements,
size: None, // TODO: 解析size_info JSON
material: None, // TODO: 解析material_info JSON
price: row.get::<_, Option<String>>(13)?.and_then(|s| s.parse().ok()),
purchase_date: row.get::<_, Option<String>>(14)?
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&Utc)),
image_urls,
tags,
notes: row.get(17)?,
created_at: DateTime::parse_from_rfc3339(&created_at_str).unwrap().with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&updated_at_str).unwrap().with_timezone(&Utc),
})
}
}

View File

@@ -1,347 +0,0 @@
use rusqlite::{Result, Row, OptionalExtension};
use std::sync::Arc;
use chrono::{DateTime, Utc};
use crate::data::models::outfit_matching::{
OutfitMatching, CreateOutfitMatchingRequest, UpdateOutfitMatchingRequest,
OutfitMatchingQueryOptions, OutfitMatchingStats, MatchingType,
MatchingScoreDetails, MatchingSuggestion, MatchingOutfitItem
};
use crate::data::models::outfit_analysis::ColorHSV;
use crate::infrastructure::database::Database;
/// 服装搭配数据仓库
/// 遵循 Tauri 开发规范的数据访问层设计
pub struct OutfitMatchingRepository {
database: Arc<Database>,
}
impl OutfitMatchingRepository {
/// 创建新的服装搭配仓库实例
pub fn new(database: Arc<Database>) -> Result<Self> {
Ok(OutfitMatchingRepository { database })
}
/// 创建服装搭配
pub fn create(&self, request: &CreateOutfitMatchingRequest) -> Result<OutfitMatching> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
// 创建默认评分详情
let mut score_details = MatchingScoreDetails {
color_harmony_score: 0.0,
style_consistency_score: 0.0,
proportion_score: 0.0,
occasion_appropriateness: 0.0,
trend_factor: 0.0,
overall_score: 0.0,
};
score_details.calculate_overall_score();
let matching = OutfitMatching {
id: uuid::Uuid::new_v4().to_string(),
project_id: request.project_id.clone(),
matching_name: request.matching_name.clone(),
matching_type: request.matching_type.clone(),
items: Vec::new(), // 将在后续填充
score_details,
suggestions: Vec::new(),
occasion_tags: request.occasion_tags.clone(),
season_tags: request.season_tags.clone(),
style_description: request.style_description.clone().unwrap_or_default(),
color_palette: Vec::new(),
is_favorite: false,
wear_count: 0,
last_worn_date: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
conn.execute(
"INSERT INTO outfit_matchings (
id, project_id, matching_name, matching_type, items,
score_details, suggestions, occasion_tags, season_tags,
style_description, color_palette, is_favorite, wear_count,
last_worn_date, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
[
matching.id.as_str(),
matching.project_id.as_str(),
matching.matching_name.as_str(),
&serde_json::to_string(&matching.matching_type).unwrap(),
&serde_json::to_string(&request.item_ids).unwrap(), // 暂时存储item_ids
&serde_json::to_string(&matching.score_details).unwrap(),
&serde_json::to_string(&matching.suggestions).unwrap(),
&serde_json::to_string(&matching.occasion_tags).unwrap(),
&serde_json::to_string(&matching.season_tags).unwrap(),
matching.style_description.as_str(),
&serde_json::to_string(&matching.color_palette).unwrap(),
if matching.is_favorite { "1" } else { "0" },
&matching.wear_count.to_string(),
matching.last_worn_date.as_ref().map(|d| d.to_rfc3339()).as_deref().unwrap_or(""),
matching.created_at.to_rfc3339().as_str(),
matching.updated_at.to_rfc3339().as_str(),
],
)?;
Ok(matching)
}
/// 根据ID获取服装搭配
pub fn get_by_id(&self, id: &str) -> Result<Option<OutfitMatching>> {
let conn = self.database.get_read_connection();
let conn = conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, project_id, matching_name, matching_type, items,
score_details, suggestions, occasion_tags, season_tags,
style_description, color_palette, is_favorite, wear_count,
last_worn_date, created_at, updated_at
FROM outfit_matchings WHERE id = ?1"
)?;
let matching = stmt.query_row([id], |row| {
self.row_to_outfit_matching(row)
}).optional()?;
Ok(matching)
}
/// 更新服装搭配
pub fn update(&self, id: &str, request: &UpdateOutfitMatchingRequest) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let mut updates = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(name) = &request.matching_name {
updates.push("matching_name = ?");
params.push(Box::new(name.clone()));
}
if let Some(matching_type) = &request.matching_type {
updates.push("matching_type = ?");
params.push(Box::new(serde_json::to_string(matching_type).unwrap()));
}
if let Some(item_ids) = &request.item_ids {
updates.push("items = ?");
params.push(Box::new(serde_json::to_string(item_ids).unwrap()));
}
if let Some(occasion_tags) = &request.occasion_tags {
updates.push("occasion_tags = ?");
params.push(Box::new(serde_json::to_string(occasion_tags).unwrap()));
}
if let Some(season_tags) = &request.season_tags {
updates.push("season_tags = ?");
params.push(Box::new(serde_json::to_string(season_tags).unwrap()));
}
if let Some(style_description) = &request.style_description {
updates.push("style_description = ?");
params.push(Box::new(style_description.clone()));
}
if let Some(is_favorite) = request.is_favorite {
updates.push("is_favorite = ?");
params.push(Box::new(if is_favorite { "1" } else { "0" }));
}
if !updates.is_empty() {
updates.push("updated_at = ?");
params.push(Box::new(Utc::now().to_rfc3339()));
params.push(Box::new(id.to_string()));
let sql = format!(
"UPDATE outfit_matchings SET {} WHERE id = ?",
updates.join(", ")
);
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
conn.execute(&sql, param_refs.as_slice())?;
}
Ok(())
}
/// 删除服装搭配
pub fn delete(&self, id: &str) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
conn.execute("DELETE FROM outfit_matchings WHERE id = ?1", [id])?;
Ok(())
}
/// 查询服装搭配列表
pub fn list(&self, options: &OutfitMatchingQueryOptions) -> Result<Vec<OutfitMatching>> {
let conn = self.database.get_read_connection();
let conn = conn.lock().unwrap();
let mut sql = "SELECT id, project_id, matching_name, matching_type, items,
score_details, suggestions, occasion_tags, season_tags,
style_description, color_palette, is_favorite, wear_count,
last_worn_date, created_at, updated_at
FROM outfit_matchings WHERE 1=1".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(project_id) = &options.project_id {
sql.push_str(" AND project_id = ?");
params.push(Box::new(project_id.clone()));
}
// 添加排序
let sort_by = options.sort_by.as_deref().unwrap_or("created_at");
let sort_order = options.sort_order.as_deref().unwrap_or("desc");
sql.push_str(&format!(" ORDER BY {} {}", sort_by, sort_order.to_uppercase()));
if let Some(limit) = options.limit {
sql.push_str(&format!(" LIMIT {}", limit));
}
if let Some(offset) = options.offset {
sql.push_str(&format!(" OFFSET {}", offset));
}
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let matching_iter = stmt.query_map(param_refs.as_slice(), |row| {
self.row_to_outfit_matching(row)
})?;
let mut matchings = Vec::new();
for matching in matching_iter {
matchings.push(matching?);
}
Ok(matchings)
}
/// 增加穿着次数
pub fn increment_wear_count(&self, id: &str) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
conn.execute(
"UPDATE outfit_matchings
SET wear_count = wear_count + 1,
last_worn_date = ?,
updated_at = ?
WHERE id = ?",
[
Utc::now().to_rfc3339().as_str(),
Utc::now().to_rfc3339().as_str(),
id,
],
)?;
Ok(())
}
/// 获取服装搭配统计信息
pub fn get_stats(&self, project_id: Option<&str>) -> Result<OutfitMatchingStats> {
let conn = self.database.get_read_connection();
let conn = conn.lock().unwrap();
let mut sql = "SELECT COUNT(*) as total,
COUNT(CASE WHEN is_favorite = '1' THEN 1 END) as favorites,
AVG(CAST(JSON_EXTRACT(score_details, '$.overall_score') AS REAL)) as avg_score
FROM outfit_matchings".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(project_id) = project_id {
sql.push_str(" WHERE project_id = ?");
params.push(Box::new(project_id.to_string()));
}
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let (total_matchings, favorite_count, average_score) = stmt.query_row(param_refs.as_slice(), |row| {
Ok((
row.get::<_, u32>(0)?,
row.get::<_, u32>(1)?,
row.get::<_, Option<f64>>(2)?.unwrap_or(0.0),
))
})?;
Ok(OutfitMatchingStats {
total_matchings,
favorite_count,
average_score,
most_worn_matching_id: None, // TODO: 实现最常穿搭配查询
style_distribution: std::collections::HashMap::new(),
occasion_distribution: std::collections::HashMap::new(),
season_distribution: std::collections::HashMap::new(),
})
}
/// 将数据库行转换为OutfitMatching对象
fn row_to_outfit_matching(&self, row: &Row) -> Result<OutfitMatching> {
let matching_type_str: String = row.get(3)?;
let matching_type = serde_json::from_str(&matching_type_str)
.unwrap_or(MatchingType::ColorHarmony);
let items_str: String = row.get(4)?;
let items = serde_json::from_str(&items_str).unwrap_or_default();
let score_details_str: String = row.get(5)?;
let score_details = serde_json::from_str(&score_details_str)
.unwrap_or_else(|_| MatchingScoreDetails {
color_harmony_score: 0.0,
style_consistency_score: 0.0,
proportion_score: 0.0,
occasion_appropriateness: 0.0,
trend_factor: 0.0,
overall_score: 0.0,
});
let suggestions_str: String = row.get(6)?;
let suggestions = serde_json::from_str(&suggestions_str).unwrap_or_default();
let occasion_tags_str: String = row.get(7)?;
let occasion_tags = serde_json::from_str(&occasion_tags_str).unwrap_or_default();
let season_tags_str: String = row.get(8)?;
let season_tags = serde_json::from_str(&season_tags_str).unwrap_or_default();
let color_palette_str: String = row.get(10)?;
let color_palette = serde_json::from_str(&color_palette_str).unwrap_or_default();
let is_favorite_str: String = row.get(11)?;
let is_favorite = is_favorite_str == "1";
let wear_count_str: String = row.get(12)?;
let wear_count = wear_count_str.parse().unwrap_or(0);
let last_worn_date_str: Option<String> = row.get(13)?;
let last_worn_date = last_worn_date_str
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&Utc));
let created_at_str: String = row.get(14)?;
let updated_at_str: String = row.get(15)?;
Ok(OutfitMatching {
id: row.get(0)?,
project_id: row.get(1)?,
matching_name: row.get(2)?,
matching_type,
items,
score_details,
suggestions,
occasion_tags,
season_tags,
style_description: row.get(9)?,
color_palette,
is_favorite,
wear_count,
last_worn_date,
created_at: DateTime::parse_from_rfc3339(&created_at_str).unwrap().with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&updated_at_str).unwrap().with_timezone(&Utc),
})
}
}

View File

@@ -255,30 +255,7 @@ pub fn run() {
commands::debug_commands::test_parse_draft_file,
commands::debug_commands::validate_template_structure,
// 便捷工具命令
commands::tools_commands::clean_jsonl_data,
// 服装搭配命令
commands::outfit_commands::create_outfit_analysis,
commands::outfit_commands::start_outfit_analysis,
commands::outfit_commands::list_outfit_analyses,
commands::outfit_commands::get_outfit_analysis_by_id,
commands::outfit_commands::delete_outfit_analysis,
commands::outfit_commands::get_outfit_analysis_stats,
commands::outfit_commands::create_outfit_item,
commands::outfit_commands::create_outfit_items_from_analysis,
commands::outfit_commands::list_outfit_items,
commands::outfit_commands::get_outfit_item_by_id,
commands::outfit_commands::update_outfit_item,
commands::outfit_commands::delete_outfit_item,
commands::outfit_commands::create_outfit_matching,
commands::outfit_commands::list_outfit_matchings,
commands::outfit_commands::get_outfit_matching_by_id,
commands::outfit_commands::update_outfit_matching,
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::generate_outfit_recommendations,
commands::outfit_commands::debug_outfit_items_stats
commands::tools_commands::clean_jsonl_data
])
.setup(|app| {
// 初始化日志系统

View File

@@ -17,4 +17,3 @@ pub mod template_matching_result_commands;
pub mod export_record_commands;
pub mod video_generation_commands;
pub mod tools_commands;
pub mod outfit_commands;

View File

@@ -1,296 +0,0 @@
/**
* 服装搭配功能集成测试
* 测试从图像分析到搭配推荐的完整流程
*/
#[cfg(test)]
mod outfit_integration_tests {
use std::sync::Arc;
use chrono::Utc;
use crate::data::models::outfit_analysis::{
OutfitAnalysis, CreateOutfitAnalysisRequest, AnalysisStatus, ColorHSV
};
use crate::data::models::outfit_item::{
OutfitItem, CreateOutfitItemRequest, OutfitCategory, OutfitStyle
};
use crate::data::models::outfit_matching::{
OutfitMatching, CreateOutfitMatchingRequest, MatchingType
};
use crate::data::repositories::outfit_analysis_repository::OutfitAnalysisRepository;
use crate::data::repositories::outfit_item_repository::OutfitItemRepository;
use crate::data::repositories::outfit_matching_repository::OutfitMatchingRepository;
use crate::business::services::outfit_analysis_service::OutfitAnalysisService;
use crate::business::services::outfit_item_service::OutfitItemService;
use crate::business::services::outfit_matching_service::OutfitMatchingService;
use crate::infrastructure::database::Database;
use crate::infrastructure::gemini_service::GeminiService;
/// 测试完整的服装分析流程
#[tokio::test]
async fn test_complete_outfit_analysis_workflow() {
// 创建内存数据库
let database = Arc::new(Database::new_in_memory().unwrap());
// 创建仓库
let analysis_repo = Arc::new(OutfitAnalysisRepository::new(database.clone()).unwrap());
let item_repo = Arc::new(OutfitItemRepository::new(database.clone()).unwrap());
let matching_repo = Arc::new(OutfitMatchingRepository::new(database.clone()).unwrap());
// 创建服务注意在实际测试中应该使用模拟的GeminiService
let gemini_service = Arc::new(GeminiService::new().unwrap());
let analysis_service = OutfitAnalysisService::new(analysis_repo.clone(), gemini_service);
let item_service = OutfitItemService::new(item_repo.clone());
let matching_service = OutfitMatchingService::new(matching_repo.clone(), item_repo.clone());
// 1. 创建分析记录
let analysis_request = CreateOutfitAnalysisRequest {
project_id: "test_project".to_string(),
image_path: "/test/image.jpg".to_string(),
image_name: "test_image.jpg".to_string(),
};
let analysis = analysis_service.create_analysis(analysis_request).await.unwrap();
assert_eq!(analysis.analysis_status, AnalysisStatus::Pending);
// 2. 模拟分析完成,手动创建服装单品
let item1_request = CreateOutfitItemRequest {
project_id: "test_project".to_string(),
analysis_id: Some(analysis.id.clone()),
name: "红色T恤".to_string(),
category: OutfitCategory::Top,
brand: Some("测试品牌".to_string()),
model: None,
color_primary: ColorHSV::new(0.0, 0.8, 0.9), // 红色
color_secondary: None,
styles: vec![OutfitStyle::Casual],
design_elements: vec!["纯色".to_string()],
size: None,
material: None,
price: Some(99.0),
purchase_date: None,
image_urls: vec![],
tags: vec!["夏季".to_string(), "休闲".to_string()],
notes: Some("舒适的棉质T恤".to_string()),
};
let item1 = item_service.create_item(item1_request).await.unwrap();
assert_eq!(item1.category, OutfitCategory::Top);
let item2_request = CreateOutfitItemRequest {
project_id: "test_project".to_string(),
analysis_id: Some(analysis.id.clone()),
name: "蓝色牛仔裤".to_string(),
category: OutfitCategory::Bottom,
brand: Some("测试品牌".to_string()),
model: None,
color_primary: ColorHSV::new(0.6, 0.7, 0.8), // 蓝色
color_secondary: None,
styles: vec![OutfitStyle::Casual],
design_elements: vec!["水洗".to_string()],
size: None,
material: None,
price: Some(199.0),
purchase_date: None,
image_urls: vec![],
tags: vec!["四季".to_string(), "百搭".to_string()],
notes: Some("经典款牛仔裤".to_string()),
};
let item2 = item_service.create_item(item2_request).await.unwrap();
assert_eq!(item2.category, OutfitCategory::Bottom);
// 3. 创建搭配
let matching_request = CreateOutfitMatchingRequest {
project_id: "test_project".to_string(),
matching_name: "休闲夏日搭配".to_string(),
matching_type: MatchingType::StyleConsistent,
item_ids: vec![item1.id.clone(), item2.id.clone()],
occasion_tags: vec!["日常".to_string(), "休闲".to_string()],
season_tags: vec!["夏季".to_string()],
style_description: Some("轻松休闲的夏日搭配".to_string()),
};
let matching = matching_service.create_matching(matching_request).await.unwrap();
assert_eq!(matching.matching_type, MatchingType::StyleConsistent);
assert_eq!(matching.items.len(), 2);
// 4. 验证搭配评分
assert!(matching.score_details.overall_score >= 0.0);
assert!(matching.score_details.overall_score <= 1.0);
// 5. 测试搭配推荐
let recommended_items = item_service.recommend_matching_items(
"test_project",
&item1.id,
Some(3)
).await.unwrap();
// 应该推荐item2因为它与item1风格匹配且类别互补
assert!(recommended_items.iter().any(|item| item.id == item2.id));
println!("✅ 服装分析完整流程测试通过");
}
/// 测试颜色匹配功能
#[tokio::test]
async fn test_color_matching_functionality() {
let database = Arc::new(Database::new_in_memory().unwrap());
let item_repo = Arc::new(OutfitItemRepository::new(database.clone()).unwrap());
let item_service = OutfitItemService::new(item_repo.clone());
// 创建不同颜色的服装单品
let red_item = CreateOutfitItemRequest {
project_id: "test_project".to_string(),
analysis_id: None,
name: "红色上衣".to_string(),
category: OutfitCategory::Top,
brand: None,
model: None,
color_primary: ColorHSV::new(0.0, 0.8, 0.9), // 纯红色
color_secondary: None,
styles: vec![OutfitStyle::Casual],
design_elements: vec![],
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: vec![],
tags: vec![],
notes: None,
};
let blue_item = CreateOutfitItemRequest {
project_id: "test_project".to_string(),
analysis_id: None,
name: "蓝色上衣".to_string(),
category: OutfitCategory::Top,
brand: None,
model: None,
color_primary: ColorHSV::new(0.6, 0.8, 0.9), // 纯蓝色
color_secondary: None,
styles: vec![OutfitStyle::Casual],
design_elements: vec![],
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: vec![],
tags: vec![],
notes: None,
};
let similar_red_item = CreateOutfitItemRequest {
project_id: "test_project".to_string(),
analysis_id: None,
name: "深红色上衣".to_string(),
category: OutfitCategory::Top,
brand: None,
model: None,
color_primary: ColorHSV::new(0.02, 0.75, 0.85), // 相似的红色
color_secondary: None,
styles: vec![OutfitStyle::Casual],
design_elements: vec![],
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: vec![],
tags: vec![],
notes: None,
};
// 创建服装单品
let red = item_service.create_item(red_item).await.unwrap();
let blue = item_service.create_item(blue_item).await.unwrap();
let similar_red = item_service.create_item(similar_red_item).await.unwrap();
// 测试颜色搜索
let target_color = ColorHSV::new(0.0, 0.8, 0.9); // 红色
let matching_items = item_service.search_by_color(
"test_project",
&target_color,
0.7 // 70% 相似度阈值
).await.unwrap();
// 应该找到红色和相似红色的单品,但不包括蓝色
assert!(matching_items.iter().any(|item| item.id == red.id));
assert!(matching_items.iter().any(|item| item.id == similar_red.id));
assert!(!matching_items.iter().any(|item| item.id == blue.id));
println!("✅ 颜色匹配功能测试通过");
}
/// 测试搭配评分算法
#[tokio::test]
async fn test_outfit_scoring_algorithm() {
let database = Arc::new(Database::new_in_memory().unwrap());
let matching_repo = Arc::new(OutfitMatchingRepository::new(database.clone()).unwrap());
let item_repo = Arc::new(OutfitItemRepository::new(database.clone()).unwrap());
let matching_service = OutfitMatchingService::new(matching_repo, item_repo);
// 创建测试用的服装单品
let items = vec![
OutfitItem {
id: "1".to_string(),
project_id: "test".to_string(),
analysis_id: None,
name: "白色T恤".to_string(),
category: OutfitCategory::Top,
brand: None,
model: None,
color_primary: ColorHSV::new(0.0, 0.0, 1.0), // 白色
color_secondary: None,
styles: vec![OutfitStyle::Casual, OutfitStyle::Minimalist],
design_elements: vec![],
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: vec![],
tags: vec![],
notes: None,
created_at: Utc::now(),
updated_at: Utc::now(),
},
OutfitItem {
id: "2".to_string(),
project_id: "test".to_string(),
analysis_id: None,
name: "黑色裤子".to_string(),
category: OutfitCategory::Bottom,
brand: None,
model: None,
color_primary: ColorHSV::new(0.0, 0.0, 0.0), // 黑色
color_secondary: None,
styles: vec![OutfitStyle::Casual, OutfitStyle::Minimalist],
design_elements: vec![],
size: None,
material: None,
price: None,
purchase_date: None,
image_urls: vec![],
tags: vec![],
notes: None,
created_at: Utc::now(),
updated_at: Utc::now(),
},
];
// 测试评分计算
let (score_details, suggestions) = matching_service.calculate_matching_score_and_suggestions(&items);
// 验证评分范围
assert!(score_details.color_harmony_score >= 0.0 && score_details.color_harmony_score <= 1.0);
assert!(score_details.style_consistency_score >= 0.0 && score_details.style_consistency_score <= 1.0);
assert!(score_details.overall_score >= 0.0 && score_details.overall_score <= 1.0);
// 黑白搭配应该有较高的风格一致性(都是极简风格)
assert!(score_details.style_consistency_score > 0.5);
println!("✅ 搭配评分算法测试通过");
println!(" 颜色和谐度: {:.2}", score_details.color_harmony_score);
println!(" 风格一致性: {:.2}", score_details.style_consistency_score);
println!(" 综合评分: {:.2}", score_details.overall_score);
}
}

View File

@@ -9,7 +9,6 @@ import AiClassificationSettings from './pages/AiClassificationSettings';
import TemplateManagement from './pages/TemplateManagement';
import { MaterialModelBinding } from './pages/MaterialModelBinding';
import Tools from './pages/Tools';
import OutfitMatch from './pages/OutfitMatch';
import Navigation from './components/Navigation';
import { NotificationSystem, useNotifications } from './components/NotificationSystem';
import { useProjectStore } from './store/projectStore';
@@ -81,7 +80,6 @@ function App() {
<Route path="/templates" element={<TemplateManagement />} />
<Route path="/material-model-binding" element={<MaterialModelBinding />} />
<Route path="/tools" element={<Tools />} />
<Route path="/outfit-match" element={<OutfitMatch />} />
</Routes>
</div>
</main>

View File

@@ -1,340 +0,0 @@
import React, { useState, useCallback, useRef } from 'react';
import {
PhotoIcon,
CloudArrowUpIcon,
XMarkIcon,
EyeIcon
} from '@heroicons/react/24/outline';
import { useNotifications } from '../NotificationSystem';
interface ImageFile {
file: File;
preview: string;
id: string;
}
interface ImageUploaderProps {
onUpload: (files: File[]) => Promise<void>;
isUploading?: boolean;
maxFiles?: number;
maxFileSize?: number; // in MB
acceptedFormats?: string[];
className?: string;
}
const ImageUploader: React.FC<ImageUploaderProps> = ({
onUpload,
isUploading = false,
maxFiles = 5,
maxFileSize = 10,
acceptedFormats = ['image/jpeg', 'image/png', 'image/webp'],
className = ''
}) => {
const [selectedFiles, setSelectedFiles] = useState<ImageFile[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { addNotification } = useNotifications();
// 验证文件
const validateFile = useCallback((file: File): string | null => {
if (!acceptedFormats.includes(file.type)) {
return `不支持的文件类型。请选择 ${acceptedFormats.join(', ')} 格式的图片。`;
}
if (file.size > maxFileSize * 1024 * 1024) {
return `文件大小超过限制。请选择小于 ${maxFileSize}MB 的图片。`;
}
return null;
}, [acceptedFormats, maxFileSize]);
// 处理文件选择
const handleFiles = useCallback((files: FileList) => {
const newFiles: ImageFile[] = [];
const errors: string[] = [];
Array.from(files).forEach((file) => {
const error = validateFile(file);
if (error) {
errors.push(`${file.name}: ${error}`);
return;
}
if (selectedFiles.length + newFiles.length >= maxFiles) {
errors.push(`最多只能选择 ${maxFiles} 个文件`);
return;
}
const id = Math.random().toString(36).substr(2, 9);
const preview = URL.createObjectURL(file);
newFiles.push({ file, preview, id });
});
if (errors.length > 0) {
addNotification({
type: 'error',
title: '文件验证失败',
message: errors.join('\n')
});
}
if (newFiles.length > 0) {
setSelectedFiles(prev => [...prev, ...newFiles]);
}
}, [selectedFiles.length, maxFiles, validateFile, addNotification]);
// 拖拽处理
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFiles(files);
}
}, [handleFiles]);
// 文件选择
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
handleFiles(files);
}
// 清空input值允许重复选择同一文件
e.target.value = '';
}, [handleFiles]);
// 移除文件
const removeFile = useCallback((id: string) => {
setSelectedFiles(prev => {
const updated = prev.filter(f => f.id !== id);
// 清理预览URL
const removed = prev.find(f => f.id === id);
if (removed) {
URL.revokeObjectURL(removed.preview);
}
return updated;
});
}, []);
// 清空所有文件
const clearAll = useCallback(() => {
selectedFiles.forEach(f => URL.revokeObjectURL(f.preview));
setSelectedFiles([]);
}, [selectedFiles]);
// 上传文件
const handleUpload = useCallback(async () => {
if (selectedFiles.length === 0) {
addNotification({
type: 'warning',
title: '请选择文件',
message: '请先选择要上传的图片文件'
});
return;
}
try {
const files = selectedFiles.map(f => f.file);
await onUpload(files);
addNotification({
type: 'success',
title: '上传成功',
message: `成功上传 ${files.length} 个文件`
});
clearAll();
} catch (error) {
addNotification({
type: 'error',
title: '上传失败',
message: error instanceof Error ? error.message : '上传过程中发生错误'
});
}
}, [selectedFiles, onUpload, addNotification, clearAll]);
// 预览图片
const showPreview = useCallback((preview: string) => {
setPreviewImage(preview);
}, []);
// 清理内存
React.useEffect(() => {
return () => {
selectedFiles.forEach(f => URL.revokeObjectURL(f.preview));
};
}, []);
return (
<div className={`space-y-4 ${className}`}>
{/* 拖拽上传区域 */}
<div
className={`relative border-2 border-dashed rounded-xl p-8 text-center transition-all duration-300 ${
isDragOver
? 'border-primary-500 bg-primary-50'
: 'border-gray-300 hover:border-gray-400'
} ${isUploading ? 'opacity-50 pointer-events-none' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
multiple
accept={acceptedFormats.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
<div className="space-y-4">
<div className="mx-auto w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center">
<PhotoIcon className="w-8 h-8 text-gray-400" />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
</h3>
<p className="text-gray-500 mb-4">
</p>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
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"
>
<CloudArrowUpIcon className="w-4 h-4 mr-2" />
</button>
</div>
<div className="text-xs text-gray-400">
{acceptedFormats.map(type => type.split('/')[1].toUpperCase()).join(', ')}
{maxFileSize}MB {maxFiles}
</div>
</div>
</div>
{/* 已选择的文件列表 */}
{selectedFiles.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-900">
{selectedFiles.length}
</h4>
<button
type="button"
onClick={clearAll}
className="text-sm text-gray-500 hover:text-gray-700"
>
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{selectedFiles.map((imageFile) => (
<div
key={imageFile.id}
className="relative group bg-white rounded-lg border border-gray-200 overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
<div className="aspect-square">
<img
src={imageFile.preview}
alt={imageFile.file.name}
className="w-full h-full object-cover"
/>
</div>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-40 transition-all duration-200 flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex space-x-2">
<button
type="button"
onClick={() => showPreview(imageFile.preview)}
className="p-2 bg-white rounded-full text-gray-700 hover:text-gray-900"
>
<EyeIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => removeFile(imageFile.id)}
className="p-2 bg-white rounded-full text-red-600 hover:text-red-800"
>
<XMarkIcon className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-2">
<p className="text-xs text-gray-600 truncate" title={imageFile.file.name}>
{imageFile.file.name}
</p>
<p className="text-xs text-gray-400">
{(imageFile.file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
))}
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleUpload}
disabled={isUploading || selectedFiles.length === 0}
className="inline-flex items-center px-6 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"
>
{isUploading ? (
<>
<div className="animate-spin -ml-1 mr-2 h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div>
...
</>
) : (
<>
<CloudArrowUpIcon className="w-4 h-4 mr-2" />
({selectedFiles.length})
</>
)}
</button>
</div>
</div>
)}
{/* 图片预览模态框 */}
{previewImage && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
<div className="relative max-w-4xl max-h-full p-4">
<button
type="button"
onClick={() => setPreviewImage(null)}
className="absolute top-4 right-4 p-2 bg-white rounded-full text-gray-700 hover:text-gray-900 z-10"
>
<XMarkIcon className="w-6 h-6" />
</button>
<img
src={previewImage}
alt="预览"
className="max-w-full max-h-full object-contain rounded-lg"
/>
</div>
</div>
)}
</div>
);
};
export default ImageUploader;

View File

@@ -1,163 +0,0 @@
import React, { useState } from 'react';
import { CustomSelect, CustomMultiSelect } from '../CustomSelect';
// 示例:如何使用单选和多选组件
const MultiSelectExample: React.FC = () => {
const [singleValue, setSingleValue] = useState<string>('');
const [multiValue, setMultiValue] = useState<string[]>([]);
const [searchableMultiValue, setSearchableMultiValue] = useState<string[]>([]);
// 示例选项
const categoryOptions = [
{ value: 'top', label: '上装' },
{ value: 'bottom', label: '下装' },
{ value: 'dress', label: '连衣裙' },
{ value: 'outerwear', label: '外套' },
{ value: 'footwear', label: '鞋类' },
{ value: 'accessory', label: '配饰' },
{ value: 'other', label: '其他' }
];
const styleOptions = [
{ value: 'casual', label: '休闲风格' },
{ value: 'formal', label: '正式风格' },
{ value: 'business', label: '商务风格' },
{ value: 'street', label: '街头风格' },
{ value: 'elegant', label: '优雅风格' },
{ value: 'sporty', label: '运动风格' },
{ value: 'vintage', label: '复古风格' },
{ value: 'minimalist', label: '简约风格' },
{ value: 'bohemian', label: '波西米亚风格' },
{ value: 'gothic', label: '哥特风格' }
];
return (
<div className="max-w-4xl mx-auto p-6 space-y-8">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
CustomSelect 使
</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 单选组件示例 */}
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-800"> (CustomSelect)</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<CustomSelect
value={singleValue}
onChange={setSingleValue}
options={categoryOptions}
placeholder="请选择一个类别"
className="w-full"
/>
<p className="mt-2 text-sm text-gray-600">
: {singleValue || '未选择'}
</p>
</div>
</div>
{/* 多选组件示例 */}
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-800"> (CustomMultiSelect)</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
()
</label>
<CustomMultiSelect
value={multiValue}
onChange={setMultiValue}
options={categoryOptions}
placeholder="请选择多个类别"
className="w-full"
maxDisplayItems={2}
/>
<p className="mt-2 text-sm text-gray-600">
: {multiValue.length > 0 ? multiValue.join(', ') : '未选择'}
</p>
</div>
</div>
</div>
{/* 带搜索的多选组件示例 */}
<div className="mt-8">
<h2 className="text-lg font-semibold text-gray-800 mb-4"></h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
()
</label>
<CustomMultiSelect
value={searchableMultiValue}
onChange={setSearchableMultiValue}
options={styleOptions}
placeholder="请选择风格,支持搜索"
className="w-full"
maxDisplayItems={3}
searchable={true}
/>
<p className="mt-2 text-sm text-gray-600">
: {searchableMultiValue.length > 0 ? searchableMultiValue.join(', ') : '未选择'}
</p>
</div>
</div>
{/* 使用说明 */}
<div className="mt-8 bg-gray-50 rounded-lg p-4">
<h3 className="text-md font-semibold text-gray-800 mb-3">使</h3>
<div className="space-y-2 text-sm text-gray-600">
<div>
<strong>CustomSelect ():</strong>
<ul className="list-disc list-inside ml-4 mt-1">
<li></li>
<li>使 value (string) onChange ((value: string) =&gt; void)</li>
</ul>
</div>
<div>
<strong>CustomMultiSelect ():</strong>
<ul className="list-disc list-inside ml-4 mt-1">
<li></li>
<li>使 value (string[]) onChange ((value: string[]) =&gt; void)</li>
<li> (searchable=true)</li>
<li>/</li>
<li> (maxDisplayItems)</li>
<li> × </li>
<li> × </li>
</ul>
</div>
</div>
</div>
{/* 代码示例 */}
<div className="mt-8 bg-gray-900 rounded-lg p-4 text-white">
<h3 className="text-md font-semibold mb-3"></h3>
<pre className="text-sm overflow-x-auto">
{`// 单选组件
<CustomSelect
value={singleValue}
onChange={setSingleValue}
options={categoryOptions}
placeholder="请选择一个类别"
/>
// 多选组件
<CustomMultiSelect
value={multiValue}
onChange={setMultiValue}
options={categoryOptions}
placeholder="请选择多个类别"
maxDisplayItems={2}
searchable={true}
/>`}
</pre>
</div>
</div>
</div>
);
};
export default MultiSelectExample;

View File

@@ -1,331 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
SparklesIcon,
EyeIcon,
ClockIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
ArrowPathIcon
} from '@heroicons/react/24/outline';
import { invoke } from '@tauri-apps/api/core';
import { useNotifications } from '../NotificationSystem';
import { PROJECT_ID } from './const';
interface OutfitAnalysis {
id: string;
project_id: string;
image_path: string;
image_name: string;
analysis_status: 'Pending' | 'Processing' | 'Completed' | 'Failed';
analysis_result?: any;
error_message?: string;
created_at: string;
updated_at: string;
analyzed_at?: string;
}
interface OutfitAnalysisResultProps {
onCreateItems?: (analysisId: string) => void;
}
const OutfitAnalysisResult: React.FC<OutfitAnalysisResultProps> = ({
onCreateItems
}) => {
const [analyses, setAnalyses] = useState<OutfitAnalysis[]>([]);
const [loading, setLoading] = useState(true);
const [selectedAnalysis, setSelectedAnalysis] = useState<OutfitAnalysis | null>(null);
const { addNotification } = useNotifications();
// 加载分析结果
const loadAnalyses = async () => {
try {
setLoading(true);
const options = {
project_id: PROJECT_ID,
status: null,
limit: 50,
offset: 0
};
const result = await invoke('list_outfit_analyses', { options });
setAnalyses(result as OutfitAnalysis[]);
} catch (error) {
console.error('加载分析结果失败:', error);
addNotification({
type: 'error',
title: '加载失败',
message: '无法加载分析结果'
});
} finally {
setLoading(false);
}
};
// 创建服装单品
const handleCreateItems = async (analysisId: string) => {
try {
await invoke('create_outfit_items_from_analysis', {
projectId: PROJECT_ID,
analysisId
});
addNotification({
type: 'success',
title: '创建成功',
message: '成功从分析结果创建服装单品'
});
if (onCreateItems) {
onCreateItems(analysisId);
}
} catch (error) {
console.error('创建服装单品失败:', error);
addNotification({
type: 'error',
title: '创建失败',
message: error instanceof Error ? error.message : '创建服装单品失败'
});
}
};
// 获取状态图标
const getStatusIcon = (status: string) => {
switch (status) {
case 'Pending':
return <ClockIcon className="w-5 h-5 text-yellow-500" />;
case 'Processing':
return <ArrowPathIcon className="w-5 h-5 text-blue-500 animate-spin" />;
case 'Completed':
return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
case 'Failed':
return <ExclamationTriangleIcon className="w-5 h-5 text-red-500" />;
default:
return <ClockIcon className="w-5 h-5 text-gray-500" />;
}
};
// 获取状态文本
const getStatusText = (status: string) => {
switch (status) {
case 'Pending':
return '等待分析';
case 'Processing':
return '分析中...';
case 'Completed':
return '分析完成';
case 'Failed':
return '分析失败';
default:
return '未知状态';
}
};
// 格式化时间
const formatTime = (timeString: string) => {
return new Date(timeString).toLocaleString('zh-CN');
};
useEffect(() => {
loadAnalyses();
// 设置定时刷新,检查分析状态
const interval = setInterval(() => {
loadAnalyses();
}, 5000); // 每5秒刷新一次
return () => clearInterval(interval);
}, []);
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>
);
}
if (analyses.length === 0) {
return (
<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"></p>
</div>
);
}
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{analyses.map((analysis) => (
<div
key={analysis.id}
className="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
>
{/* 图片预览 */}
<div className="aspect-video bg-gray-100 relative">
{analysis.image_path && (
<img
src={`file://${analysis.image_path}`}
alt={analysis.image_name}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className="absolute top-2 right-2">
<button
onClick={() => setSelectedAnalysis(analysis)}
className="p-2 bg-white rounded-full shadow-md hover:shadow-lg transition-shadow"
>
<EyeIcon className="w-4 h-4 text-gray-600" />
</button>
</div>
</div>
{/* 分析信息 */}
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-gray-900 truncate">
{analysis.image_name}
</h4>
<div className="flex items-center space-x-1">
{getStatusIcon(analysis.analysis_status)}
<span className="text-sm text-gray-600">
{getStatusText(analysis.analysis_status)}
</span>
</div>
</div>
<p className="text-xs text-gray-500 mb-3">
: {formatTime(analysis.created_at)}
</p>
{/* 分析结果 */}
{analysis.analysis_status === 'Completed' && analysis.analysis_result && (
<div className="space-y-2 mb-3">
<div className="text-sm">
<span className="font-medium text-gray-700">:</span>
<span className="ml-1 text-gray-600">
{analysis.analysis_result.products?.length || 0}
</span>
</div>
{analysis.analysis_result.style_description && (
<div className="text-sm">
<span className="font-medium text-gray-700">:</span>
<span className="ml-1 text-gray-600">
{analysis.analysis_result.style_description}
</span>
</div>
)}
</div>
)}
{/* 错误信息 */}
{analysis.analysis_status === 'Failed' && analysis.error_message && (
<div className="mb-3">
<p className="text-sm text-red-600">
{analysis.error_message}
</p>
</div>
)}
{/* 操作按钮 */}
<div className="flex space-x-2">
{analysis.analysis_status === 'Completed' && (
<button
onClick={() => handleCreateItems(analysis.id)}
className="flex-1 px-3 py-2 bg-primary-600 text-white text-sm font-medium rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
</button>
)}
<button
onClick={() => setSelectedAnalysis(analysis)}
className="px-3 py-2 border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
</button>
</div>
</div>
</div>
))}
</div>
{/* 详情模态框 */}
{selectedAnalysis && (
<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">{selectedAnalysis.image_name}</span>
</div>
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{getStatusText(selectedAnalysis.analysis_status)}</span>
</div>
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{formatTime(selectedAnalysis.created_at)}</span>
</div>
{selectedAnalysis.analyzed_at && (
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{formatTime(selectedAnalysis.analyzed_at)}</span>
</div>
)}
{selectedAnalysis.analysis_result && (
<div>
<span className="font-medium text-gray-700">:</span>
<pre className="mt-2 text-sm text-gray-600 bg-gray-50 p-3 rounded-md overflow-auto max-h-40">
{JSON.stringify(selectedAnalysis.analysis_result, null, 2)}
</pre>
</div>
)}
{selectedAnalysis.error_message && (
<div>
<span className="font-medium text-red-700">:</span>
<p className="mt-1 text-sm text-red-600">{selectedAnalysis.error_message}</p>
</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={() => setSelectedAnalysis(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 OutfitAnalysisResult;

View File

@@ -1,277 +0,0 @@
import React from 'react';
import {
HeartIcon,
EyeIcon,
TrashIcon,
StarIcon,
ClockIcon,
SparklesIcon
} from '@heroicons/react/24/outline';
import { HeartIcon as HeartSolidIcon } from '@heroicons/react/24/solid';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
interface OutfitMatching {
id: string;
project_id: string;
matching_name: string;
matching_type: string;
items: Array<{
item_id: string;
item_name: string;
category: string;
color_primary: any;
styles: string[];
role_in_outfit: string;
image_url?: string;
}>;
score_details: {
color_harmony_score: number;
style_consistency_score: number;
proportion_score: number;
occasion_appropriateness: number;
trend_factor: number;
overall_score: number;
};
suggestions: Array<{
suggestion_type: string;
description: string;
priority: number;
}>;
occasion_tags: string[];
season_tags: string[];
style_description: string;
color_palette: any[];
is_favorite: boolean;
wear_count: number;
last_worn_date?: string;
created_at: string;
updated_at: string;
}
interface OutfitCardProps {
matching: OutfitMatching;
onToggleFavorite: () => void;
onDelete: () => void;
onView: () => void;
}
const OutfitCard: React.FC<OutfitCardProps> = ({
matching,
onToggleFavorite,
onDelete,
onView
}) => {
const formatColorHSV = (color: any) => {
if (!color) return '#000000';
try {
const h = color.hue * 360;
const s = color.saturation * 100;
const v = color.value * 100;
return `hsl(${h}, ${s}%, ${v}%)`;
} catch {
return '#000000';
}
};
const getScoreColor = (score: number) => {
if (score >= 0.8) return 'text-green-600 bg-green-100';
if (score >= 0.6) return 'text-yellow-600 bg-yellow-100';
return 'text-red-600 bg-red-100';
};
const getMatchingTypeLabel = (type: string) => {
const typeMap: { [key: string]: string } = {
'ColorHarmony': '颜色和谐',
'StyleConsistent': '风格一致',
'Complementary': '互补搭配',
'Seasonal': '季节搭配',
'Occasion': '场合搭配',
'Trendy': '时尚搭配'
};
return typeMap[type] || type;
};
const formatDate = (dateString: string) => {
try {
return format(new Date(dateString), 'MM-dd', { locale: zhCN });
} catch {
return dateString;
}
};
const getCategoryIcon = (_category: string) => {
// 这里可以根据类别返回不同的图标
return '👕'; // 简化处理,实际应用中可以使用更具体的图标
};
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md transition-all duration-200 overflow-hidden">
{/* 头部 */}
<div className="p-4 border-b border-gray-100">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-medium text-gray-900 truncate">
{matching.matching_name}
</h3>
<p className="text-sm text-gray-500 mt-1">
{matching.style_description}
</p>
</div>
<div className="flex items-center space-x-2 ml-3">
<button
onClick={onToggleFavorite}
className={`p-1.5 rounded-full transition-colors ${
matching.is_favorite
? 'text-red-500 hover:text-red-600'
: 'text-gray-400 hover:text-red-500'
}`}
>
{matching.is_favorite ? (
<HeartSolidIcon className="h-5 w-5" />
) : (
<HeartIcon className="h-5 w-5" />
)}
</button>
<button
onClick={onView}
className="p-1.5 rounded-full text-gray-400 hover:text-indigo-500 transition-colors"
>
<EyeIcon className="h-5 w-5" />
</button>
<button
onClick={onDelete}
className="p-1.5 rounded-full text-gray-400 hover:text-red-500 transition-colors"
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</div>
{/* 搭配类型和评分 */}
<div className="flex items-center justify-between mt-3">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
{getMatchingTypeLabel(matching.matching_type)}
</span>
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getScoreColor(matching.score_details.overall_score)}`}>
<StarIcon className="h-3 w-3 mr-1" />
{(matching.score_details.overall_score * 100).toFixed(0)}
</div>
</div>
</div>
{/* 服装单品预览 */}
<div className="p-4">
<h4 className="text-sm font-medium text-gray-900 mb-3">
({matching.items.length})
</h4>
<div className="grid grid-cols-2 gap-2">
{matching.items.slice(0, 4).map((item) => (
<div key={item.item_id} className="flex items-center space-x-2 p-2 bg-gray-50 rounded-md">
<div className="flex-shrink-0">
<div
className="w-6 h-6 rounded-full border border-gray-300 flex items-center justify-center text-xs"
style={{ backgroundColor: formatColorHSV(item.color_primary) }}
>
{getCategoryIcon(item.category)}
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-gray-900 truncate">
{item.item_name}
</p>
<p className="text-xs text-gray-500">
{item.role_in_outfit}
</p>
</div>
</div>
))}
{matching.items.length > 4 && (
<div className="flex items-center justify-center p-2 bg-gray-50 rounded-md border-2 border-dashed border-gray-300">
<span className="text-xs text-gray-500">
+{matching.items.length - 4}
</span>
</div>
)}
</div>
</div>
{/* 色彩搭配方案 */}
{matching.color_palette.length > 0 && (
<div className="px-4 pb-4">
<h4 className="text-sm font-medium text-gray-900 mb-2"></h4>
<div className="flex space-x-1">
{matching.color_palette.slice(0, 6).map((color, index) => (
<div
key={index}
className="w-6 h-6 rounded-full border border-gray-300"
style={{ backgroundColor: formatColorHSV(color) }}
title={`颜色 ${index + 1}`}
/>
))}
{matching.color_palette.length > 6 && (
<div className="w-6 h-6 rounded-full border-2 border-dashed border-gray-300 flex items-center justify-center">
<span className="text-xs text-gray-500">+</span>
</div>
)}
</div>
</div>
)}
{/* 标签 */}
<div className="px-4 pb-4">
<div className="flex flex-wrap gap-1">
{matching.occasion_tags.slice(0, 2).map((tag, index) => (
<span
key={`occasion-${index}`}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
>
{tag}
</span>
))}
{matching.season_tags.slice(0, 2).map((tag, index) => (
<span
key={`season-${index}`}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"
>
{tag}
</span>
))}
</div>
</div>
{/* 底部信息 */}
<div className="px-4 py-3 bg-gray-50 border-t border-gray-100">
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center space-x-3">
<span className="flex items-center">
<ClockIcon className="h-3 w-3 mr-1" />
{formatDate(matching.created_at)}
</span>
{matching.wear_count > 0 && (
<span className="flex items-center">
<SparklesIcon className="h-3 w-3 mr-1" />
穿 {matching.wear_count}
</span>
)}
</div>
{matching.last_worn_date && (
<span>
穿: {formatDate(matching.last_worn_date)}
</span>
)}
</div>
</div>
</div>
);
};
export default OutfitCard;

View File

@@ -1,235 +0,0 @@
import React from 'react';
import {
PhotoIcon,
TagIcon,
SparklesIcon
} from '@heroicons/react/24/outline';
import { CheckCircleIcon as CheckCircleSolidIcon } from '@heroicons/react/24/solid';
interface OutfitItem {
id: string;
project_id: string;
analysis_id?: string;
name: string;
category: string;
brand?: string;
color_primary: any;
styles: string[];
image_urls: string[];
tags: string[];
notes?: string;
created_at: string;
updated_at: string;
}
interface OutfitItemCardProps {
item: OutfitItem;
isSelected: boolean;
onSelect: (selected: boolean) => void;
}
const OutfitItemCard: React.FC<OutfitItemCardProps> = ({
item,
isSelected,
onSelect
}) => {
const formatColorHSV = (color: any) => {
if (!color) return '#000000';
try {
const h = color.hue * 360;
const s = color.saturation * 100;
const v = color.value * 100;
return `hsl(${h}, ${s}%, ${v}%)`;
} catch {
return '#000000';
}
};
const getCategoryLabel = (category: string) => {
const categoryMap: { [key: string]: string } = {
'Top': '上装',
'Bottom': '下装',
'Dress': '连衣裙',
'Outerwear': '外套',
'Footwear': '鞋类',
'Accessory': '配饰',
'Other': '其他'
};
return categoryMap[category] || category;
};
const getStyleLabel = (style: string) => {
const styleMap: { [key: string]: string } = {
'Casual': '休闲',
'Formal': '正式',
'Business': '商务',
'Sporty': '运动',
'Vintage': '复古',
'Bohemian': '波西米亚',
'Minimalist': '极简',
'Streetwear': '街头',
'Elegant': '优雅',
'Trendy': '时尚'
};
return styleMap[style] || style;
};
const getCategoryIcon = (category: string) => {
const iconMap: { [key: string]: string } = {
'Top': '👕',
'Bottom': '👖',
'Dress': '👗',
'Outerwear': '🧥',
'Footwear': '👟',
'Accessory': '👜',
'Other': '👔'
};
return iconMap[category] || '👔';
};
const handleCardClick = () => {
onSelect(!isSelected);
};
return (
<div
className={`
relative bg-white border-2 rounded-lg shadow-sm hover:shadow-md transition-all duration-200 cursor-pointer
${isSelected
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:border-gray-300'
}
`}
onClick={handleCardClick}
>
{/* 选择状态指示器 */}
<div className="absolute top-3 right-3 z-10">
{isSelected ? (
<CheckCircleSolidIcon className="h-6 w-6 text-indigo-600" />
) : (
<div className="h-6 w-6 border-2 border-gray-300 rounded-full bg-white"></div>
)}
</div>
{/* 图片区域 */}
<div className="aspect-square bg-gray-100 rounded-t-lg relative overflow-hidden">
{item.image_urls.length > 0 ? (
<img
src={item.image_urls[0]}
alt={item.name}
className="w-full h-full object-cover"
onError={(e) => {
// 图片加载失败时显示默认图标
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
{/* 默认图标(当没有图片或图片加载失败时显示) */}
<div className={`absolute inset-0 flex items-center justify-center ${item.image_urls.length > 0 ? 'hidden' : ''}`}>
<div className="text-center">
<div className="text-4xl mb-2">
{getCategoryIcon(item.category)}
</div>
<PhotoIcon className="h-8 w-8 text-gray-400 mx-auto" />
</div>
</div>
{/* 颜色指示器 */}
<div className="absolute bottom-2 left-2">
<div
className="w-6 h-6 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: formatColorHSV(item.color_primary) }}
title="主色调"
/>
</div>
{/* 类别标签 */}
<div className="absolute top-2 left-2">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-white bg-opacity-90 text-gray-800">
{getCategoryLabel(item.category)}
</span>
</div>
</div>
{/* 内容区域 */}
<div className="p-3 space-y-2">
{/* 名称和品牌 */}
<div>
<h3 className="text-sm font-medium text-gray-900 truncate">
{item.name}
</h3>
{item.brand && (
<p className="text-xs text-gray-500 truncate">
{item.brand}
</p>
)}
</div>
{/* 风格标签 */}
{item.styles.length > 0 && (
<div className="flex flex-wrap gap-1">
{item.styles.slice(0, 2).map((style, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
>
{getStyleLabel(style)}
</span>
))}
{item.styles.length > 2 && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">
+{item.styles.length - 2}
</span>
)}
</div>
)}
{/* 自定义标签 */}
{item.tags.length > 0 && (
<div className="flex items-center space-x-1">
<TagIcon className="h-3 w-3 text-gray-400" />
<div className="flex flex-wrap gap-1">
{item.tags.slice(0, 2).map((tag, index) => (
<span
key={index}
className="text-xs text-gray-500"
>
{tag}
</span>
))}
{item.tags.length > 2 && (
<span className="text-xs text-gray-400">
+{item.tags.length - 2}
</span>
)}
</div>
</div>
)}
{/* 备注 */}
{item.notes && (
<p className="text-xs text-gray-500 line-clamp-2">
{item.notes}
</p>
)}
{/* 来源标识 */}
{item.analysis_id && (
<div className="flex items-center space-x-1 pt-1 border-t border-gray-100">
<SparklesIcon className="h-3 w-3 text-indigo-400" />
<span className="text-xs text-indigo-600">AI分析生成</span>
</div>
)}
</div>
{/* 选中状态的覆盖层 */}
{isSelected && (
<div className="absolute inset-0 bg-indigo-500 bg-opacity-10 rounded-lg pointer-events-none"></div>
)}
</div>
);
};
export default OutfitItemCard;

View File

@@ -1,497 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
XMarkIcon,
PlusIcon,
XCircleIcon
} from '@heroicons/react/24/outline';
import { invoke } from '@tauri-apps/api/core';
import { useNotifications } from '../NotificationSystem';
import { PROJECT_ID } from './const';
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> = ({
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: PROJECT_ID,
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

@@ -1,432 +0,0 @@
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';
import { PROJECT_ID } from './const';
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: PROJECT_ID,
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

@@ -1,562 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
SparklesIcon,
HeartIcon,
BookmarkIcon,
EyeIcon,
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';
import OutfitSearchPanel from './OutfitSearchPanel';
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({
categories: [] as string[],
styles: [] as string[],
occasions: [] as string[],
seasons: [] as string[],
colors: [] as string[],
minScore: 0.7,
confidenceLevel: '',
matchingTypes: [] as string[]
});
// 处理筛选器变化
const handleFiltersChange = (newFilters: any) => {
setFilters({
categories: newFilters.categories || [],
styles: newFilters.styles || [],
occasions: newFilters.occasions || [],
seasons: newFilters.seasons || [],
colors: newFilters.colors || [],
minScore: newFilters.minScore || 0.7,
confidenceLevel: newFilters.confidenceLevel || '',
matchingTypes: newFilters.matchingTypes || []
});
};
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.occasions.length > 0 ? filters.occasions[0] : null,
season_filter: filters.seasons.length > 0 ? filters.seasons[0] : null,
style_filter: filters.styles.length > 0 ? filters.styles.join(',') : 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.occasions.length > 0 && !filters.occasions.some(occasion => rec.occasion_tags.includes(occasion))) {
return false;
}
// 季节筛选
if (filters.seasons.length > 0 && !filters.seasons.some(season => rec.season_tags.includes(season))) {
return false;
}
// 风格筛选
if (filters.styles.length > 0 && !filters.styles.some(style =>
rec.style_description.toLowerCase().includes(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 && (
<OutfitSearchPanel
filters={filters}
onFiltersChange={handleFiltersChange}
onSearch={generateRecommendations}
/>
)}
{/* 推荐列表 */}
{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) => (
<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

@@ -1,330 +0,0 @@
import React, { useState } from 'react';
import {
MagnifyingGlassIcon,
FunnelIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import {CustomMultiSelect, CustomSelect} from '../CustomSelect';
interface SearchFilters {
categories?: string[];
styles?: string[];
occasions?: string[];
seasons?: string[];
colors?: string[];
minScore?: number;
confidenceLevel?: string;
matchingTypes?: string[];
}
interface OutfitSearchPanelProps {
filters: SearchFilters;
onFiltersChange: (filters: SearchFilters) => void;
onSearch: () => void;
}
const OutfitSearchPanel: React.FC<OutfitSearchPanelProps> = ({
filters,
onFiltersChange,
onSearch
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [searchText, setSearchText] = useState('');
// 预定义选项
const categoryOptions = [
{ value: 'Top', label: '上装' },
{ value: 'Bottom', label: '下装' },
{ value: 'Dress', label: '连衣裙' },
{ value: 'Outerwear', label: '外套' },
{ value: 'Footwear', label: '鞋类' },
{ value: 'Accessory', label: '配饰' }
];
const styleOptions = [
{ value: 'Casual', label: '休闲' },
{ value: 'Formal', label: '正式' },
{ value: 'Business', label: '商务' },
{ value: 'Sporty', label: '运动' },
{ value: 'Vintage', label: '复古' },
{ value: 'Bohemian', label: '波西米亚' },
{ value: 'Minimalist', label: '极简' },
{ value: 'Streetwear', label: '街头' },
{ value: 'Elegant', label: '优雅' },
{ value: 'Trendy', label: '时尚' }
];
const occasionOptions = [
{ value: 'work', label: '工作' },
{ value: 'casual', label: '日常' },
{ value: 'party', label: '聚会' },
{ value: 'date', label: '约会' },
{ value: 'travel', label: '旅行' },
{ value: 'sport', label: '运动' },
{ value: 'formal', label: '正式场合' }
];
const seasonOptions = [
{ value: 'spring', label: '春季' },
{ value: 'summer', label: '夏季' },
{ value: 'autumn', label: '秋季' },
{ value: 'winter', label: '冬季' }
];
const colorOptions = [
{ value: 'red', label: '红色' },
{ value: 'blue', label: '蓝色' },
{ value: 'green', label: '绿色' },
{ value: 'yellow', label: '黄色' },
{ value: 'purple', label: '紫色' },
{ value: 'orange', label: '橙色' },
{ value: 'pink', label: '粉色' },
{ value: 'brown', label: '棕色' },
{ value: 'black', label: '黑色' },
{ value: 'white', label: '白色' },
{ value: 'gray', label: '灰色' }
];
const confidenceLevelOptions = [
{ value: 'Low', label: '低' },
{ value: 'Medium', label: '中' },
{ value: 'High', label: '高' },
{ value: 'Excellent', label: '极佳' }
];
const matchingTypeOptions = [
{ value: 'ColorHarmony', label: '颜色和谐' },
{ value: 'StyleConsistent', label: '风格一致' },
{ value: 'Complementary', label: '互补搭配' },
{ value: 'Seasonal', label: '季节搭配' },
{ value: 'Occasion', label: '场合搭配' },
{ value: 'Trendy', label: '时尚搭配' }
];
const handleFilterChange = (key: keyof SearchFilters, value: any) => {
onFiltersChange({
...filters,
[key]: value
});
};
const clearFilters = () => {
onFiltersChange({});
setSearchText('');
};
const hasActiveFilters = () => {
return Object.keys(filters).some(key => {
const value = filters[key as keyof SearchFilters];
return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null;
});
};
const getActiveFilterCount = () => {
let count = 0;
Object.keys(filters).forEach(key => {
const value = filters[key as keyof SearchFilters];
if (Array.isArray(value) && value.length > 0) {
count += value.length;
} else if (value !== undefined && value !== null) {
count += 1;
}
});
return count;
};
return (
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-4">
{/* 搜索栏和展开按钮 */}
<div className="flex items-center space-x-3">
<div className="flex-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="搜索搭配..."
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className={`inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium ${
hasActiveFilters()
? 'text-indigo-700 bg-indigo-50 border-indigo-300'
: 'text-gray-700 bg-white hover:bg-gray-50'
}`}
>
<FunnelIcon className="h-4 w-4 mr-1" />
{hasActiveFilters() && (
<span className="ml-1 inline-flex items-center justify-center px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
{getActiveFilterCount()}
</span>
)}
</button>
<button
onClick={onSearch}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
</button>
</div>
{/* 展开的筛选面板 */}
{isExpanded && (
<div className="border-t border-gray-200 pt-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* 服装类别 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<CustomMultiSelect
options={categoryOptions}
value={filters.categories || []}
onChange={(value) => handleFilterChange('categories', value)}
placeholder="选择类别"
/>
</div>
{/* 风格 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<CustomMultiSelect
options={styleOptions}
value={filters.styles || []}
onChange={(value) => handleFilterChange('styles', value)}
placeholder="选择风格"
/>
</div>
{/* 场合 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<CustomMultiSelect
options={occasionOptions}
value={filters.occasions || []}
onChange={(value) => handleFilterChange('occasions', value)}
placeholder="选择场合"
/>
</div>
{/* 季节 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<CustomMultiSelect
options={seasonOptions}
value={filters.seasons || []}
onChange={(value) => handleFilterChange('seasons', value)}
placeholder="选择季节"
/>
</div>
{/* 颜色 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<CustomMultiSelect
options={colorOptions}
value={filters.colors || []}
onChange={(value) => handleFilterChange('colors', value)}
placeholder="选择颜色"
/>
</div>
{/* 搭配类型 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<CustomMultiSelect
options={matchingTypeOptions}
value={filters.matchingTypes || []}
onChange={(value) => handleFilterChange('matchingTypes', value)}
placeholder="选择搭配类型"
/>
</div>
</div>
{/* 评分和置信度 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 最低评分 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={filters.minScore || 0}
onChange={(e) => handleFilterChange('minScore', parseFloat(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0.0</span>
<span className="font-medium">{(filters.minScore || 0).toFixed(1)}</span>
<span>1.0</span>
</div>
</div>
{/* 置信度等级 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<CustomSelect
options={confidenceLevelOptions}
value={filters.confidenceLevel || ''}
onChange={(value) => handleFilterChange('confidenceLevel', value)}
placeholder="选择置信度等级"
/>
</div>
</div>
{/* 操作按钮 */}
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
<button
onClick={clearFilters}
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"
disabled={!hasActiveFilters()}
>
<XMarkIcon className="h-4 w-4 mr-1" />
</button>
<div className="flex space-x-2">
<button
onClick={() => setIsExpanded(false)}
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"
>
</button>
<button
onClick={onSearch}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default OutfitSearchPanel;

View File

@@ -1 +0,0 @@
export const PROJECT_ID = "gen-lang-client-0413414134"

View File

@@ -1,295 +0,0 @@
import React, { useState } from 'react';
import {
PhotoIcon,
SparklesIcon,
PlusIcon
} from '@heroicons/react/24/outline';
import { invoke } from '@tauri-apps/api/core';
// 导入设计系统样式
import '../styles/design-system.css';
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';
import { PROJECT_ID } from '../components/outfit/const';
interface OutfitMatchProps {
}
// 定义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或默认的PROJECT_ID
const currentProjectId = PROJECT_ID;
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();
// 处理图像上传
const handleImageUpload = async (files: File[]) => {
setIsUploading(true);
try {
// 为每个文件创建分析记录
for (const file of files) {
// 读取文件数据
const fileData = await file.arrayBuffer();
const uint8Array = new Uint8Array(fileData);
// 保存文件到服务器
const savedPath = await invoke('save_outfit_image', {
projectId: currentProjectId,
fileName: file.name,
fileData: Array.from(uint8Array)
});
console.log('文件保存成功:', savedPath);
// 创建分析记录
const analysisRequest = {
project_id: currentProjectId,
image_path: savedPath,
image_name: file.name
};
const analysis = await invoke('create_outfit_analysis', {
request: analysisRequest
});
console.log('创建分析记录成功:', analysis);
// 开始分析
await invoke('start_outfit_analysis', {
analysisId: (analysis as any).id
});
console.log('开始分析:', (analysis as any).id);
}
addNotification({
type: 'success',
title: '上传成功',
message: `成功上传 ${files.length} 个文件正在进行AI分析...`
});
// 切换到分析标签页
setActiveTab('analysis');
} catch (error) {
console.error('上传失败:', error);
addNotification({
type: 'error',
title: '上传失败',
message: error instanceof Error ? error.message : '上传过程中发生错误'
});
} finally {
setIsUploading(false);
}
};
// 处理创建服装单品
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">
{/* 页面头部 - 优雅的渐变背景和动画 */}
<div className="mb-8 relative overflow-hidden rounded-2xl bg-gradient-to-r from-primary-600 to-primary-700 p-8 text-white shadow-xl">
<div className="absolute inset-0 bg-black opacity-10"></div>
<div className="relative z-10">
<div className="flex items-center space-x-3 mb-4">
<div className="p-3 bg-white bg-opacity-20 rounded-xl backdrop-blur-sm">
<SparklesIcon className="h-8 w-8 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-primary-100 mt-1">AI智能分析服装搭配</p>
</div>
</div>
</div>
{/* 装饰性背景元素 */}
<div className="absolute top-0 right-0 -mt-4 -mr-4 w-24 h-24 bg-white opacity-10 rounded-full"></div>
<div className="absolute bottom-0 left-0 -mb-8 -ml-8 w-32 h-32 bg-white opacity-5 rounded-full"></div>
</div>
{/* 标签页导航 */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 mb-8">
<div className="border-b border-gray-200">
<nav className="flex space-x-8 px-6">
{[
{ key: 'upload', label: '图像上传', icon: PhotoIcon },
{ key: 'analysis', label: '分析结果', icon: SparklesIcon },
{ key: 'items', label: '服装单品', icon: PlusIcon },
{ key: 'matching', label: '智能搭配', icon: SparklesIcon }
].map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveTab(key as any)}
className={`group inline-flex items-center py-4 px-1 border-b-2 font-medium text-sm ${activeTab === key
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Icon className="h-5 w-5 mr-2" />
{label}
</button>
))}
</nav>
</div>
{/* 标签页内容 */}
<div className="p-6">
{/* 图像上传标签页 */}
{activeTab === 'upload' && (
<div className="space-y-6">
<div className="text-center mb-6">
<h3 className="text-xl font-bold text-gray-900 mb-2">
</h3>
<p className="text-gray-600">
AI将自动分析服装类别
</p>
</div>
<ImageUploader
onUpload={handleImageUpload}
isUploading={isUploading}
maxFiles={10}
maxFileSize={20}
acceptedFormats={['image/jpeg', 'image/png', 'image/webp']}
/>
</div>
)}
{/* 分析结果标签页 */}
{activeTab === 'analysis' && (
<div className="space-y-6">
<div className="text-center mb-6">
<h3 className="text-xl font-bold text-gray-900 mb-2">
AI分析结果
</h3>
<p className="text-gray-600">
AI对您上传图片的分析结果
</p>
</div>
<OutfitAnalysisResult
onCreateItems={(analysisId) => {
console.log('创建服装单品:', analysisId);
// 切换到服装单品标签页
setActiveTab('items');
}}
/>
</div>
)}
{/* 服装单品标签页 */}
{activeTab === 'items' && (
<div className="space-y-6">
<div className="text-center mb-6">
<h3 className="text-xl font-bold text-gray-900 mb-2">
</h3>
<p className="text-gray-600">
</p>
</div>
<OutfitItemList
projectId={currentProjectId}
onCreateItem={handleCreateItem}
onEditItem={handleEditItem}
/>
</div>
)}
{/* 智能搭配标签页 */}
{activeTab === 'matching' && (
<div className="space-y-6">
<div className="text-center mb-6">
<h3 className="text-xl font-bold text-gray-900 mb-2">
</h3>
<p className="text-gray-600">
AI为您推荐最佳搭配组合
</p>
</div>
<OutfitMatchingRecommendation
projectId={currentProjectId}
onSaveMatching={(recommendation) => {
console.log('保存搭配推荐:', recommendation);
addNotification({
type: 'success',
title: '搭配已保存',
message: '您可以在我的搭配中查看保存的搭配'
});
}}
/>
</div>
)}
</div>
</div>
</div>
{/* 服装单品表单 */}
<OutfitItemForm
projectId={currentProjectId}
item={editingItem || undefined}
isOpen={showItemForm}
onClose={handleFormClose}
onSave={handleFormSave}
/>
</div>
);
};
export default OutfitMatch;