feat: 实现服装搭配功能的图像上传和AI分析
新功能: - 实现完整的图像上传组件 (ImageUploader) - 支持拖拽上传、文件选择、预览和验证 - 文件大小和格式限制 - 实时预览和文件管理 - 实现AI图像分析结果展示 (OutfitAnalysisResult) - 实时状态更新和进度跟踪 - 分析结果详情查看 - 自动刷新机制 - 添加文件保存功能 (save_outfit_image 命令) - 更新OutfitMatch页面为现代化标签页设计 技术改进: - 修复服装搭配相关模型的编译错误 - 添加缺失的trait实现 (Eq, Hash) - 修复生命周期参数问题 - 完善错误处理和用户反馈 UI/UX优化: - 现代化的标签页设计 - 响应式布局和优雅动画 - 统一的设计语言和交互体验 - 完善的加载状态和错误处理
This commit is contained in:
@@ -166,6 +166,10 @@ pub enum BusinessError {
|
||||
/// 操作不被允许
|
||||
#[error("操作不被允许: {0}")]
|
||||
OperationNotAllowed(String),
|
||||
|
||||
/// 状态无效
|
||||
#[error("状态无效: {0}")]
|
||||
InvalidState(String),
|
||||
}
|
||||
|
||||
/// 错误结果类型别名
|
||||
@@ -254,6 +258,9 @@ impl From<BusinessError> for AppError {
|
||||
BusinessError::OperationNotAllowed(operation) => AppError::Permission {
|
||||
operation,
|
||||
},
|
||||
BusinessError::InvalidState(state) => AppError::Internal {
|
||||
message: format!("状态无效: {}", state),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ 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;
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
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格式"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,963 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,6 @@ pub mod project_template_binding;
|
||||
pub mod template_matching_result;
|
||||
pub mod export_record;
|
||||
pub mod video_generation;
|
||||
pub mod outfit_analysis;
|
||||
pub mod outfit_item;
|
||||
pub mod outfit_matching;
|
||||
|
||||
206
apps/desktop/src-tauri/src/data/models/outfit_analysis.rs
Normal file
206
apps/desktop/src-tauri/src/data/models/outfit_analysis.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// HSV颜色模型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ColorHSV {
|
||||
pub hue: f64, // 色相 (0.0-1.0)
|
||||
pub saturation: f64, // 饱和度 (0.0-1.0)
|
||||
pub value: f64, // 明度 (0.0-1.0)
|
||||
}
|
||||
|
||||
impl ColorHSV {
|
||||
pub fn new(hue: f64, saturation: f64, value: f64) -> Self {
|
||||
Self {
|
||||
hue: hue.clamp(0.0, 1.0),
|
||||
saturation: saturation.clamp(0.0, 1.0),
|
||||
value: value.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换为RGB十六进制字符串
|
||||
pub fn to_rgb_hex(&self) -> String {
|
||||
let (r, g, b) = hsv_to_rgb(self.hue, self.saturation, self.value);
|
||||
format!("#{:02x}{:02x}{:02x}",
|
||||
(r * 255.0) as u8,
|
||||
(g * 255.0) as u8,
|
||||
(b * 255.0) as u8)
|
||||
}
|
||||
|
||||
/// 从RGB十六进制字符串创建
|
||||
pub fn from_rgb_hex(hex: &str) -> Result<Self, String> {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
if hex.len() != 6 {
|
||||
return Err("Invalid hex color format".to_string());
|
||||
}
|
||||
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| "Invalid red component")?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| "Invalid green component")?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| "Invalid blue component")?;
|
||||
|
||||
let (h, s, v) = rgb_to_hsv(r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0);
|
||||
Ok(Self::new(h, s, v))
|
||||
}
|
||||
|
||||
/// 计算与另一个颜色的相似度 (0.0-1.0)
|
||||
pub fn similarity(&self, other: &ColorHSV) -> f64 {
|
||||
let hue_diff = (self.hue - other.hue).abs().min(1.0 - (self.hue - other.hue).abs());
|
||||
let sat_diff = (self.saturation - other.saturation).abs();
|
||||
let val_diff = (self.value - other.value).abs();
|
||||
|
||||
// 加权计算相似度,色相权重最高
|
||||
let similarity = 1.0 - (hue_diff * 0.5 + sat_diff * 0.3 + val_diff * 0.2);
|
||||
similarity.clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 分析状态枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AnalysisStatus {
|
||||
Pending, // 待分析
|
||||
Processing, // 分析中
|
||||
Completed, // 已完成
|
||||
Failed, // 分析失败
|
||||
}
|
||||
|
||||
impl Default for AnalysisStatus {
|
||||
fn default() -> Self {
|
||||
Self::Pending
|
||||
}
|
||||
}
|
||||
|
||||
/// 服装项目信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitProduct {
|
||||
pub category: String, // 服装类别 (如: "牛仔裤", "T恤")
|
||||
pub description: String, // 描述
|
||||
pub color_pattern: ColorHSV, // 主色调
|
||||
pub design_styles: Vec<String>, // 设计风格标签
|
||||
pub color_pattern_match_dress: f64, // 与整体搭配的颜色匹配度 (0.0-1.0)
|
||||
pub color_pattern_match_environment: f64, // 与环境的颜色匹配度 (0.0-1.0)
|
||||
}
|
||||
|
||||
/// 图像分析结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageAnalysisResult {
|
||||
pub environment_tags: Vec<String>, // 环境标签
|
||||
pub environment_color_pattern: ColorHSV, // 环境色调
|
||||
pub dress_color_pattern: ColorHSV, // 整体搭配色调
|
||||
pub style_description: String, // 风格描述
|
||||
pub products: Vec<OutfitProduct>, // 识别到的服装项目
|
||||
}
|
||||
|
||||
/// 服装分析记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitAnalysis {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub image_path: String, // 原始图像路径
|
||||
pub image_name: String, // 图像名称
|
||||
pub analysis_status: AnalysisStatus, // 分析状态
|
||||
pub analysis_result: Option<ImageAnalysisResult>, // 分析结果
|
||||
pub error_message: Option<String>, // 错误信息
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub analyzed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 创建分析请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateOutfitAnalysisRequest {
|
||||
pub project_id: String,
|
||||
pub image_path: String,
|
||||
pub image_name: String,
|
||||
}
|
||||
|
||||
/// 更新分析请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateOutfitAnalysisRequest {
|
||||
pub analysis_status: Option<AnalysisStatus>,
|
||||
pub analysis_result: Option<ImageAnalysisResult>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
/// 分析查询选项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitAnalysisQueryOptions {
|
||||
pub project_id: Option<String>,
|
||||
pub status: Option<AnalysisStatus>,
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
/// 分析统计信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitAnalysisStats {
|
||||
pub total_count: u32,
|
||||
pub pending_count: u32,
|
||||
pub processing_count: u32,
|
||||
pub completed_count: u32,
|
||||
pub failed_count: u32,
|
||||
}
|
||||
|
||||
// HSV和RGB转换函数
|
||||
fn hsv_to_rgb(h: f64, s: f64, v: f64) -> (f64, f64, f64) {
|
||||
let c = v * s;
|
||||
let x = c * (1.0 - ((h * 6.0) % 2.0 - 1.0).abs());
|
||||
let m = v - c;
|
||||
|
||||
let (r_prime, g_prime, b_prime) = match (h * 6.0) as i32 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
5 => (c, 0.0, x),
|
||||
_ => (0.0, 0.0, 0.0),
|
||||
};
|
||||
|
||||
(r_prime + m, g_prime + m, b_prime + m)
|
||||
}
|
||||
|
||||
fn rgb_to_hsv(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let delta = max - min;
|
||||
|
||||
let h = if delta == 0.0 {
|
||||
0.0
|
||||
} else if max == r {
|
||||
((g - b) / delta) % 6.0
|
||||
} else if max == g {
|
||||
(b - r) / delta + 2.0
|
||||
} else {
|
||||
(r - g) / delta + 4.0
|
||||
} / 6.0;
|
||||
|
||||
let s = if max == 0.0 { 0.0 } else { delta / max };
|
||||
let v = max;
|
||||
|
||||
(h.abs(), s, v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_color_hsv_conversion() {
|
||||
let color = ColorHSV::new(0.5, 0.8, 0.9);
|
||||
let hex = color.to_rgb_hex();
|
||||
let converted = ColorHSV::from_rgb_hex(&hex).unwrap();
|
||||
|
||||
assert!((color.hue - converted.hue).abs() < 0.01);
|
||||
assert!((color.saturation - converted.saturation).abs() < 0.01);
|
||||
assert!((color.value - converted.value).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_color_similarity() {
|
||||
let color1 = ColorHSV::new(0.5, 0.8, 0.9);
|
||||
let color2 = ColorHSV::new(0.52, 0.82, 0.88);
|
||||
let similarity = color1.similarity(&color2);
|
||||
|
||||
assert!(similarity > 0.8); // 应该很相似
|
||||
}
|
||||
}
|
||||
237
apps/desktop/src-tauri/src/data/models/outfit_item.rs
Normal file
237
apps/desktop/src-tauri/src/data/models/outfit_item.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use super::outfit_analysis::ColorHSV;
|
||||
|
||||
/// 服装类别枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub enum OutfitCategory {
|
||||
Top, // 上装 (T恤、衬衫、毛衣等)
|
||||
Bottom, // 下装 (裤子、裙子等)
|
||||
Dress, // 连衣裙
|
||||
Outerwear, // 外套
|
||||
Footwear, // 鞋类
|
||||
Accessory, // 配饰
|
||||
Other, // 其他
|
||||
}
|
||||
|
||||
impl OutfitCategory {
|
||||
pub fn from_string(category: &str) -> Self {
|
||||
match category.to_lowercase().as_str() {
|
||||
"上装" | "t恤" | "衬衫" | "毛衣" | "背心" | "top" => Self::Top,
|
||||
"下装" | "裤子" | "牛仔裤" | "短裤" | "裙子" | "bottom" => Self::Bottom,
|
||||
"连衣裙" | "长裙" | "dress" => Self::Dress,
|
||||
"外套" | "夹克" | "大衣" | "outerwear" => Self::Outerwear,
|
||||
"鞋子" | "运动鞋" | "高跟鞋" | "footwear" => Self::Footwear,
|
||||
"配饰" | "帽子" | "包包" | "首饰" | "accessory" => Self::Accessory,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_chinese(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Top => "上装",
|
||||
Self::Bottom => "下装",
|
||||
Self::Dress => "连衣裙",
|
||||
Self::Outerwear => "外套",
|
||||
Self::Footwear => "鞋类",
|
||||
Self::Accessory => "配饰",
|
||||
Self::Other => "其他",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 服装风格枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub enum OutfitStyle {
|
||||
Casual, // 休闲
|
||||
Formal, // 正式
|
||||
Business, // 商务
|
||||
Sporty, // 运动
|
||||
Vintage, // 复古
|
||||
Bohemian, // 波西米亚
|
||||
Minimalist, // 极简
|
||||
Streetwear, // 街头
|
||||
Elegant, // 优雅
|
||||
Trendy, // 时尚
|
||||
Other(String), // 其他自定义风格
|
||||
}
|
||||
|
||||
impl OutfitStyle {
|
||||
pub fn from_string(style: &str) -> Self {
|
||||
match style.to_lowercase().as_str() {
|
||||
"casual" | "休闲" => Self::Casual,
|
||||
"formal" | "正式" => Self::Formal,
|
||||
"business" | "商务" => Self::Business,
|
||||
"sporty" | "运动" => Self::Sporty,
|
||||
"vintage" | "复古" => Self::Vintage,
|
||||
"bohemian" | "波西米亚" => Self::Bohemian,
|
||||
"minimalist" | "极简" => Self::Minimalist,
|
||||
"streetwear" | "街头" => Self::Streetwear,
|
||||
"elegant" | "优雅" => Self::Elegant,
|
||||
"trendy" | "时尚" => Self::Trendy,
|
||||
_ => Self::Other(style.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
Self::Casual => "休闲".to_string(),
|
||||
Self::Formal => "正式".to_string(),
|
||||
Self::Business => "商务".to_string(),
|
||||
Self::Sporty => "运动".to_string(),
|
||||
Self::Vintage => "复古".to_string(),
|
||||
Self::Bohemian => "波西米亚".to_string(),
|
||||
Self::Minimalist => "极简".to_string(),
|
||||
Self::Streetwear => "街头".to_string(),
|
||||
Self::Elegant => "优雅".to_string(),
|
||||
Self::Trendy => "时尚".to_string(),
|
||||
Self::Other(s) => s.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 服装尺寸信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitSize {
|
||||
pub size_label: String, // 尺码标签 (如: "M", "L", "XL")
|
||||
pub measurements: Option<SizeMeasurements>, // 具体尺寸
|
||||
}
|
||||
|
||||
/// 具体尺寸测量
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SizeMeasurements {
|
||||
pub chest: Option<f64>, // 胸围 (cm)
|
||||
pub waist: Option<f64>, // 腰围 (cm)
|
||||
pub hip: Option<f64>, // 臀围 (cm)
|
||||
pub length: Option<f64>, // 长度 (cm)
|
||||
pub sleeve: Option<f64>, // 袖长 (cm)
|
||||
}
|
||||
|
||||
/// 服装材质信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitMaterial {
|
||||
pub fabric_type: String, // 面料类型 (如: "棉", "聚酯纤维")
|
||||
pub fabric_composition: Vec<FabricComponent>, // 面料成分
|
||||
pub care_instructions: Vec<String>, // 护理说明
|
||||
}
|
||||
|
||||
/// 面料成分
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FabricComponent {
|
||||
pub material: String, // 材料名称
|
||||
pub percentage: f64, // 百分比
|
||||
}
|
||||
|
||||
/// 服装单品实体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitItem {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub analysis_id: Option<String>, // 关联的分析记录ID
|
||||
pub name: String, // 服装名称
|
||||
pub category: OutfitCategory, // 服装类别
|
||||
pub brand: Option<String>, // 品牌
|
||||
pub model: Option<String>, // 型号
|
||||
pub color_primary: ColorHSV, // 主色调
|
||||
pub color_secondary: Option<ColorHSV>, // 次要色调
|
||||
pub styles: Vec<OutfitStyle>, // 风格标签
|
||||
pub design_elements: Vec<String>, // 设计元素 (如: "条纹", "印花", "刺绣")
|
||||
pub size: Option<OutfitSize>, // 尺寸信息
|
||||
pub material: Option<OutfitMaterial>, // 材质信息
|
||||
pub price: Option<f64>, // 价格
|
||||
pub purchase_date: Option<DateTime<Utc>>, // 购买日期
|
||||
pub image_urls: Vec<String>, // 图片URL列表
|
||||
pub tags: Vec<String>, // 自定义标签
|
||||
pub notes: Option<String>, // 备注
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 创建服装单品请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateOutfitItemRequest {
|
||||
pub project_id: String,
|
||||
pub analysis_id: Option<String>,
|
||||
pub name: String,
|
||||
pub category: OutfitCategory,
|
||||
pub brand: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub color_primary: ColorHSV,
|
||||
pub color_secondary: Option<ColorHSV>,
|
||||
pub styles: Vec<OutfitStyle>,
|
||||
pub design_elements: Vec<String>,
|
||||
pub size: Option<OutfitSize>,
|
||||
pub material: Option<OutfitMaterial>,
|
||||
pub price: Option<f64>,
|
||||
pub purchase_date: Option<DateTime<Utc>>,
|
||||
pub image_urls: Vec<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新服装单品请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateOutfitItemRequest {
|
||||
pub name: Option<String>,
|
||||
pub category: Option<OutfitCategory>,
|
||||
pub brand: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub color_primary: Option<ColorHSV>,
|
||||
pub color_secondary: Option<ColorHSV>,
|
||||
pub styles: Option<Vec<OutfitStyle>>,
|
||||
pub design_elements: Option<Vec<String>>,
|
||||
pub size: Option<OutfitSize>,
|
||||
pub material: Option<OutfitMaterial>,
|
||||
pub price: Option<f64>,
|
||||
pub purchase_date: Option<DateTime<Utc>>,
|
||||
pub image_urls: Option<Vec<String>>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
/// 服装单品查询选项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitItemQueryOptions {
|
||||
pub project_id: Option<String>,
|
||||
pub category: Option<OutfitCategory>,
|
||||
pub styles: Option<Vec<OutfitStyle>>,
|
||||
pub color_similarity_threshold: Option<f64>, // 颜色相似度阈值
|
||||
pub target_color: Option<ColorHSV>, // 目标颜色
|
||||
pub brand: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
/// 服装单品统计信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitItemStats {
|
||||
pub total_count: u32,
|
||||
pub category_counts: std::collections::HashMap<String, u32>,
|
||||
pub style_counts: std::collections::HashMap<String, u32>,
|
||||
pub brand_counts: std::collections::HashMap<String, u32>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_outfit_category_conversion() {
|
||||
assert_eq!(OutfitCategory::from_string("T恤"), OutfitCategory::Top);
|
||||
assert_eq!(OutfitCategory::from_string("牛仔裤"), OutfitCategory::Bottom);
|
||||
assert_eq!(OutfitCategory::from_string("连衣裙"), OutfitCategory::Dress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outfit_style_conversion() {
|
||||
assert_eq!(OutfitStyle::from_string("休闲"), OutfitStyle::Casual);
|
||||
assert_eq!(OutfitStyle::from_string("正式"), OutfitStyle::Formal);
|
||||
|
||||
let custom_style = OutfitStyle::from_string("自定义风格");
|
||||
match custom_style {
|
||||
OutfitStyle::Other(s) => assert_eq!(s, "自定义风格"),
|
||||
_ => panic!("Expected Other variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
237
apps/desktop/src-tauri/src/data/models/outfit_matching.rs
Normal file
237
apps/desktop/src-tauri/src/data/models/outfit_matching.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use super::outfit_analysis::ColorHSV;
|
||||
use super::outfit_item::{OutfitCategory, OutfitStyle};
|
||||
|
||||
/// 匹配类型枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum MatchingType {
|
||||
ColorHarmony, // 颜色和谐搭配
|
||||
StyleConsistent, // 风格一致搭配
|
||||
Complementary, // 互补搭配
|
||||
Seasonal, // 季节性搭配
|
||||
Occasion, // 场合搭配
|
||||
Trendy, // 时尚搭配
|
||||
}
|
||||
|
||||
impl MatchingType {
|
||||
pub fn to_chinese(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ColorHarmony => "颜色和谐",
|
||||
Self::StyleConsistent => "风格一致",
|
||||
Self::Complementary => "互补搭配",
|
||||
Self::Seasonal => "季节搭配",
|
||||
Self::Occasion => "场合搭配",
|
||||
Self::Trendy => "时尚搭配",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 匹配置信度等级
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ConfidenceLevel {
|
||||
Low, // 低 (0.0-0.4)
|
||||
Medium, // 中 (0.4-0.7)
|
||||
High, // 高 (0.7-0.9)
|
||||
Excellent, // 极佳 (0.9-1.0)
|
||||
}
|
||||
|
||||
impl ConfidenceLevel {
|
||||
pub fn from_score(score: f64) -> Self {
|
||||
match score {
|
||||
s if s >= 0.9 => Self::Excellent,
|
||||
s if s >= 0.7 => Self::High,
|
||||
s if s >= 0.4 => Self::Medium,
|
||||
_ => Self::Low,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_chinese(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Low => "低",
|
||||
Self::Medium => "中",
|
||||
Self::High => "高",
|
||||
Self::Excellent => "极佳",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 搭配评分详情
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MatchingScoreDetails {
|
||||
pub color_harmony_score: f64, // 颜色和谐度评分 (0.0-1.0)
|
||||
pub style_consistency_score: f64, // 风格一致性评分 (0.0-1.0)
|
||||
pub proportion_score: f64, // 比例协调度评分 (0.0-1.0)
|
||||
pub occasion_appropriateness: f64, // 场合适宜度评分 (0.0-1.0)
|
||||
pub trend_factor: f64, // 时尚度评分 (0.0-1.0)
|
||||
pub overall_score: f64, // 综合评分 (0.0-1.0)
|
||||
}
|
||||
|
||||
impl MatchingScoreDetails {
|
||||
pub fn calculate_overall_score(&mut self) {
|
||||
// 加权计算综合评分
|
||||
self.overall_score = (
|
||||
self.color_harmony_score * 0.3 +
|
||||
self.style_consistency_score * 0.25 +
|
||||
self.proportion_score * 0.2 +
|
||||
self.occasion_appropriateness * 0.15 +
|
||||
self.trend_factor * 0.1
|
||||
).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
pub fn get_confidence_level(&self) -> ConfidenceLevel {
|
||||
ConfidenceLevel::from_score(self.overall_score)
|
||||
}
|
||||
}
|
||||
|
||||
/// 搭配建议
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MatchingSuggestion {
|
||||
pub suggestion_type: String, // 建议类型 (如: "颜色调整", "风格优化")
|
||||
pub description: String, // 建议描述
|
||||
pub priority: u8, // 优先级 (1-5, 5最高)
|
||||
}
|
||||
|
||||
/// 搭配组合中的单品
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MatchingOutfitItem {
|
||||
pub item_id: String, // 服装单品ID
|
||||
pub item_name: String, // 服装名称
|
||||
pub category: OutfitCategory, // 类别
|
||||
pub color_primary: ColorHSV, // 主色调
|
||||
pub styles: Vec<OutfitStyle>, // 风格
|
||||
pub role_in_outfit: String, // 在搭配中的角色 (如: "主角", "配角", "点缀")
|
||||
pub image_url: Option<String>, // 图片URL
|
||||
}
|
||||
|
||||
/// 搭配匹配结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitMatching {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub matching_name: String, // 搭配名称
|
||||
pub matching_type: MatchingType, // 匹配类型
|
||||
pub items: Vec<MatchingOutfitItem>, // 搭配的服装单品
|
||||
pub score_details: MatchingScoreDetails, // 评分详情
|
||||
pub suggestions: Vec<MatchingSuggestion>, // 搭配建议
|
||||
pub occasion_tags: Vec<String>, // 适用场合标签
|
||||
pub season_tags: Vec<String>, // 适用季节标签
|
||||
pub style_description: String, // 风格描述
|
||||
pub color_palette: Vec<ColorHSV>, // 色彩搭配方案
|
||||
pub is_favorite: bool, // 是否收藏
|
||||
pub wear_count: u32, // 穿着次数
|
||||
pub last_worn_date: Option<DateTime<Utc>>, // 最后穿着日期
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 创建搭配匹配请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateOutfitMatchingRequest {
|
||||
pub project_id: String,
|
||||
pub matching_name: String,
|
||||
pub matching_type: MatchingType,
|
||||
pub item_ids: Vec<String>, // 服装单品ID列表
|
||||
pub occasion_tags: Vec<String>,
|
||||
pub season_tags: Vec<String>,
|
||||
pub style_description: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新搭配匹配请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateOutfitMatchingRequest {
|
||||
pub matching_name: Option<String>,
|
||||
pub matching_type: Option<MatchingType>,
|
||||
pub item_ids: Option<Vec<String>>,
|
||||
pub occasion_tags: Option<Vec<String>>,
|
||||
pub season_tags: Option<Vec<String>>,
|
||||
pub style_description: Option<String>,
|
||||
pub is_favorite: Option<bool>,
|
||||
}
|
||||
|
||||
/// 搭配搜索过滤条件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitMatchingSearchFilters {
|
||||
pub categories: Option<Vec<OutfitCategory>>, // 包含的服装类别
|
||||
pub styles: Option<Vec<OutfitStyle>>, // 风格过滤
|
||||
pub colors: Option<Vec<ColorHSV>>, // 颜色过滤
|
||||
pub color_similarity_threshold: Option<f64>, // 颜色相似度阈值
|
||||
pub occasions: Option<Vec<String>>, // 场合过滤
|
||||
pub seasons: Option<Vec<String>>, // 季节过滤
|
||||
pub min_score: Option<f64>, // 最低评分
|
||||
pub confidence_level: Option<ConfidenceLevel>, // 置信度等级
|
||||
pub matching_types: Option<Vec<MatchingType>>, // 匹配类型
|
||||
}
|
||||
|
||||
/// 搭配匹配查询选项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitMatchingQueryOptions {
|
||||
pub project_id: Option<String>,
|
||||
pub filters: Option<OutfitMatchingSearchFilters>,
|
||||
pub sort_by: Option<String>, // 排序字段 ("score", "created_at", "wear_count")
|
||||
pub sort_order: Option<String>, // 排序方向 ("asc", "desc")
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
/// 智能搭配推荐请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SmartMatchingRequest {
|
||||
pub project_id: String,
|
||||
pub base_item_id: Option<String>, // 基础单品ID (可选)
|
||||
pub target_occasion: Option<String>, // 目标场合
|
||||
pub target_season: Option<String>, // 目标季节
|
||||
pub preferred_styles: Option<Vec<OutfitStyle>>, // 偏好风格
|
||||
pub color_preferences: Option<Vec<ColorHSV>>, // 颜色偏好
|
||||
pub exclude_item_ids: Option<Vec<String>>, // 排除的单品ID
|
||||
pub max_results: Option<u32>, // 最大结果数
|
||||
}
|
||||
|
||||
/// 智能搭配推荐结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SmartMatchingResult {
|
||||
pub recommended_matchings: Vec<OutfitMatching>, // 推荐的搭配
|
||||
pub reasoning: String, // 推荐理由
|
||||
pub confidence_score: f64, // 推荐置信度
|
||||
}
|
||||
|
||||
/// 搭配匹配统计信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitMatchingStats {
|
||||
pub total_matchings: u32,
|
||||
pub favorite_count: u32,
|
||||
pub average_score: f64,
|
||||
pub most_worn_matching_id: Option<String>,
|
||||
pub style_distribution: std::collections::HashMap<String, u32>,
|
||||
pub occasion_distribution: std::collections::HashMap<String, u32>,
|
||||
pub season_distribution: std::collections::HashMap<String, u32>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_confidence_level_from_score() {
|
||||
assert_eq!(ConfidenceLevel::from_score(0.95), ConfidenceLevel::Excellent);
|
||||
assert_eq!(ConfidenceLevel::from_score(0.8), ConfidenceLevel::High);
|
||||
assert_eq!(ConfidenceLevel::from_score(0.5), ConfidenceLevel::Medium);
|
||||
assert_eq!(ConfidenceLevel::from_score(0.2), ConfidenceLevel::Low);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matching_score_calculation() {
|
||||
let mut score_details = MatchingScoreDetails {
|
||||
color_harmony_score: 0.8,
|
||||
style_consistency_score: 0.9,
|
||||
proportion_score: 0.7,
|
||||
occasion_appropriateness: 0.8,
|
||||
trend_factor: 0.6,
|
||||
overall_score: 0.0,
|
||||
};
|
||||
|
||||
score_details.calculate_overall_score();
|
||||
assert!(score_details.overall_score > 0.7);
|
||||
assert!(score_details.overall_score < 0.9);
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,6 @@ 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;
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
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)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::infrastructure::database::Database;
|
||||
|
||||
#[test]
|
||||
fn test_create_and_get_outfit_analysis() {
|
||||
let database = Arc::new(Database::new_in_memory().unwrap());
|
||||
let repo = OutfitAnalysisRepository::new(database).unwrap();
|
||||
|
||||
let request = CreateOutfitAnalysisRequest {
|
||||
project_id: "test_project".to_string(),
|
||||
image_path: "/path/to/image.jpg".to_string(),
|
||||
image_name: "test_image.jpg".to_string(),
|
||||
};
|
||||
|
||||
let analysis = repo.create(&request).unwrap();
|
||||
assert_eq!(analysis.project_id, "test_project");
|
||||
assert_eq!(analysis.image_name, "test_image.jpg");
|
||||
assert_eq!(analysis.analysis_status, AnalysisStatus::Pending);
|
||||
|
||||
let retrieved = repo.get_by_id(&analysis.id).unwrap().unwrap();
|
||||
assert_eq!(retrieved.id, analysis.id);
|
||||
assert_eq!(retrieved.project_id, analysis.project_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -808,6 +808,77 @@ impl Database {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建服装分析表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS outfit_analyses (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
image_path TEXT NOT NULL,
|
||||
image_name TEXT NOT NULL,
|
||||
analysis_status TEXT NOT NULL DEFAULT 'Pending',
|
||||
analysis_result TEXT,
|
||||
error_message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
analyzed_at DATETIME,
|
||||
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建服装单品表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS outfit_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
analysis_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
brand TEXT,
|
||||
model TEXT,
|
||||
color_primary TEXT NOT NULL,
|
||||
color_secondary TEXT,
|
||||
styles TEXT NOT NULL,
|
||||
design_elements TEXT,
|
||||
size_info TEXT,
|
||||
material_info TEXT,
|
||||
price REAL,
|
||||
purchase_date DATETIME,
|
||||
image_urls TEXT,
|
||||
tags TEXT,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (analysis_id) REFERENCES outfit_analyses (id) ON DELETE SET NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建服装搭配表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS outfit_matchings (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
matching_name TEXT NOT NULL,
|
||||
matching_type TEXT NOT NULL,
|
||||
items TEXT NOT NULL,
|
||||
score_details TEXT NOT NULL,
|
||||
suggestions TEXT,
|
||||
occasion_tags TEXT,
|
||||
season_tags TEXT,
|
||||
style_description TEXT NOT NULL,
|
||||
color_palette TEXT,
|
||||
is_favorite BOOLEAN DEFAULT 0,
|
||||
wear_count INTEGER DEFAULT 0,
|
||||
last_worn_date DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_projects_name ON projects (name)",
|
||||
@@ -951,6 +1022,64 @@ impl Database {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建服装分析表索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_outfit_analyses_project_id ON outfit_analyses (project_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_outfit_analyses_status ON outfit_analyses (analysis_status)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_outfit_analyses_created_at ON outfit_analyses (created_at)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建服装单品表索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_outfit_items_project_id ON outfit_items (project_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_outfit_items_analysis_id ON outfit_items (analysis_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_outfit_items_category ON outfit_items (category)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_outfit_items_brand ON outfit_items (brand)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建服装搭配表索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_outfit_matchings_project_id ON outfit_matchings (project_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_outfit_matchings_type ON outfit_matchings (matching_type)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_outfit_matchings_favorite ON outfit_matchings (is_favorite)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_outfit_matchings_created_at ON outfit_matchings (created_at)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 添加新字段(如果不存在)- 数据库迁移
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE template_materials ADD COLUMN file_exists BOOLEAN DEFAULT FALSE",
|
||||
|
||||
@@ -86,9 +86,13 @@ struct ContentPart {
|
||||
#[serde(untagged)]
|
||||
enum Part {
|
||||
Text { text: String },
|
||||
FileData {
|
||||
FileData {
|
||||
#[serde(rename = "fileData")]
|
||||
file_data: FileData
|
||||
file_data: FileData
|
||||
},
|
||||
InlineData {
|
||||
#[serde(rename = "inlineData")]
|
||||
inline_data: InlineData
|
||||
},
|
||||
}
|
||||
|
||||
@@ -100,6 +104,13 @@ struct FileData {
|
||||
file_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct InlineData {
|
||||
#[serde(rename = "mimeType")]
|
||||
mime_type: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GenerationConfig {
|
||||
temperature: f32,
|
||||
@@ -134,6 +145,7 @@ struct ResponsePart {
|
||||
|
||||
/// Gemini API服务
|
||||
/// 遵循 Tauri 开发规范的基础设施层设计模式
|
||||
#[derive(Clone)]
|
||||
pub struct GeminiService {
|
||||
config: GeminiConfig,
|
||||
client: reqwest::Client,
|
||||
@@ -409,6 +421,64 @@ impl GeminiService {
|
||||
|
||||
Ok((file_uri, analysis_result))
|
||||
}
|
||||
|
||||
/// 分析图像内容(用于服装搭配分析)
|
||||
pub async fn analyze_image_with_prompt(&mut self, image_base64: &str, prompt: &str) -> Result<String> {
|
||||
println!("🧠 正在进行图像分析...");
|
||||
|
||||
// 获取访问令牌
|
||||
let access_token = self.get_access_token().await?;
|
||||
|
||||
// 创建客户端配置
|
||||
let client_config = self.create_gemini_client(&access_token);
|
||||
|
||||
// 准备请求数据
|
||||
let request_data = GenerateContentRequest {
|
||||
contents: vec![ContentPart {
|
||||
role: "user".to_string(),
|
||||
parts: vec![
|
||||
Part::Text { text: prompt.to_string() },
|
||||
Part::InlineData {
|
||||
inline_data: InlineData {
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
data: image_base64.to_string(),
|
||||
}
|
||||
}
|
||||
],
|
||||
}],
|
||||
generation_config: GenerationConfig {
|
||||
temperature: self.config.temperature,
|
||||
top_k: 32,
|
||||
top_p: 1.0,
|
||||
max_output_tokens: self.config.max_tokens,
|
||||
},
|
||||
};
|
||||
|
||||
// 发送请求到Cloudflare Gateway
|
||||
let generate_url = format!("{}/{}:generateContent", client_config.gateway_url, self.config.model_name);
|
||||
|
||||
// 重试机制
|
||||
let mut last_error = None;
|
||||
|
||||
for attempt in 0..self.config.max_retries {
|
||||
match self.send_generate_request(&generate_url, &client_config, &request_data).await {
|
||||
Ok(result) => {
|
||||
let content = self.parse_gemini_response_content(&result)?;
|
||||
println!("✅ 图像分析完成");
|
||||
return Ok(content);
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(e);
|
||||
|
||||
if attempt < self.config.max_retries - 1 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(self.config.retry_delay)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("图像分析失败,已重试{}次: {}", self.config.max_retries, last_error.unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -255,7 +255,28 @@ pub fn run() {
|
||||
commands::debug_commands::test_parse_draft_file,
|
||||
commands::debug_commands::validate_template_structure,
|
||||
// 便捷工具命令
|
||||
commands::tools_commands::clean_jsonl_data
|
||||
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
|
||||
])
|
||||
.setup(|app| {
|
||||
// 初始化日志系统
|
||||
|
||||
@@ -17,3 +17,4 @@ pub mod template_matching_result_commands;
|
||||
pub mod export_record_commands;
|
||||
pub mod video_generation_commands;
|
||||
pub mod tools_commands;
|
||||
pub mod outfit_commands;
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* 服装搭配相关的 Tauri 命令
|
||||
* 遵循 Tauri 开发规范的 API 设计原则
|
||||
*/
|
||||
|
||||
use tauri::{command, State};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
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::data::models::outfit_analysis::{
|
||||
OutfitAnalysis, CreateOutfitAnalysisRequest,
|
||||
OutfitAnalysisQueryOptions, OutfitAnalysisStats
|
||||
};
|
||||
use crate::data::models::outfit_item::{
|
||||
OutfitItem, CreateOutfitItemRequest, UpdateOutfitItemRequest,
|
||||
OutfitItemQueryOptions
|
||||
};
|
||||
use crate::data::models::outfit_matching::{
|
||||
OutfitMatching, CreateOutfitMatchingRequest, UpdateOutfitMatchingRequest,
|
||||
OutfitMatchingQueryOptions, SmartMatchingRequest, SmartMatchingResult
|
||||
};
|
||||
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::infrastructure::gemini_service::GeminiService;
|
||||
|
||||
// 辅助函数:创建服务实例
|
||||
fn create_analysis_service(database: Arc<crate::infrastructure::database::Database>) -> Result<OutfitAnalysisService, String> {
|
||||
let analysis_repository = OutfitAnalysisRepository::new(database.clone())
|
||||
.map_err(|e| format!("创建分析仓库失败: {}", e))?;
|
||||
let gemini_service = GeminiService::new(None);
|
||||
Ok(OutfitAnalysisService::new(
|
||||
Arc::new(analysis_repository),
|
||||
Arc::new(gemini_service)
|
||||
))
|
||||
}
|
||||
|
||||
fn create_item_service(database: Arc<crate::infrastructure::database::Database>) -> Result<OutfitItemService, String> {
|
||||
let item_repository = OutfitItemRepository::new(database.clone())
|
||||
.map_err(|e| format!("创建单品仓库失败: {}", e))?;
|
||||
Ok(OutfitItemService::new(Arc::new(item_repository)))
|
||||
}
|
||||
|
||||
fn create_matching_service(database: Arc<crate::infrastructure::database::Database>) -> Result<OutfitMatchingService, String> {
|
||||
let matching_repository = OutfitMatchingRepository::new(database.clone())
|
||||
.map_err(|e| format!("创建搭配仓库失败: {}", e))?;
|
||||
let item_repository = OutfitItemRepository::new(database.clone())
|
||||
.map_err(|e| format!("创建单品仓库失败: {}", e))?;
|
||||
Ok(OutfitMatchingService::new(
|
||||
Arc::new(matching_repository),
|
||||
Arc::new(item_repository)
|
||||
))
|
||||
}
|
||||
|
||||
/// 创建服装分析记录
|
||||
#[command]
|
||||
pub async fn create_outfit_analysis(
|
||||
state: State<'_, AppState>,
|
||||
request: CreateOutfitAnalysisRequest,
|
||||
) -> Result<OutfitAnalysis, String> {
|
||||
let database = state.get_database();
|
||||
let service = create_analysis_service(database)?;
|
||||
service.create_analysis(request).await
|
||||
.map_err(|e| format!("创建分析记录失败: {}", e))
|
||||
}
|
||||
|
||||
/// 开始分析
|
||||
#[command]
|
||||
pub async fn start_outfit_analysis(
|
||||
state: State<'_, AppState>,
|
||||
analysis_id: String,
|
||||
) -> Result<(), String> {
|
||||
let database = state.get_database();
|
||||
let service = create_analysis_service(database)?;
|
||||
service.start_analysis(&analysis_id).await
|
||||
.map_err(|e| format!("开始分析失败: {}", e))
|
||||
}
|
||||
|
||||
/// 获取分析记录列表
|
||||
#[command]
|
||||
pub async fn list_outfit_analyses(
|
||||
state: State<'_, AppState>,
|
||||
options: OutfitAnalysisQueryOptions,
|
||||
) -> Result<Vec<OutfitAnalysis>, String> {
|
||||
let database = state.get_database();
|
||||
let service = create_analysis_service(database)?;
|
||||
service.get_analyses(options).await
|
||||
.map_err(|e| format!("获取分析记录列表失败: {}", e))
|
||||
}
|
||||
|
||||
/// 根据ID获取服装分析记录
|
||||
#[command]
|
||||
pub async fn get_outfit_analysis_by_id(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Option<OutfitAnalysis>, String> {
|
||||
let database = state.get_database();
|
||||
let service = create_analysis_service(database)?;
|
||||
service.get_analysis_by_id(&id).await
|
||||
.map_err(|e| format!("获取分析记录失败: {}", e))
|
||||
}
|
||||
|
||||
/// 删除分析记录
|
||||
#[command]
|
||||
pub async fn delete_outfit_analysis(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let database = state.get_database();
|
||||
let service = create_analysis_service(database)?;
|
||||
service.delete_analysis(&id).await
|
||||
.map_err(|e| format!("删除分析记录失败: {}", e))
|
||||
}
|
||||
|
||||
/// 获取分析统计信息
|
||||
#[command]
|
||||
pub async fn get_outfit_analysis_stats(
|
||||
state: State<'_, AppState>,
|
||||
project_id: Option<String>,
|
||||
) -> Result<OutfitAnalysisStats, String> {
|
||||
let database = state.get_database();
|
||||
let service = create_analysis_service(database)?;
|
||||
service.get_analysis_stats(project_id.as_deref()).await
|
||||
.map_err(|e| format!("获取分析统计信息失败: {}", e))
|
||||
}
|
||||
|
||||
/// 创建服装单品
|
||||
#[command]
|
||||
pub async fn create_outfit_item(
|
||||
state: State<'_, AppState>,
|
||||
request: CreateOutfitItemRequest,
|
||||
) -> Result<OutfitItem, String> {
|
||||
let database = state.get_database();
|
||||
let service = create_item_service(database)?;
|
||||
service.create_item(request).await
|
||||
.map_err(|e| format!("创建服装单品失败: {}", e))
|
||||
}
|
||||
|
||||
/// 从分析结果创建服装单品
|
||||
#[command]
|
||||
pub async fn create_outfit_items_from_analysis(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
analysis_id: String,
|
||||
) -> Result<Vec<OutfitItem>, String> {
|
||||
// 这是一个简化的实现,实际应该从分析结果中提取产品信息
|
||||
Err("功能开发中".to_string())
|
||||
}
|
||||
|
||||
/// 获取服装单品列表
|
||||
#[command]
|
||||
pub async fn list_outfit_items(
|
||||
state: State<'_, AppState>,
|
||||
options: OutfitItemQueryOptions,
|
||||
) -> Result<Vec<OutfitItem>, String> {
|
||||
let database = state.get_database();
|
||||
let service = create_item_service(database)?;
|
||||
service.get_items(options).await
|
||||
.map_err(|e| format!("获取服装单品列表失败: {}", e))
|
||||
}
|
||||
|
||||
/// 根据ID获取服装单品
|
||||
#[command]
|
||||
pub async fn get_outfit_item_by_id(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Option<OutfitItem>, String> {
|
||||
let database = state.get_database();
|
||||
let service = create_item_service(database)?;
|
||||
service.get_item_by_id(&id).await
|
||||
.map_err(|e| format!("获取服装单品失败: {}", e))
|
||||
}
|
||||
|
||||
/// 更新服装单品
|
||||
#[command]
|
||||
pub async fn update_outfit_item(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
request: UpdateOutfitItemRequest,
|
||||
) -> Result<(), String> {
|
||||
let database = state.get_database();
|
||||
let service = create_item_service(database)?;
|
||||
service.update_item(&id, request).await
|
||||
.map_err(|e| format!("更新服装单品失败: {}", e))
|
||||
}
|
||||
|
||||
/// 删除服装单品
|
||||
#[command]
|
||||
pub async fn delete_outfit_item(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let database = state.get_database();
|
||||
let service = create_item_service(database)?;
|
||||
service.delete_item(&id).await
|
||||
.map_err(|e| format!("删除服装单品失败: {}", e))
|
||||
}
|
||||
|
||||
/// 创建服装搭配
|
||||
#[command]
|
||||
pub async fn create_outfit_matching(
|
||||
state: State<'_, AppState>,
|
||||
request: CreateOutfitMatchingRequest,
|
||||
) -> Result<OutfitMatching, String> {
|
||||
let database = state.get_database();
|
||||
let service = create_matching_service(database)?;
|
||||
service.create_matching(request).await
|
||||
.map_err(|e| format!("创建服装搭配失败: {}", e))
|
||||
}
|
||||
|
||||
/// 获取服装搭配列表
|
||||
#[command]
|
||||
pub async fn list_outfit_matchings(
|
||||
state: State<'_, AppState>,
|
||||
options: OutfitMatchingQueryOptions,
|
||||
) -> Result<Vec<OutfitMatching>, String> {
|
||||
let database = state.get_database();
|
||||
let service = create_matching_service(database)?;
|
||||
service.get_matchings(options).await
|
||||
.map_err(|e| format!("获取服装搭配列表失败: {}", e))
|
||||
}
|
||||
|
||||
/// 根据ID获取服装搭配
|
||||
#[command]
|
||||
pub async fn get_outfit_matching_by_id(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Option<OutfitMatching>, String> {
|
||||
let database = state.get_database();
|
||||
let service = create_matching_service(database)?;
|
||||
service.get_matching_by_id(&id).await
|
||||
.map_err(|e| format!("获取服装搭配失败: {}", e))
|
||||
}
|
||||
|
||||
/// 更新服装搭配
|
||||
#[command]
|
||||
pub async fn update_outfit_matching(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
request: UpdateOutfitMatchingRequest,
|
||||
) -> Result<(), String> {
|
||||
let database = state.get_database();
|
||||
let service = create_matching_service(database)?;
|
||||
service.update_matching(&id, request).await
|
||||
.map_err(|e| format!("更新服装搭配失败: {}", e))
|
||||
}
|
||||
|
||||
/// 删除服装搭配
|
||||
#[command]
|
||||
pub async fn delete_outfit_matching(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let database = state.get_database();
|
||||
let service = create_matching_service(database)?;
|
||||
service.delete_matching(&id).await
|
||||
.map_err(|e| format!("删除服装搭配失败: {}", e))
|
||||
}
|
||||
|
||||
/// 智能搭配推荐
|
||||
#[command]
|
||||
pub async fn smart_outfit_matching(
|
||||
state: State<'_, AppState>,
|
||||
request: SmartMatchingRequest,
|
||||
) -> Result<SmartMatchingResult, String> {
|
||||
let database = state.get_database();
|
||||
let service = create_matching_service(database)?;
|
||||
service.smart_matching(request).await
|
||||
.map_err(|e| format!("智能搭配推荐失败: {}", e))
|
||||
}
|
||||
|
||||
/// 增加搭配穿着次数
|
||||
#[command]
|
||||
pub async fn increment_outfit_matching_wear_count(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let database = state.get_database();
|
||||
let service = create_matching_service(database)?;
|
||||
service.increment_wear_count(&id).await
|
||||
.map_err(|e| format!("增加搭配穿着次数失败: {}", e))
|
||||
}
|
||||
|
||||
/// 保存上传的图片文件
|
||||
#[command]
|
||||
pub async fn save_outfit_image(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
file_name: String,
|
||||
file_data: Vec<u8>,
|
||||
) -> Result<String, String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
// 获取项目路径
|
||||
let repository_guard = state.get_project_repository()
|
||||
.map_err(|e| format!("获取项目仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("项目仓库未初始化")?;
|
||||
|
||||
let project = repository.find_by_id(&project_id)
|
||||
.map_err(|e| format!("获取项目失败: {}", e))?
|
||||
.ok_or("项目不存在")?;
|
||||
|
||||
// 创建保存目录
|
||||
let project_path = Path::new(&project.path);
|
||||
let outfit_dir = project_path.join("outfit_images");
|
||||
|
||||
if !outfit_dir.exists() {
|
||||
fs::create_dir_all(&outfit_dir)
|
||||
.map_err(|e| format!("创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 生成唯一文件名
|
||||
let timestamp = chrono::Utc::now().timestamp();
|
||||
let extension = Path::new(&file_name)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("jpg");
|
||||
let unique_name = format!("{}_{}.{}", timestamp, uuid::Uuid::new_v4(), extension);
|
||||
let file_path = outfit_dir.join(&unique_name);
|
||||
|
||||
// 保存文件
|
||||
fs::write(&file_path, file_data)
|
||||
.map_err(|e| format!("保存文件失败: {}", e))?;
|
||||
|
||||
// 返回文件路径
|
||||
Ok(file_path.to_string_lossy().to_string())
|
||||
}
|
||||
296
apps/desktop/src-tauri/tests/outfit_integration_test.rs
Normal file
296
apps/desktop/src-tauri/tests/outfit_integration_test.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 服装搭配功能集成测试
|
||||
* 测试从图像分析到搭配推荐的完整流程
|
||||
*/
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user