fix: 移除错误代码
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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格式"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
// 初始化日志系统
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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) => 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[]) => 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export const PROJECT_ID = "gen-lang-client-0413414134"
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user