diff --git a/apps/desktop/src-tauri/src/data/models/mod.rs b/apps/desktop/src-tauri/src/data/models/mod.rs index 9e6b9f1..c49dfbe 100644 --- a/apps/desktop/src-tauri/src/data/models/mod.rs +++ b/apps/desktop/src-tauri/src/data/models/mod.rs @@ -16,6 +16,7 @@ pub mod outfit_search; pub mod gemini_analysis; pub mod outfit_recommendation; pub mod material_search; +pub mod outfit_favorite; pub mod custom_tag; pub mod watermark; pub mod thumbnail; diff --git a/apps/desktop/src-tauri/src/data/models/outfit_favorite.rs b/apps/desktop/src-tauri/src/data/models/outfit_favorite.rs new file mode 100644 index 0000000..9c3e712 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/outfit_favorite.rs @@ -0,0 +1,114 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; +use crate::data::models::outfit_recommendation::OutfitRecommendation; + +/// 收藏的穿搭方案 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitFavorite { + /// 收藏ID + pub id: String, + /// 原始方案数据(JSON序列化存储) + pub recommendation_data: OutfitRecommendation, + /// 用户自定义名称 + pub custom_name: Option, + /// 创建时间 + pub created_at: DateTime, +} + +impl OutfitFavorite { + /// 创建新的收藏方案 + pub fn new(recommendation: OutfitRecommendation, custom_name: Option) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + recommendation_data: recommendation, + custom_name, + created_at: Utc::now(), + } + } + + /// 获取显示名称(优先使用自定义名称,否则使用原方案标题) + pub fn display_name(&self) -> &str { + self.custom_name.as_ref().unwrap_or(&self.recommendation_data.title) + } + + /// 获取方案描述 + pub fn description(&self) -> &str { + &self.recommendation_data.description + } + + /// 获取方案风格标签 + pub fn style_tags(&self) -> &[String] { + &self.recommendation_data.style_tags + } + + /// 获取适合场合 + pub fn occasions(&self) -> &[String] { + &self.recommendation_data.occasions + } +} + +/// 收藏方案请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SaveOutfitToFavoritesRequest { + /// 要收藏的方案 + pub recommendation: OutfitRecommendation, + /// 用户自定义名称 + pub custom_name: Option, +} + +/// 收藏方案响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SaveOutfitToFavoritesResponse { + /// 收藏ID + pub favorite_id: String, + /// 收藏成功的方案 + pub favorite: OutfitFavorite, +} + +/// 获取收藏列表响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetFavoriteOutfitsResponse { + /// 收藏方案列表 + pub favorites: Vec, + /// 总数量 + pub total_count: usize, +} + +/// 基于收藏方案的素材检索请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchMaterialsByFavoriteRequest { + /// 收藏方案ID + pub favorite_id: String, + /// 分页信息 + pub page: Option, + /// 每页大小 + pub page_size: Option, +} + +/// 方案对比请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompareOutfitFavoritesRequest { + /// 第一个收藏方案ID + pub favorite_id_1: String, + /// 第二个收藏方案ID + pub favorite_id_2: String, + /// 分页信息 + pub page: Option, + /// 每页大小 + pub page_size: Option, +} + +/// 方案对比响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompareOutfitFavoritesResponse { + /// 第一个方案信息 + pub favorite_1: OutfitFavorite, + /// 第一个方案的检索结果 + pub materials_1: crate::data::models::material_search::MaterialSearchResponse, + /// 第二个方案信息 + pub favorite_2: OutfitFavorite, + /// 第二个方案的检索结果 + pub materials_2: crate::data::models::material_search::MaterialSearchResponse, + /// 对比生成时间 + pub compared_at: DateTime, +} diff --git a/apps/desktop/src-tauri/src/data/repositories/mod.rs b/apps/desktop/src-tauri/src/data/repositories/mod.rs index 8df3596..75d7575 100644 --- a/apps/desktop/src-tauri/src/data/repositories/mod.rs +++ b/apps/desktop/src-tauri/src/data/repositories/mod.rs @@ -11,5 +11,6 @@ pub mod export_record_repository; pub mod video_generation_repository; pub mod conversation_repository; pub mod custom_tag_repository; +pub mod outfit_favorite_repository; pub mod watermark_template_repository; pub mod template_segment_weight_repository; diff --git a/apps/desktop/src-tauri/src/data/repositories/outfit_favorite_repository.rs b/apps/desktop/src-tauri/src/data/repositories/outfit_favorite_repository.rs new file mode 100644 index 0000000..c878163 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/repositories/outfit_favorite_repository.rs @@ -0,0 +1,185 @@ +use anyhow::Result; +use rusqlite::params; +use std::sync::Arc; +use crate::data::models::outfit_favorite::OutfitFavorite; +use crate::data::models::outfit_recommendation::OutfitRecommendation; +use crate::infrastructure::database::Database; + +/// 穿搭方案收藏数据访问层 +pub struct OutfitFavoriteRepository { + database: Arc, +} + +impl OutfitFavoriteRepository { + /// 创建新的收藏仓库实例 + pub fn new(database: Arc) -> Self { + Self { database } + } + + /// 保存方案到收藏 + pub async fn save_to_favorites( + &self, + recommendation: OutfitRecommendation, + custom_name: Option, + ) -> Result { + let favorite = OutfitFavorite::new(recommendation, custom_name); + + // 序列化方案数据 + let recommendation_json = serde_json::to_string(&favorite.recommendation_data)?; + + self.database.with_connection(|conn| { + conn.execute( + "INSERT INTO outfit_favorites (id, recommendation_data, custom_name, created_at) + VALUES (?1, ?2, ?3, ?4)", + params![ + &favorite.id, + &recommendation_json, + &favorite.custom_name, + favorite.created_at.to_rfc3339() + ], + )?; + Ok(()) + })?; + + Ok(favorite) + } + + /// 获取所有收藏的方案 + pub async fn get_all_favorites(&self) -> Result> { + Ok(self.database.with_connection(|conn| { + let mut stmt = conn.prepare( + "SELECT id, recommendation_data, custom_name, created_at + FROM outfit_favorites + ORDER BY created_at DESC" + )?; + + let favorites = stmt.query_map([], |row| { + let id: String = row.get(0)?; + let recommendation_json: String = row.get(1)?; + let custom_name: Option = row.get(2)?; + let created_at_str: String = row.get(3)?; + + // 反序列化方案数据 + let recommendation_data: OutfitRecommendation = + serde_json::from_str(&recommendation_json) + .map_err(|_e| rusqlite::Error::InvalidColumnType( + 0, "recommendation_data".to_string(), rusqlite::types::Type::Text + ))?; + + let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str) + .map_err(|_| rusqlite::Error::InvalidColumnType( + 3, "created_at".to_string(), rusqlite::types::Type::Text + ))? + .with_timezone(&chrono::Utc); + + Ok(OutfitFavorite { + id, + recommendation_data, + custom_name, + created_at, + }) + })?.collect::, _>>()?; + + Ok(favorites) + })?) + } + + /// 根据ID获取收藏方案 + pub async fn get_favorite_by_id(&self, favorite_id: &str) -> Result> { + Ok(self.database.with_connection(|conn| { + let mut stmt = conn.prepare( + "SELECT id, recommendation_data, custom_name, created_at + FROM outfit_favorites + WHERE id = ?1" + )?; + + let result = stmt.query_row([favorite_id], |row| { + let id: String = row.get(0)?; + let recommendation_json: String = row.get(1)?; + let custom_name: Option = row.get(2)?; + let created_at_str: String = row.get(3)?; + + // 反序列化方案数据 + let recommendation_data: OutfitRecommendation = + serde_json::from_str(&recommendation_json) + .map_err(|_e| rusqlite::Error::InvalidColumnType( + 0, "recommendation_data".to_string(), rusqlite::types::Type::Text + ))?; + + let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str) + .map_err(|_| rusqlite::Error::InvalidColumnType( + 3, "created_at".to_string(), rusqlite::types::Type::Text + ))? + .with_timezone(&chrono::Utc); + + Ok(OutfitFavorite { + id, + recommendation_data, + custom_name, + created_at, + }) + }); + + match result { + Ok(favorite) => Ok(Some(favorite)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + })?) + } + + /// 从收藏中移除方案 + pub async fn remove_from_favorites(&self, favorite_id: &str) -> Result { + let rows_affected = self.database.with_connection(|conn| { + conn.execute( + "DELETE FROM outfit_favorites WHERE id = ?1", + [favorite_id], + ) + })?; + + Ok(rows_affected > 0) + } + + /// 更新收藏方案的自定义名称 + pub async fn update_custom_name( + &self, + favorite_id: &str, + custom_name: Option, + ) -> Result { + let rows_affected = self.database.with_connection(|conn| { + conn.execute( + "UPDATE outfit_favorites SET custom_name = ?1 WHERE id = ?2", + params![custom_name, favorite_id], + ) + })?; + + Ok(rows_affected > 0) + } + + /// 检查方案是否已收藏(基于方案ID) + pub async fn is_recommendation_favorited(&self, recommendation_id: &str) -> Result { + let count: i64 = self.database.with_connection(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM outfit_favorites + WHERE json_extract(recommendation_data, '$.id') = ?1", + [recommendation_id], + |row| row.get(0), + ) + })?; + + Ok(count > 0) + } + + /// 获取收藏总数 + pub async fn get_favorites_count(&self) -> Result { + let count: i64 = self.database.with_connection(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM outfit_favorites", + [], + |row| row.get(0), + ) + })?; + + Ok(count as usize) + } +} diff --git a/apps/desktop/src-tauri/src/data/repositories/outfit_favorite_repository_test.rs b/apps/desktop/src-tauri/src/data/repositories/outfit_favorite_repository_test.rs new file mode 100644 index 0000000..72e9ea8 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/repositories/outfit_favorite_repository_test.rs @@ -0,0 +1,217 @@ +#[cfg(test)] +mod tests { + use super::*; + use crate::infrastructure::database::Database; + use crate::data::models::outfit_recommendation::OutfitRecommendation; + use std::sync::Arc; + use tempfile::tempdir; + + async fn create_test_database() -> Arc { + let temp_dir = tempdir().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let database = Database::new(db_path.to_str().unwrap()).await.unwrap(); + Arc::new(database) + } + + fn create_test_recommendation() -> OutfitRecommendation { + OutfitRecommendation { + id: "test-recommendation-1".to_string(), + title: "测试穿搭方案".to_string(), + description: "这是一个测试用的穿搭方案".to_string(), + overall_style: "休闲".to_string(), + style_tags: vec!["简约".to_string(), "舒适".to_string()], + occasions: vec!["日常".to_string(), "工作".to_string()], + seasons: vec!["春季".to_string(), "秋季".to_string()], + color_theme: "中性色调".to_string(), + primary_colors: vec![], + groups: vec![], + ai_confidence: 0.85, + tiktok_optimization: None, + created_at: chrono::Utc::now(), + } + } + + #[tokio::test] + async fn test_save_and_get_favorite() { + let database = create_test_database().await; + let repository = OutfitFavoriteRepository::new(database); + let recommendation = create_test_recommendation(); + + // 保存收藏 + let favorite = repository + .save_to_favorites(recommendation.clone(), Some("我的测试收藏".to_string())) + .await + .unwrap(); + + assert_eq!(favorite.custom_name, Some("我的测试收藏".to_string())); + assert_eq!(favorite.recommendation_data.id, recommendation.id); + + // 获取收藏 + let retrieved = repository + .get_favorite_by_id(&favorite.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(retrieved.id, favorite.id); + assert_eq!(retrieved.custom_name, favorite.custom_name); + assert_eq!(retrieved.recommendation_data.title, recommendation.title); + } + + #[tokio::test] + async fn test_get_all_favorites() { + let database = create_test_database().await; + let repository = OutfitFavoriteRepository::new(database); + + // 保存多个收藏 + let recommendation1 = create_test_recommendation(); + let mut recommendation2 = create_test_recommendation(); + recommendation2.id = "test-recommendation-2".to_string(); + recommendation2.title = "第二个测试方案".to_string(); + + repository + .save_to_favorites(recommendation1, Some("收藏1".to_string())) + .await + .unwrap(); + + repository + .save_to_favorites(recommendation2, Some("收藏2".to_string())) + .await + .unwrap(); + + // 获取所有收藏 + let favorites = repository.get_all_favorites().await.unwrap(); + + assert_eq!(favorites.len(), 2); + assert!(favorites.iter().any(|f| f.custom_name == Some("收藏1".to_string()))); + assert!(favorites.iter().any(|f| f.custom_name == Some("收藏2".to_string()))); + } + + #[tokio::test] + async fn test_remove_favorite() { + let database = create_test_database().await; + let repository = OutfitFavoriteRepository::new(database); + let recommendation = create_test_recommendation(); + + // 保存收藏 + let favorite = repository + .save_to_favorites(recommendation, None) + .await + .unwrap(); + + // 确认存在 + let exists = repository + .get_favorite_by_id(&favorite.id) + .await + .unwrap() + .is_some(); + assert!(exists); + + // 删除收藏 + let removed = repository + .remove_from_favorites(&favorite.id) + .await + .unwrap(); + assert!(removed); + + // 确认已删除 + let not_exists = repository + .get_favorite_by_id(&favorite.id) + .await + .unwrap() + .is_none(); + assert!(not_exists); + } + + #[tokio::test] + async fn test_update_custom_name() { + let database = create_test_database().await; + let repository = OutfitFavoriteRepository::new(database); + let recommendation = create_test_recommendation(); + + // 保存收藏 + let favorite = repository + .save_to_favorites(recommendation, Some("原始名称".to_string())) + .await + .unwrap(); + + // 更新名称 + let updated = repository + .update_custom_name(&favorite.id, Some("新名称".to_string())) + .await + .unwrap(); + assert!(updated); + + // 验证更新 + let retrieved = repository + .get_favorite_by_id(&favorite.id) + .await + .unwrap() + .unwrap(); + assert_eq!(retrieved.custom_name, Some("新名称".to_string())); + } + + #[tokio::test] + async fn test_is_recommendation_favorited() { + let database = create_test_database().await; + let repository = OutfitFavoriteRepository::new(database); + let recommendation = create_test_recommendation(); + + // 初始状态未收藏 + let not_favorited = repository + .is_recommendation_favorited(&recommendation.id) + .await + .unwrap(); + assert!(!not_favorited); + + // 保存收藏 + repository + .save_to_favorites(recommendation.clone(), None) + .await + .unwrap(); + + // 确认已收藏 + let favorited = repository + .is_recommendation_favorited(&recommendation.id) + .await + .unwrap(); + assert!(favorited); + } + + #[tokio::test] + async fn test_get_favorites_count() { + let database = create_test_database().await; + let repository = OutfitFavoriteRepository::new(database); + + // 初始计数为0 + let initial_count = repository.get_favorites_count().await.unwrap(); + assert_eq!(initial_count, 0); + + // 添加收藏 + let recommendation = create_test_recommendation(); + repository + .save_to_favorites(recommendation, None) + .await + .unwrap(); + + // 验证计数 + let count_after_add = repository.get_favorites_count().await.unwrap(); + assert_eq!(count_after_add, 1); + } + + #[tokio::test] + async fn test_favorite_display_name() { + let recommendation = create_test_recommendation(); + + // 测试有自定义名称的情况 + let favorite_with_custom = OutfitFavorite::new( + recommendation.clone(), + Some("自定义名称".to_string()) + ); + assert_eq!(favorite_with_custom.display_name(), "自定义名称"); + + // 测试无自定义名称的情况 + let favorite_without_custom = OutfitFavorite::new(recommendation.clone(), None); + assert_eq!(favorite_without_custom.display_name(), &recommendation.title); + } +} diff --git a/apps/desktop/src-tauri/src/infrastructure/database/migrations.rs b/apps/desktop/src-tauri/src/infrastructure/database/migrations.rs index f26c706..42727cf 100644 --- a/apps/desktop/src-tauri/src/infrastructure/database/migrations.rs +++ b/apps/desktop/src-tauri/src/infrastructure/database/migrations.rs @@ -196,6 +196,14 @@ impl MigrationManager { up_sql: include_str!("migrations/020_create_template_segment_weights_table.sql").to_string(), down_sql: Some(include_str!("migrations/020_create_template_segment_weights_table_down.sql").to_string()), }); + + // 迁移 21: 创建穿搭方案收藏表 + self.add_migration(Migration { + version: 21, + description: "创建穿搭方案收藏表".to_string(), + up_sql: include_str!("migrations/021_outfit_favorites.sql").to_string(), + down_sql: Some(include_str!("migrations/021_outfit_favorites_down.sql").to_string()), + }); } /// 添加迁移 diff --git a/apps/desktop/src-tauri/src/infrastructure/database/migrations/021_outfit_favorites.sql b/apps/desktop/src-tauri/src/infrastructure/database/migrations/021_outfit_favorites.sql new file mode 100644 index 0000000..9bb29f7 --- /dev/null +++ b/apps/desktop/src-tauri/src/infrastructure/database/migrations/021_outfit_favorites.sql @@ -0,0 +1,11 @@ +-- 创建穿搭方案收藏表 +CREATE TABLE IF NOT EXISTS outfit_favorites ( + id TEXT PRIMARY KEY, + recommendation_data TEXT NOT NULL, -- JSON序列化的OutfitRecommendation数据 + custom_name TEXT, -- 用户自定义名称 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引以提高查询性能 +CREATE INDEX IF NOT EXISTS idx_outfit_favorites_created_at ON outfit_favorites (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_outfit_favorites_custom_name ON outfit_favorites (custom_name); diff --git a/apps/desktop/src-tauri/src/infrastructure/database/migrations/021_outfit_favorites_down.sql b/apps/desktop/src-tauri/src/infrastructure/database/migrations/021_outfit_favorites_down.sql new file mode 100644 index 0000000..8d2eea6 --- /dev/null +++ b/apps/desktop/src-tauri/src/infrastructure/database/migrations/021_outfit_favorites_down.sql @@ -0,0 +1,4 @@ +-- 回滚穿搭方案收藏表 +DROP INDEX IF EXISTS idx_outfit_favorites_custom_name; +DROP INDEX IF EXISTS idx_outfit_favorites_created_at; +DROP TABLE IF EXISTS outfit_favorites; diff --git a/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs b/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs index 196e7f5..9bf93aa 100644 --- a/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs +++ b/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs @@ -1927,7 +1927,6 @@ impl GeminiService { /// 解析穿搭方案分组响应 fn parse_outfit_recommendation_groups(&self, raw_response: &str, _request: &OutfitRecommendationRequest) -> Result<(GroupingStrategy, Vec)> { println!("🔍 开始解析穿搭方案分组响应..."); - println!("📄 原始响应: {}", raw_response); // 尝试提取JSON部分 let json_str = self.extract_json_from_response(raw_response)?; @@ -2051,7 +2050,6 @@ impl GeminiService { /// 解析穿搭方案推荐响应 (向后兼容) fn parse_outfit_recommendations(&self, raw_response: &str, _request: &OutfitRecommendationRequest) -> Result> { println!("🔍 开始解析穿搭方案响应..."); - println!("📄 原始响应: {}", raw_response); // 尝试提取JSON部分 let json_str = self.extract_json_from_response(raw_response)?; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index fb2b8cb..df42d53 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ pub mod infrastructure; pub mod data; pub mod business; pub mod presentation; +pub mod services; // 应用状态和配置 pub mod app_state; @@ -302,6 +303,13 @@ pub fn run() { commands::material_search_commands::generate_material_search_query, commands::material_search_commands::search_materials_for_outfit, commands::material_search_commands::quick_material_search, + // 穿搭方案收藏命令 + commands::outfit_favorite_commands::save_outfit_to_favorites, + commands::outfit_favorite_commands::get_favorite_outfits, + commands::outfit_favorite_commands::remove_from_favorites, + commands::outfit_favorite_commands::search_materials_by_favorite, + commands::outfit_favorite_commands::compare_outfit_favorites, + commands::outfit_favorite_commands::is_outfit_favorited, // 相似度检索工具命令 commands::similarity_search_commands::quick_similarity_search, commands::similarity_search_commands::get_similarity_search_suggestions, diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index fc4b555..04edb73 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -20,6 +20,7 @@ pub mod video_generation_commands; pub mod tools_commands; pub mod outfit_search_commands; pub mod material_search_commands; +pub mod outfit_favorite_commands; pub mod similarity_search_commands; pub mod custom_tag_commands; pub mod tolerant_json_commands; diff --git a/apps/desktop/src-tauri/src/presentation/commands/outfit_favorite_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/outfit_favorite_commands.rs new file mode 100644 index 0000000..2c3332f --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/outfit_favorite_commands.rs @@ -0,0 +1,267 @@ +use tauri::{command, State}; +use crate::data::models::outfit_favorite::{ + SaveOutfitToFavoritesRequest, SaveOutfitToFavoritesResponse, + GetFavoriteOutfitsResponse, SearchMaterialsByFavoriteRequest, + CompareOutfitFavoritesRequest, CompareOutfitFavoritesResponse, +}; +use crate::data::models::material_search::{MaterialSearchRequest, MaterialSearchPagination}; +use crate::data::repositories::outfit_favorite_repository::OutfitFavoriteRepository; +use crate::app_state::AppState; +use crate::services::material_search_service::MaterialSearchService; + +/// 保存方案到收藏 +#[command] +pub async fn save_outfit_to_favorites( + state: State<'_, AppState>, + request: SaveOutfitToFavoritesRequest, +) -> Result { + println!("💖 保存方案到收藏: {}", request.recommendation.title); + + let database = { + let database_guard = state.database.lock().unwrap(); + database_guard.as_ref().ok_or("Database not initialized")?.clone() + }; + + let repository = OutfitFavoriteRepository::new(database); + + let favorite = repository + .save_to_favorites(request.recommendation, request.custom_name) + .await + .map_err(|e| { + eprintln!("保存收藏失败: {}", e); + format!("保存收藏失败: {}", e) + })?; + + println!("✅ 方案收藏成功,ID: {}", favorite.id); + + Ok(SaveOutfitToFavoritesResponse { + favorite_id: favorite.id.clone(), + favorite, + }) +} + +/// 获取所有收藏的方案 +#[command] +pub async fn get_favorite_outfits( + state: State<'_, AppState>, +) -> Result { + println!("📋 获取收藏方案列表"); + + let database = { + let database_guard = state.database.lock().unwrap(); + database_guard.as_ref().ok_or("Database not initialized")?.clone() + }; + + let repository = OutfitFavoriteRepository::new(database); + + let favorites = repository + .get_all_favorites() + .await + .map_err(|e| { + eprintln!("获取收藏列表失败: {}", e); + format!("获取收藏列表失败: {}", e) + })?; + + println!("✅ 获取到 {} 个收藏方案", favorites.len()); + + Ok(GetFavoriteOutfitsResponse { + total_count: favorites.len(), + favorites, + }) +} + +/// 从收藏中移除方案 +#[command] +pub async fn remove_from_favorites( + state: State<'_, AppState>, + favorite_id: String, +) -> Result { + println!("🗑️ 从收藏中移除方案: {}", favorite_id); + + let database = { + let database_guard = state.database.lock().unwrap(); + database_guard.as_ref().ok_or("Database not initialized")?.clone() + }; + + let repository = OutfitFavoriteRepository::new(database); + + let removed = repository + .remove_from_favorites(&favorite_id) + .await + .map_err(|e| { + eprintln!("移除收藏失败: {}", e); + format!("移除收藏失败: {}", e) + })?; + + if removed { + println!("✅ 收藏移除成功"); + } else { + println!("⚠️ 未找到要移除的收藏"); + } + + Ok(removed) +} + +/// 基于收藏方案检索素材 +#[command] +pub async fn search_materials_by_favorite( + state: State<'_, AppState>, + request: SearchMaterialsByFavoriteRequest, +) -> Result { + println!("🔍 基于收藏方案检索素材: {}", request.favorite_id); + + let database = { + let database_guard = state.database.lock().unwrap(); + database_guard.as_ref().ok_or("Database not initialized")?.clone() + }; + + let repository = OutfitFavoriteRepository::new(database); + + // 获取收藏方案 + let favorite = repository + .get_favorite_by_id(&request.favorite_id) + .await + .map_err(|e| { + eprintln!("获取收藏方案失败: {}", e); + format!("获取收藏方案失败: {}", e) + })? + .ok_or_else(|| format!("收藏方案不存在: {}", request.favorite_id))?; + + // 使用现有的素材检索服务 + let search_service = MaterialSearchService::new(); + + // 生成检索条件 + let query_response = search_service + .generate_search_query(&favorite.recommendation_data, None) + .await + .map_err(|e| { + eprintln!("生成检索条件失败: {}", e); + format!("生成检索条件失败: {}", e) + })?; + + // 执行检索 + let search_request = MaterialSearchRequest { + query: query_response.query, + recommendation_id: favorite.recommendation_data.id, + search_config: query_response.search_config, + pagination: MaterialSearchPagination { + page: request.page.unwrap_or(1), + page_size: request.page_size.unwrap_or(9), + }, + }; + + let search_response = search_service + .search_materials(&search_request) + .await + .map_err(|e| { + eprintln!("素材检索失败: {}", e); + format!("素材检索失败: {}", e) + })?; + + println!("✅ 检索完成,找到 {} 个结果", search_response.results.len()); + + Ok(search_response) +} + +/// 对比两个收藏方案的素材检索结果 +#[command] +pub async fn compare_outfit_favorites( + state: State<'_, AppState>, + request: CompareOutfitFavoritesRequest, +) -> Result { + println!("🔄 对比收藏方案: {} vs {}", request.favorite_id_1, request.favorite_id_2); + + let database = { + let database_guard = state.database.lock().unwrap(); + database_guard.as_ref().ok_or("Database not initialized")?.clone() + }; + + let repository = OutfitFavoriteRepository::new(database); + + // 获取两个收藏方案 + let favorite_1 = repository + .get_favorite_by_id(&request.favorite_id_1) + .await + .map_err(|e| format!("获取第一个收藏方案失败: {}", e))? + .ok_or_else(|| format!("第一个收藏方案不存在: {}", request.favorite_id_1))?; + + let favorite_2 = repository + .get_favorite_by_id(&request.favorite_id_2) + .await + .map_err(|e| format!("获取第二个收藏方案失败: {}", e))? + .ok_or_else(|| format!("第二个收藏方案不存在: {}", request.favorite_id_2))?; + + // 并行执行两个方案的素材检索 + let search_service = MaterialSearchService::new(); + + // 为两个方案生成检索条件和执行检索 + let (materials_1, materials_2) = tokio::try_join!( + async { + let query_response = search_service + .generate_search_query(&favorite_1.recommendation_data, None) + .await?; + + let search_request = MaterialSearchRequest { + query: query_response.query, + recommendation_id: favorite_1.recommendation_data.id.clone(), + search_config: query_response.search_config, + pagination: MaterialSearchPagination { + page: request.page.unwrap_or(1), + page_size: request.page_size.unwrap_or(9), + }, + }; + + search_service.search_materials(&search_request).await + }, + async { + let query_response = search_service + .generate_search_query(&favorite_2.recommendation_data, None) + .await?; + + let search_request = MaterialSearchRequest { + query: query_response.query, + recommendation_id: favorite_2.recommendation_data.id.clone(), + search_config: query_response.search_config, + pagination: MaterialSearchPagination { + page: request.page.unwrap_or(1), + page_size: request.page_size.unwrap_or(9), + }, + }; + + search_service.search_materials(&search_request).await + } + ).map_err(|e: anyhow::Error| format!("对比检索失败: {}", e))?; + + println!("✅ 对比完成,方案1找到{}个结果,方案2找到{}个结果", + materials_1.results.len(), materials_2.results.len()); + + Ok(CompareOutfitFavoritesResponse { + favorite_1, + materials_1, + favorite_2, + materials_2, + compared_at: chrono::Utc::now(), + }) +} + +/// 检查方案是否已收藏 +#[command] +pub async fn is_outfit_favorited( + state: State<'_, AppState>, + recommendation_id: String, +) -> Result { + let database = { + let database_guard = state.database.lock().unwrap(); + database_guard.as_ref().ok_or("Database not initialized")?.clone() + }; + + let repository = OutfitFavoriteRepository::new(database); + + repository + .is_recommendation_favorited(&recommendation_id) + .await + .map_err(|e| { + eprintln!("检查收藏状态失败: {}", e); + format!("检查收藏状态失败: {}", e) + }) +} diff --git a/apps/desktop/src-tauri/src/presentation/commands/outfit_favorite_commands_test.rs b/apps/desktop/src-tauri/src/presentation/commands/outfit_favorite_commands_test.rs new file mode 100644 index 0000000..f59c67a --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/outfit_favorite_commands_test.rs @@ -0,0 +1,201 @@ +#[cfg(test)] +mod tests { + use super::*; + use crate::infrastructure::database::Database; + use crate::presentation::state::AppState; + use crate::data::models::outfit_recommendation::OutfitRecommendation; + use std::sync::{Arc, Mutex}; + use tempfile::tempdir; + use tauri::State; + + async fn create_test_app_state() -> AppState { + let temp_dir = tempdir().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let database = Database::new(db_path.to_str().unwrap()).await.unwrap(); + + AppState { + database: Mutex::new(Some(Arc::new(database))), + gemini_service: Mutex::new(None), + } + } + + fn create_test_recommendation() -> OutfitRecommendation { + OutfitRecommendation { + id: "test-recommendation-1".to_string(), + title: "测试穿搭方案".to_string(), + description: "这是一个测试用的穿搭方案".to_string(), + overall_style: "休闲".to_string(), + style_tags: vec!["简约".to_string(), "舒适".to_string()], + occasions: vec!["日常".to_string(), "工作".to_string()], + seasons: vec!["春季".to_string(), "秋季".to_string()], + color_theme: "中性色调".to_string(), + primary_colors: vec![], + groups: vec![], + ai_confidence: 0.85, + tiktok_optimization: None, + created_at: chrono::Utc::now(), + } + } + + #[tokio::test] + async fn test_save_and_get_favorite_commands() { + let app_state = create_test_app_state().await; + let state = State::from(&app_state); + let recommendation = create_test_recommendation(); + + // 测试保存收藏 + let save_request = SaveOutfitToFavoritesRequest { + recommendation: recommendation.clone(), + custom_name: Some("测试收藏".to_string()), + }; + + let save_response = save_outfit_to_favorites(state.clone(), save_request) + .await + .unwrap(); + + assert_eq!(save_response.favorite.custom_name, Some("测试收藏".to_string())); + assert_eq!(save_response.favorite.recommendation_data.id, recommendation.id); + + // 测试获取收藏列表 + let get_response = get_favorite_outfits(state.clone()).await.unwrap(); + + assert_eq!(get_response.total_count, 1); + assert_eq!(get_response.favorites.len(), 1); + assert_eq!(get_response.favorites[0].id, save_response.favorite_id); + } + + #[tokio::test] + async fn test_remove_favorite_command() { + let app_state = create_test_app_state().await; + let state = State::from(&app_state); + let recommendation = create_test_recommendation(); + + // 先保存一个收藏 + let save_request = SaveOutfitToFavoritesRequest { + recommendation, + custom_name: None, + }; + + let save_response = save_outfit_to_favorites(state.clone(), save_request) + .await + .unwrap(); + + // 测试删除收藏 + let removed = remove_from_favorites(state.clone(), save_response.favorite_id.clone()) + .await + .unwrap(); + + assert!(removed); + + // 验证已删除 + let get_response = get_favorite_outfits(state.clone()).await.unwrap(); + assert_eq!(get_response.total_count, 0); + } + + #[tokio::test] + async fn test_is_outfit_favorited_command() { + let app_state = create_test_app_state().await; + let state = State::from(&app_state); + let recommendation = create_test_recommendation(); + + // 初始状态未收藏 + let not_favorited = is_outfit_favorited(state.clone(), recommendation.id.clone()) + .await + .unwrap(); + assert!(!not_favorited); + + // 保存收藏 + let save_request = SaveOutfitToFavoritesRequest { + recommendation: recommendation.clone(), + custom_name: None, + }; + + save_outfit_to_favorites(state.clone(), save_request) + .await + .unwrap(); + + // 验证已收藏 + let favorited = is_outfit_favorited(state.clone(), recommendation.id) + .await + .unwrap(); + assert!(favorited); + } + + #[tokio::test] + async fn test_multiple_favorites() { + let app_state = create_test_app_state().await; + let state = State::from(&app_state); + + // 创建多个不同的推荐方案 + let mut recommendation1 = create_test_recommendation(); + recommendation1.id = "test-recommendation-1".to_string(); + recommendation1.title = "方案1".to_string(); + + let mut recommendation2 = create_test_recommendation(); + recommendation2.id = "test-recommendation-2".to_string(); + recommendation2.title = "方案2".to_string(); + + // 保存多个收藏 + let save_request1 = SaveOutfitToFavoritesRequest { + recommendation: recommendation1, + custom_name: Some("收藏1".to_string()), + }; + + let save_request2 = SaveOutfitToFavoritesRequest { + recommendation: recommendation2, + custom_name: Some("收藏2".to_string()), + }; + + save_outfit_to_favorites(state.clone(), save_request1) + .await + .unwrap(); + + save_outfit_to_favorites(state.clone(), save_request2) + .await + .unwrap(); + + // 验证收藏列表 + let get_response = get_favorite_outfits(state.clone()).await.unwrap(); + + assert_eq!(get_response.total_count, 2); + assert_eq!(get_response.favorites.len(), 2); + + let custom_names: Vec<_> = get_response.favorites + .iter() + .map(|f| f.custom_name.as_ref().unwrap()) + .collect(); + + assert!(custom_names.contains(&"收藏1".to_string())); + assert!(custom_names.contains(&"收藏2".to_string())); + } + + #[tokio::test] + async fn test_error_handling() { + let app_state = AppState { + database: Mutex::new(None), // 故意设置为None来测试错误处理 + gemini_service: Mutex::new(None), + }; + let state = State::from(&app_state); + let recommendation = create_test_recommendation(); + + // 测试数据库未初始化的错误 + let save_request = SaveOutfitToFavoritesRequest { + recommendation, + custom_name: None, + }; + + let result = save_outfit_to_favorites(state.clone(), save_request).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Database not initialized")); + + // 测试获取收藏列表的错误 + let get_result = get_favorite_outfits(state.clone()).await; + assert!(get_result.is_err()); + assert!(get_result.unwrap_err().contains("Database not initialized")); + + // 测试删除收藏的错误 + let remove_result = remove_from_favorites(state.clone(), "non-existent".to_string()).await; + assert!(remove_result.is_err()); + assert!(remove_result.unwrap_err().contains("Database not initialized")); + } +} diff --git a/apps/desktop/src-tauri/src/services/material_search_service.rs b/apps/desktop/src-tauri/src/services/material_search_service.rs new file mode 100644 index 0000000..c3f429c --- /dev/null +++ b/apps/desktop/src-tauri/src/services/material_search_service.rs @@ -0,0 +1,170 @@ +use anyhow::Result; +use crate::data::models::outfit_recommendation::OutfitRecommendation; +use crate::data::models::material_search::{ + MaterialSearchRequest, MaterialSearchResponse, GenerateSearchQueryRequest, + GenerateSearchQueryResponse, GenerateSearchQueryOptions, +}; +use crate::infrastructure::gemini_service::{GeminiService, GeminiConfig}; + + +/// 素材检索服务 +pub struct MaterialSearchService { + gemini_service: GeminiService, +} + +impl MaterialSearchService { + /// 创建新的素材检索服务实例 + pub fn new() -> Self { + let config = GeminiConfig::default(); + let gemini_service = GeminiService::new(Some(config)) + .expect("Failed to create GeminiService"); + + Self { gemini_service } + } + + /// 为穿搭方案生成检索条件 + pub async fn generate_search_query( + &self, + recommendation: &OutfitRecommendation, + options: Option, + ) -> Result { + let request = GenerateSearchQueryRequest { + recommendation: recommendation.clone(), + options, + }; + + // 调用现有的生成检索条件逻辑 + self.generate_search_query_internal(&request).await + } + + /// 执行素材检索 + pub async fn search_materials( + &self, + request: &MaterialSearchRequest, + ) -> Result { + // 转换为标准搜索请求 + let search_request = crate::data::models::outfit_search::SearchRequest { + query: request.query.clone(), + config: request.search_config.clone().into(), + page_size: request.pagination.page_size as usize, + page_offset: ((request.pagination.page - 1) * request.pagination.page_size) as usize, + }; + + // 执行搜索 + let mut gemini_service = self.gemini_service.clone(); + let search_response = execute_material_search(&mut gemini_service, &search_request).await?; + + // 转换搜索结果 + let material_results: Vec = + search_response.results + .into_iter() + .map(|result| crate::data::models::material_search::MaterialSearchResult::from(result)) + .collect(); + + Ok(MaterialSearchResponse { + results: material_results, + total_size: search_response.total_size as u32, + current_page: request.pagination.page, + page_size: request.pagination.page_size, + total_pages: ((search_response.total_size as f64) / (request.pagination.page_size as f64)).ceil() as u32, + search_time_ms: search_response.search_time_ms, + searched_at: chrono::Utc::now(), + next_page_token: search_response.next_page_token, + }) + } + + /// 内部生成检索条件的实现 + async fn generate_search_query_internal( + &self, + request: &GenerateSearchQueryRequest, + ) -> Result { + // 获取生成选项,使用默认值如果未提供 + let default_options = GenerateSearchQueryOptions::default(); + let options = request.options.as_ref().unwrap_or(&default_options); + + // 构建搜索查询字符串 + let mut query_parts = Vec::new(); + + // 添加基础描述 + query_parts.push("model".to_string()); + query_parts.push("fashion".to_string()); + query_parts.push("outfit".to_string()); + + // 添加整体风格 + if options.include_styles.unwrap_or(true) { + query_parts.push(request.recommendation.overall_style.clone()); + + // 添加风格标签 + for tag in &request.recommendation.style_tags { + if !tag.is_empty() { + query_parts.push(tag.clone()); + } + } + } + + // 添加适合场合 + if options.include_occasions.unwrap_or(true) { + for occasion in &request.recommendation.occasions { + if !occasion.is_empty() { + query_parts.push(occasion.clone()); + } + } + } + + // 添加季节信息 + if options.include_seasons.unwrap_or(false) { + for season in &request.recommendation.seasons { + if !season.is_empty() { + query_parts.push(season.clone()); + } + } + } + + // 添加色彩信息 + if options.include_colors.unwrap_or(true) { + query_parts.push(request.recommendation.color_theme.clone()); + + // 添加主要色彩名称 + for color in &request.recommendation.primary_colors { + if !color.name.is_empty() { + query_parts.push(color.name.clone()); + } + } + } + + // 构建最终查询字符串 + let query = query_parts.join(" "); + + // 创建搜索配置 + let search_config = crate::data::models::material_search::MaterialSearchConfig::default(); + + println!("🔍 为方案 '{}' 生成检索条件: {}", request.recommendation.title, query); + + Ok(GenerateSearchQueryResponse { + query, + search_config, + generation_time_ms: 50, // 简单估算 + generated_at: chrono::Utc::now(), + }) + } +} + +impl Clone for MaterialSearchService { + fn clone(&self) -> Self { + Self::new() + } +} + +/// 执行素材搜索的内部函数 +async fn execute_material_search( + gemini_service: &mut GeminiService, + request: &crate::data::models::outfit_search::SearchRequest, +) -> Result { + // 直接使用outfit_search_commands中的vertex搜索服务 + use crate::presentation::commands::outfit_search_commands::execute_vertex_ai_search; + + println!("🔍 开始执行素材库搜索..."); + println!("搜索查询: {}", request.query); + + execute_vertex_ai_search(gemini_service, request).await +} diff --git a/apps/desktop/src-tauri/src/services/mod.rs b/apps/desktop/src-tauri/src/services/mod.rs new file mode 100644 index 0000000..e6da439 --- /dev/null +++ b/apps/desktop/src-tauri/src/services/mod.rs @@ -0,0 +1 @@ +pub mod material_search_service; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 5ad5a9e..ae6a307 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -22,6 +22,9 @@ import BatchThumbnailGenerator from './pages/tools/BatchThumbnailGenerator'; import OutfitRecommendationTool from './pages/tools/OutfitRecommendationTool'; import AdvancedFilterTool from './pages/tools/AdvancedFilterTool'; import OutfitSearchTool from './pages/tools/OutfitSearchTool'; +import OutfitFavoritesTool from './pages/tools/OutfitFavoritesTool'; +import OutfitComparisonTool from './pages/tools/OutfitComparisonTool'; +import MaterialSearchTool from './pages/tools/MaterialSearchTool'; import { EnrichedAnalysisDemo } from './pages/tools/EnrichedAnalysisDemo'; import Navigation from './components/Navigation'; @@ -33,10 +36,6 @@ import { screenAdaptationService } from './services/screenAdaptationService'; import "./App.css"; import './styles/design-system.css'; import './styles/animations.css'; -// 导入测试工具(仅在开发环境) -if (import.meta.env.DEV) { - import('./utils/testChatFunction'); -} /** * 主应用组件 * 遵循 Tauri 开发规范的应用架构设计 @@ -128,6 +127,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/desktop/src/components/outfit/FavoriteButton.tsx b/apps/desktop/src/components/outfit/FavoriteButton.tsx new file mode 100644 index 0000000..cce4245 --- /dev/null +++ b/apps/desktop/src/components/outfit/FavoriteButton.tsx @@ -0,0 +1,160 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { Heart, HeartIcon as HeartOutline, Loader2 } from 'lucide-react'; +import { OutfitRecommendation } from '../../types/outfitRecommendation'; +import { useOutfitFavorites } from '../../hooks/useOutfitFavorites'; + +interface FavoriteButtonProps { + /** 穿搭方案 */ + recommendation: OutfitRecommendation; + /** 是否已收藏 */ + isFavorited?: boolean; + /** 收藏状态变化回调 */ + onFavoriteChange?: (isFavorited: boolean, favoriteId?: string) => void; + /** 按钮大小 */ + size?: 'sm' | 'md' | 'lg'; + /** 是否显示文本 */ + showText?: boolean; + /** 自定义类名 */ + className?: string; +} + +/** + * 收藏按钮组件 + * 遵循设计系统规范,提供统一的收藏操作界面 + */ +export const FavoriteButton: React.FC = ({ + recommendation, + isFavorited = false, + onFavoriteChange, + size = 'md', + showText = false, + className = '', +}) => { + const [favorited, setFavorited] = useState(isFavorited); + const [favoriteId, setFavoriteId] = useState(); + + const { saveToFavorites, removeFromFavorites, isOutfitFavorited, state } = useOutfitFavorites({ + autoLoad: false, // 不自动加载,按需检查 + }); + + // 检查收藏状态 + useEffect(() => { + const checkFavoriteStatus = async () => { + try { + const favorited = await isOutfitFavorited(recommendation.id); + setFavorited(favorited); + } catch (error) { + console.error('检查收藏状态失败:', error); + } + }; + + if (!isFavorited) { + checkFavoriteStatus(); + } + }, [recommendation.id, isFavorited, isOutfitFavorited]); + + // 处理收藏操作 + const handleFavoriteClick = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); // 防止触发父组件的点击事件 + + if (state.isSaving || state.isDeleting) return; + + try { + if (favorited) { + // 取消收藏 + if (favoriteId) { + const success = await removeFromFavorites(favoriteId); + if (success) { + setFavorited(false); + setFavoriteId(undefined); + onFavoriteChange?.(false); + } + } + } else { + // 添加收藏 + const newFavoriteId = await saveToFavorites(recommendation); + if (newFavoriteId) { + setFavorited(true); + setFavoriteId(newFavoriteId); + onFavoriteChange?.(true, newFavoriteId); + } + } + } catch (error) { + console.error('收藏操作失败:', error); + // 这里可以添加错误提示 + } + }, [favorited, favoriteId, recommendation, onFavoriteChange, state.isSaving, state.isDeleting, saveToFavorites, removeFromFavorites]); + + // 获取按钮尺寸样式 + const getSizeClasses = () => { + switch (size) { + case 'sm': + return 'p-1.5 w-7 h-7'; + case 'lg': + return 'p-3 w-12 h-12'; + default: + return 'p-2 w-9 h-9'; + } + }; + + // 获取图标尺寸 + const getIconSize = () => { + switch (size) { + case 'sm': + return 14; + case 'lg': + return 20; + default: + return 16; + } + }; + + // 获取文本尺寸样式 + const getTextClasses = () => { + switch (size) { + case 'sm': + return 'text-xs'; + case 'lg': + return 'text-base'; + default: + return 'text-sm'; + } + }; + + const iconSize = getIconSize(); + + return ( + + ); +}; + +export default FavoriteButton; diff --git a/apps/desktop/src/components/outfit/FavoriteOutfitList.tsx b/apps/desktop/src/components/outfit/FavoriteOutfitList.tsx new file mode 100644 index 0000000..0c81fe4 --- /dev/null +++ b/apps/desktop/src/components/outfit/FavoriteOutfitList.tsx @@ -0,0 +1,265 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Search, Grid, List, Trash2, Eye, Loader2, Heart } from 'lucide-react'; +import { OutfitFavorite, FavoriteViewMode, FAVORITE_SORT_OPTIONS } from '../../types/outfitFavorite'; +import { OutfitFavoriteService } from '../../services/outfitFavoriteService'; + +interface FavoriteOutfitListProps { + /** 收藏方案选择回调 */ + onFavoriteSelect?: (favorite: OutfitFavorite) => void; + /** 收藏方案删除回调 */ + onFavoriteDelete?: (favoriteId: string) => void; + /** 查看详情回调 */ + onViewDetails?: (favorite: OutfitFavorite) => void; + /** 自定义类名 */ + className?: string; +} + +/** + * 收藏穿搭方案列表组件 + * 遵循设计系统规范,提供统一的收藏列表展示界面 + */ +export const FavoriteOutfitList: React.FC = ({ + onFavoriteSelect, + onFavoriteDelete, + onViewDetails, + className = '', +}) => { + const [favorites, setFavorites] = useState([]); + const [filteredFavorites, setFilteredFavorites] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState('created_at_desc'); + const [viewMode, setViewMode] = useState(FavoriteViewMode.Grid); + + // 加载收藏列表 + const loadFavorites = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const response = await OutfitFavoriteService.getFavoriteOutfits(); + setFavorites(response.favorites); + } catch (err) { + console.error('加载收藏列表失败:', err); + setError(err instanceof Error ? err.message : '加载收藏列表失败'); + } finally { + setIsLoading(false); + } + }, []); + + // 初始加载 + useEffect(() => { + loadFavorites(); + }, [loadFavorites]); + + // 过滤和排序 + useEffect(() => { + let filtered = OutfitFavoriteService.filterFavorites(favorites, searchTerm); + filtered = OutfitFavoriteService.sortFavorites(filtered, sortBy); + setFilteredFavorites(filtered); + }, [favorites, searchTerm, sortBy]); + + // 处理删除收藏 + const handleDelete = useCallback(async (favoriteId: string) => { + if (!confirm('确定要删除这个收藏吗?')) return; + + try { + await OutfitFavoriteService.removeFromFavorites(favoriteId); + setFavorites(prev => prev.filter(f => f.id !== favoriteId)); + onFavoriteDelete?.(favoriteId); + } catch (err) { + console.error('删除收藏失败:', err); + setError(err instanceof Error ? err.message : '删除收藏失败'); + } + }, [onFavoriteDelete]); + + // 渲染收藏卡片 + const renderFavoriteCard = (favorite: OutfitFavorite) => { + const displayName = OutfitFavoriteService.getDisplayName(favorite); + const description = OutfitFavoriteService.getDescription(favorite); + const styleTags = OutfitFavoriteService.getStyleTags(favorite); + const occasions = OutfitFavoriteService.getOccasions(favorite); + const createdAt = OutfitFavoriteService.formatCreatedAt(favorite); + + return ( +
onFavoriteSelect?.(favorite)} + > + {/* 收藏图标 */} +
+ +
+ + {/* 内容区域 */} +
+
+

+ {displayName} +

+
+ + +
+
+ +

+ {description} +

+ + {/* 标签 */} +
+ {styleTags.slice(0, 3).map((tag, index) => ( + + {tag} + + ))} + {occasions.slice(0, 2).map((occasion, index) => ( + + {occasion} + + ))} +
+ + {/* 收藏时间 */} +
+ 收藏于 {createdAt} +
+
+
+ ); + }; + + if (isLoading) { + return ( +
+
+ + 加载收藏列表... +
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + return ( +
+ {/* 工具栏 */} +
+ {/* 搜索框 */} +
+ + setSearchTerm(e.target.value)} + placeholder="搜索收藏方案..." + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + /> +
+ + {/* 排序和视图切换 */} +
+ + +
+ + +
+
+
+ + {/* 收藏列表 */} + {filteredFavorites.length === 0 ? ( +
+ +

+ {searchTerm ? '没有找到匹配的收藏' : '暂无收藏方案'} +

+

+ {searchTerm ? '尝试调整搜索条件' : '开始收藏您喜欢的穿搭方案吧'} +

+
+ ) : ( +
+ {filteredFavorites.map(renderFavoriteCard)} +
+ )} +
+ ); +}; + +export default FavoriteOutfitList; diff --git a/apps/desktop/src/components/outfit/FavoriteSelector.tsx b/apps/desktop/src/components/outfit/FavoriteSelector.tsx new file mode 100644 index 0000000..c78978b --- /dev/null +++ b/apps/desktop/src/components/outfit/FavoriteSelector.tsx @@ -0,0 +1,225 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Heart, ChevronDown, Search, Loader2 } from 'lucide-react'; +import { OutfitFavorite } from '../../types/outfitFavorite'; +import { OutfitFavoriteService } from '../../services/outfitFavoriteService'; + +interface FavoriteSelectorProps { + /** 当前选中的收藏方案 */ + selectedFavorite?: OutfitFavorite | null; + /** 选择变化回调 */ + onSelectionChange?: (favorite: OutfitFavorite | null) => void; + /** 占位符文本 */ + placeholder?: string; + /** 是否禁用 */ + disabled?: boolean; + /** 自定义类名 */ + className?: string; +} + +/** + * 收藏方案选择器组件 + * 提供下拉选择收藏的穿搭方案功能 + */ +export const FavoriteSelector: React.FC = ({ + selectedFavorite, + onSelectionChange, + placeholder = '选择收藏方案...', + disabled = false, + className = '', +}) => { + const [favorites, setFavorites] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const containerRef = useRef(null); + + // 加载收藏列表 + const loadFavorites = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const response = await OutfitFavoriteService.getFavoriteOutfits(); + setFavorites(response.favorites); + } catch (err) { + console.error('加载收藏列表失败:', err); + setError(err instanceof Error ? err.message : '加载收藏列表失败'); + } finally { + setIsLoading(false); + } + }, []); + + // 初始加载 + useEffect(() => { + loadFavorites(); + }, [loadFavorites]); + + // 过滤收藏列表 + const filteredFavorites = React.useMemo(() => { + return OutfitFavoriteService.filterFavorites(favorites, searchTerm); + }, [favorites, searchTerm]); + + // 处理选择 + const handleSelect = useCallback((favorite: OutfitFavorite | null) => { + onSelectionChange?.(favorite); + setIsOpen(false); + setSearchTerm(''); + }, [onSelectionChange]); + + // 处理点击外部关闭 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (!target.closest('.favorite-selector')) { + setIsOpen(false); + setSearchTerm(''); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + return ( +
+ {/* 选择器按钮 */} + + + {/* 下拉菜单 */} + {isOpen && ( +
+ {/* 搜索框 */} +
+
+ + setSearchTerm(e.target.value)} + placeholder="搜索收藏方案..." + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm" + autoFocus + /> +
+
+ + {/* 选项列表 */} +
+ {isLoading ? ( +
+ + 加载中... +
+ ) : error ? ( +
+

{error}

+ +
+ ) : filteredFavorites.length === 0 ? ( +
+ +

+ {searchTerm ? '没有找到匹配的收藏' : '暂无收藏方案'} +

+
+ ) : ( + <> + {/* 清除选择选项 */} + {selectedFavorite && ( + + )} + + {/* 收藏方案列表 */} + {filteredFavorites.map((favorite) => ( + + ))} + + )} +
+
+ )} +
+ ); +}; + +export default FavoriteSelector; diff --git a/apps/desktop/src/components/outfit/OutfitComparisonView.tsx b/apps/desktop/src/components/outfit/OutfitComparisonView.tsx new file mode 100644 index 0000000..bcbb772 --- /dev/null +++ b/apps/desktop/src/components/outfit/OutfitComparisonView.tsx @@ -0,0 +1,286 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Heart, Search, Loader2, ArrowLeftRight, X, RefreshCw, RotateCcw } from 'lucide-react'; +import { OutfitFavorite, CompareOutfitFavoritesResponse } from '../../types/outfitFavorite'; +import { OutfitFavoriteService } from '../../services/outfitFavoriteService'; + +interface OutfitComparisonViewProps { + /** 第一个收藏方案 */ + favorite1: OutfitFavorite; + /** 第二个收藏方案 */ + favorite2: OutfitFavorite; + /** 关闭对比回调 */ + onClose?: () => void; + /** 是否为临时对比(来自推荐页面) */ + isTemporary?: boolean; + /** 自定义类名 */ + className?: string; +} + +/** + * 穿搭方案对比视图组件 + * 提供分屏对比两个收藏方案的素材检索结果 + */ +export const OutfitComparisonView: React.FC = ({ + favorite1, + favorite2, + onClose, + isTemporary = false, + className = '', +}) => { + const [comparisonData, setComparisonData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // 执行对比 + const performComparison = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + if (isTemporary) { + // 临时对比:需要先将推荐数据保存为临时收藏,然后执行对比 + try { + // 保存第一个方案为临时收藏 + const saveResponse1 = await OutfitFavoriteService.saveToFavorites( + favorite1.recommendation_data, + `临时对比-${favorite1.custom_name || favorite1.recommendation_data.title}` + ); + + // 保存第二个方案为临时收藏 + const saveResponse2 = await OutfitFavoriteService.saveToFavorites( + favorite2.recommendation_data, + `临时对比-${favorite2.custom_name || favorite2.recommendation_data.title}` + ); + + // 使用临时收藏的ID执行对比 + const response = await OutfitFavoriteService.compareOutfitFavorites( + saveResponse1.favorite.id, + saveResponse2.favorite.id + ); + + // 更新返回的收藏信息,使用原始的自定义名称 + response.favorite_1.custom_name = favorite1.custom_name || favorite1.recommendation_data.title; + response.favorite_2.custom_name = favorite2.custom_name || favorite2.recommendation_data.title; + + setComparisonData(response); + + // 清理临时收藏(可选,或者可以在组件卸载时清理) + // 这里暂时保留,让用户可以看到完整的对比结果 + } catch (tempError) { + console.error('临时对比失败:', tempError); + throw tempError; + } + } else { + // 正常对比:调用后端API + const response = await OutfitFavoriteService.compareOutfitFavorites( + favorite1.id, + favorite2.id + ); + setComparisonData(response); + } + } catch (err) { + console.error('对比失败:', err); + setError(err instanceof Error ? err.message : '对比失败'); + } finally { + setIsLoading(false); + } + }, [favorite1, favorite2, isTemporary]); + + // 初始加载 + useEffect(() => { + performComparison(); + }, [performComparison]); + + // 渲染方案信息卡片 + const renderFavoriteCard = (favorite: OutfitFavorite, _side: 'left' | 'right') => { + const displayName = OutfitFavoriteService.getDisplayName(favorite); + const description = OutfitFavoriteService.getDescription(favorite); + const styleTags = OutfitFavoriteService.getStyleTags(favorite); + const occasions = OutfitFavoriteService.getOccasions(favorite); + + return ( +
+
+
+ +
+
+

+ {displayName} +

+

+ {description} +

+
+ {styleTags.slice(0, 2).map((tag, index) => ( + + {tag} + + ))} + {occasions.slice(0, 1).map((occasion, index) => ( + + {occasion} + + ))} +
+
+
+
+ ); + }; + + // 渲染素材网格 + const renderMaterialGrid = (materials: any[], _side: 'left' | 'right') => { + if (materials.length === 0) { + return ( +
+
+ +

没有找到相关素材

+
+
+ ); + } + + return ( +
+ {materials.map((material, index) => ( +
+ Material { + e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik0xMDAgNzBMMTMwIDEwMEgxMTBWMTMwSDkwVjEwMEg3MEwxMDAgNzBaIiBmaWxsPSIjOUI5QkEwIi8+Cjx0ZXh0IHg9IjEwMCIgeT0iMTUwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOUI5QkEwIiBmb250LXNpemU9IjEyIj7lm77niYfliKDpmaTlpLHotKU8L3RleHQ+Cjwvc3ZnPg=='; + }} + /> +
+

+ {material.style_description} +

+
+ {material.environment_tags?.slice(0, 2).map((tag: string, i: number) => ( + + {tag} + + ))} +
+ {material.relevance_score && ( +
+ 相关度: {(material.relevance_score * 100).toFixed(1)}% +
+ )} +
+
+ ))} +
+ ); + }; + + if (isLoading) { + return ( +
+
+ +

正在对比方案...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + if (!comparisonData) { + return null; + } + + return ( +
+ {/* 标题栏 */} +
+
+
+ +
+
+

方案对比

+ {isTemporary && ( +

来自推荐页面的临时对比

+ )} +
+
+ {onClose && ( + + )} +
+ + {/* 对比内容 */} +
+ {/* 左侧方案 */} +
+
+ {renderFavoriteCard(comparisonData.favorite_1, 'left')} + +
+
+

检索结果

+ + {comparisonData.materials_1.total_size} 个结果 + +
+
+ {renderMaterialGrid(comparisonData.materials_1.results, 'left')} +
+
+
+
+ + {/* 右侧方案 */} +
+
+ {renderFavoriteCard(comparisonData.favorite_2, 'right')} + +
+
+

检索结果

+ + {comparisonData.materials_2.total_size} 个结果 + +
+
+ {renderMaterialGrid(comparisonData.materials_2.results, 'right')} +
+
+
+
+
+
+ ); +}; + +export default OutfitComparisonView; diff --git a/apps/desktop/src/components/outfit/OutfitRecommendationCard.tsx b/apps/desktop/src/components/outfit/OutfitRecommendationCard.tsx index e722bdc..f5d67bb 100644 --- a/apps/desktop/src/components/outfit/OutfitRecommendationCard.tsx +++ b/apps/desktop/src/components/outfit/OutfitRecommendationCard.tsx @@ -1,6 +1,5 @@ import React, { useState, useCallback } from 'react'; import { - Sparkles, MapPin, Clock, Palette, @@ -13,8 +12,10 @@ import { Sunrise, Sunset, Search, + ArrowLeftRight, } from 'lucide-react'; import { OutfitRecommendationCardProps } from '../../types/outfitRecommendation'; +import FavoriteButton from './FavoriteButton'; /** * 穿搭方案推荐卡片组件 @@ -25,6 +26,8 @@ export const OutfitRecommendationCard: React.FC = onSelect, onSceneSearch, onMaterialSearch, + onAddToComparison, + isSelectedForComparison = false, showDetails = true, compact = false, className = '', @@ -66,6 +69,14 @@ export const OutfitRecommendationCard: React.FC = setIsExpanded(!isExpanded); }, [isExpanded]); + // 处理添加到对比 + const handleAddToComparison = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (onAddToComparison) { + onAddToComparison(recommendation); + } + }, [recommendation, onAddToComparison]); + // 获取时间段图标 const getTimeIcon = (timeOfDay: string) => { switch (timeOfDay.toLowerCase()) { @@ -103,8 +114,17 @@ export const OutfitRecommendationCard: React.FC =
+ {/* 收藏按钮 */} +
+ +
+ {/* 标题和描述 */} -
+

{recommendation.title}

@@ -275,7 +295,7 @@ export const OutfitRecommendationCard: React.FC = {recommendation.color_theme}
-
+
{onMaterialSearch && ( )} + + {onAddToComparison && ( + + )}
diff --git a/apps/desktop/src/components/outfit/OutfitRecommendationList.tsx b/apps/desktop/src/components/outfit/OutfitRecommendationList.tsx index ad5e303..ff2837b 100644 --- a/apps/desktop/src/components/outfit/OutfitRecommendationList.tsx +++ b/apps/desktop/src/components/outfit/OutfitRecommendationList.tsx @@ -25,6 +25,8 @@ export const OutfitRecommendationList: React.FC = onMaterialSearch, onRegenerate, onLoadMoreForGroup, + onAddToComparison, + selectedForComparison = [], className = '', }) => { // 加载状态 @@ -228,6 +230,8 @@ export const OutfitRecommendationList: React.FC = onSelect={onRecommendationSelect} onSceneSearch={onSceneSearch} onMaterialSearch={onMaterialSearch} + onAddToComparison={onAddToComparison} + isSelectedForComparison={selectedForComparison.some(r => r.id === recommendation.id)} showDetails={true} compact={false} className="animate-fade-in-up" @@ -247,6 +251,8 @@ export const OutfitRecommendationList: React.FC = onSelect={onRecommendationSelect} onSceneSearch={onSceneSearch} onMaterialSearch={onMaterialSearch} + onAddToComparison={onAddToComparison} + isSelectedForComparison={selectedForComparison.some(r => r.id === recommendation.id)} showDetails={true} compact={false} className="animate-fade-in-up" diff --git a/apps/desktop/src/data/tools.ts b/apps/desktop/src/data/tools.ts index 4a0f81a..d81d336 100644 --- a/apps/desktop/src/data/tools.ts +++ b/apps/desktop/src/data/tools.ts @@ -1,17 +1,12 @@ import { - FileText, Code, - Bug, Wrench, Database, FileSearch, - MessageCircle, - Droplets, - ImageIcon, Search, Sparkles, - Filter, - BarChart3 + Heart, + ArrowLeftRight } from 'lucide-react'; import { Tool, ToolCategory, ToolStatus } from '../types/tool'; @@ -20,92 +15,6 @@ import { Tool, ToolCategory, ToolStatus } from '../types/tool'; * 定义所有可用的工具及其属性 */ export const TOOLS_DATA: Tool[] = [ - { - id: 'data-cleaning', - name: 'AI检索图片/数据清洗', - description: 'JSONL格式数据去重处理工具,支持基于URI字段的精确匹配去重', - longDescription: '专业的数据清洗工具,支持JSONL格式文件的批量处理。通过URI字段进行精确匹配,快速去除重复数据,提供实时进度显示和详细的处理统计信息。', - icon: FileText, - route: '/tools/data-cleaning', - category: ToolCategory.DATA_PROCESSING, - status: ToolStatus.STABLE, - tags: ['JSONL', '数据去重', '批量处理', 'URI匹配'], - isPopular: true, - version: '1.0.0', - lastUpdated: '2024-01-15' - }, - { - id: 'json-parser', - name: '容错JSON解析器', - description: '基于Tree-sitter的大模型JSON容错解析器,支持处理不规范的JSON数据', - longDescription: '高性能的JSON解析工具,专门处理大模型返回的不规范JSON数据。支持注释、无引号键名、尾随逗号等非标准格式,提供多种错误恢复策略。', - icon: Code, - route: '/tools/json-parser', - category: ToolCategory.DEVELOPMENT, - status: ToolStatus.STABLE, - tags: ['JSON解析', 'Tree-sitter', '容错处理', '大模型'], - isNew: true, - version: '2.1.0', - lastUpdated: '2024-01-20' - }, - { - id: 'debug-panel', - name: 'JSON解析器调试面板', - description: '用于测试后端命令是否正常工作的调试工具面板', - longDescription: '开发者调试工具,提供完整的后端命令测试功能。支持实时测试各种JSON解析场景,查看详细的错误信息和性能统计数据。', - icon: Bug, - route: '/tools/debug-panel', - category: ToolCategory.DEVELOPMENT, - status: ToolStatus.BETA, - tags: ['调试工具', '后端测试', '命令测试', '开发辅助'], - version: '1.2.0', - lastUpdated: '2024-01-18' - }, - { - id: 'ai-chat', - name: 'AI 智能聊天', - description: '基于 RAG 检索增强生成的智能对话助手,支持上下文保持和知识问答', - longDescription: '先进的AI聊天工具,基于RAG(检索增强生成)技术,能够根据知识库提供准确的答案。支持上下文保持,最多保留3条对话记录,实时显示响应时间和参考来源。', - icon: MessageCircle, - route: '/tools/ai-chat', - category: ToolCategory.AI_TOOLS, - status: ToolStatus.STABLE, - tags: ['AI聊天', 'RAG', '知识问答', '智能助手', '上下文保持'], - isNew: true, - isPopular: true, - version: '1.0.0', - lastUpdated: '2024-01-21' - }, - { - id: 'watermark-tool', - name: '水印处理工具', - description: '专业的视频水印检测、移除和添加工具,支持批量处理和多种水印类型', - longDescription: '强大的水印处理工具集,提供智能水印检测、精确移除和自定义添加功能。支持视频和图片格式,提供多种移除算法(AI修复、模糊处理、裁剪等)和丰富的水印样式选择。', - icon: Droplets, - route: '/tools/watermark', - category: ToolCategory.FILE_PROCESSING, - status: ToolStatus.STABLE, - tags: ['水印检测', '水印移除', '水印添加', '批量处理', '视频处理'], - isNew: true, - isPopular: true, - version: '1.0.0', - lastUpdated: '2024-01-23' - }, - { - id: 'batch-thumbnail-generator', - name: '批量缩略图生成器', - description: '为视频文件批量生成预览缩略图和时间轴,支持自定义时间戳、尺寸和格式', - longDescription: '专业的批量缩略图生成工具,支持多种视频格式的批量处理。提供灵活的时间戳配置、多种尺寸预设、智能场景检测和时间轴缩略图生成功能。支持并发处理、进度监控和错误恢复机制。', - icon: ImageIcon, - route: '/tools/batch-thumbnail-generator', - category: ToolCategory.FILE_PROCESSING, - status: ToolStatus.STABLE, - tags: ['缩略图生成', '批量处理', '视频处理', '时间轴', '场景检测'], - isNew: true, - isPopular: true, - version: '1.0.0', - lastUpdated: '2024-01-24' - }, { id: 'similarity-search', name: '相似度检索工具', @@ -136,20 +45,6 @@ export const TOOLS_DATA: Tool[] = [ version: '1.0.0', lastUpdated: '2024-01-25' }, - { - id: 'advanced-filter-demo', - name: '高级过滤器演示', - description: '展示和测试高级过滤器组件功能,包括类别、环境、设计风格和颜色检测', - longDescription: '专业的高级过滤器演示工具,展示完整的过滤器组件功能。包括类别过滤器、环境标签选择器、设计风格选择器和颜色检测过滤器。支持实时配置预览、JSON导出和过滤器摘要显示,是开发和测试过滤器功能的理想工具。', - icon: Filter, - route: '/tools/advanced-filter-demo', - category: ToolCategory.DEVELOPMENT, - status: ToolStatus.BETA, - tags: ['过滤器', '组件演示', '开发工具', 'UI组件', '配置管理'], - isNew: true, - version: '1.0.0', - lastUpdated: '2024-01-25' - }, { id: 'outfit-search', name: '智能服装搜索', @@ -166,19 +61,49 @@ export const TOOLS_DATA: Tool[] = [ lastUpdated: '2024-01-26' }, { - id: 'enriched-analysis-demo', - name: '🎨 丰富图像分析演示', - description: '展示智能服装分析的详细结果,包含颜色分析、风格分析、产品分析等丰富信息', - longDescription: '专业的图像分析结果展示工具,提供极其详细和丰富的分析信息。包含颜色和谐度分析、色彩温度匹配、风格一致性评估、产品匹配度统计、环境适配性分析、拍摄质量评估、个性化搭配建议等功能。支持可展开的分析报告、统计图表、建议卡片和完整的分析摘要。', - icon: BarChart3, - route: '/tools/enriched-analysis-demo', + id: 'outfit-favorites', + name: '穿搭方案收藏管理', + description: '管理收藏的穿搭方案,支持基于收藏方案的智能素材检索', + longDescription: '专业的穿搭方案收藏管理工具,提供完整的收藏管理功能。支持收藏方案的添加、删除、搜索和筛选,以及基于收藏方案的智能素材检索功能。帮助用户建立个人穿搭方案库,快速找到适合的素材。', + icon: Heart, + route: '/tools/outfit-favorites', category: ToolCategory.AI_TOOLS, - status: ToolStatus.BETA, - tags: ['图像分析', '详细报告', '颜色分析', '风格分析', '搭配建议', '统计图表'], + status: ToolStatus.STABLE, + tags: ['收藏管理', '穿搭方案', '素材检索', '个人库', '智能搜索'], isNew: true, isPopular: true, version: '1.0.0', - lastUpdated: '2024-01-26' + lastUpdated: '2024-01-27' + }, + { + id: 'outfit-comparison', + name: '穿搭方案对比分析', + description: '分屏对比两个收藏方案的素材检索结果,分析方案差异', + longDescription: '强大的穿搭方案对比分析工具,支持同时选择两个收藏方案进行分屏对比。并行执行素材检索,直观展示两个方案的检索结果差异,帮助用户更好地理解不同方案的特点和适用场景。', + icon: ArrowLeftRight, + route: '/tools/outfit-comparison', + category: ToolCategory.AI_TOOLS, + status: ToolStatus.STABLE, + tags: ['方案对比', '分屏展示', '差异分析', '素材检索', '对比分析'], + isNew: true, + isPopular: true, + version: '1.0.0', + lastUpdated: '2024-01-27' + }, + { + id: 'material-search', + name: '智能素材检索', + description: '基于收藏穿搭方案的智能素材检索工具,快速找到匹配的素材', + longDescription: '专业的智能素材检索工具,基于收藏的穿搭方案进行精准的素材匹配。支持多维度检索条件生成、相关度排序、分页浏览等功能。提供直观的检索界面和丰富的筛选选项,帮助用户快速找到最适合的素材。', + icon: Search, + route: '/tools/material-search', + category: ToolCategory.AI_TOOLS, + status: ToolStatus.STABLE, + tags: ['素材检索', '智能匹配', '方案关联', '相关度排序', '精准搜索'], + isNew: true, + isPopular: true, + version: '1.0.0', + lastUpdated: '2024-01-27' } ]; diff --git a/apps/desktop/src/hooks/useMaterialSearch.ts b/apps/desktop/src/hooks/useMaterialSearch.ts new file mode 100644 index 0000000..63ebb1e --- /dev/null +++ b/apps/desktop/src/hooks/useMaterialSearch.ts @@ -0,0 +1,256 @@ +/** + * 素材检索管理 Hook + * 提供基于收藏方案的素材检索功能,包含缓存和防抖优化 + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { OutfitFavorite, CompareOutfitFavoritesResponse } from '../types/outfitFavorite'; +import { MaterialSearchResponse } from '../types/materialSearch'; +import { OutfitFavoriteService } from '../services/outfitFavoriteService'; + +interface UseMaterialSearchOptions { + /** 防抖延迟(毫秒) */ + debounceDelay?: number; + /** 缓存时间(毫秒) */ + cacheTime?: number; +} + +interface MaterialSearchState { + /** 是否正在检索 */ + isSearching: boolean; + /** 是否正在对比 */ + isComparing: boolean; + /** 错误信息 */ + error?: string; +} + +interface SearchCacheItem { + data: MaterialSearchResponse; + timestamp: number; + favoriteId: string; + page: number; + pageSize: number; +} + +interface ComparisonCacheItem { + data: CompareOutfitFavoritesResponse; + timestamp: number; + favoriteId1: string; + favoriteId2: string; +} + +interface UseMaterialSearchReturn { + /** 检索状态 */ + state: MaterialSearchState; + /** 基于收藏方案检索素材 */ + searchByFavorite: ( + favorite: OutfitFavorite, + page?: number, + pageSize?: number + ) => Promise; + /** 对比两个收藏方案 */ + compareFavorites: ( + favorite1: OutfitFavorite, + favorite2: OutfitFavorite, + page?: number, + pageSize?: number + ) => Promise; + /** 清除缓存 */ + clearCache: () => void; +} + +/** + * 素材检索管理 Hook + */ +export const useMaterialSearch = ( + options: UseMaterialSearchOptions = {} +): UseMaterialSearchReturn => { + const { debounceDelay = 300, cacheTime = 3 * 60 * 1000 } = options; // 默认缓存3分钟 + + const [state, setState] = useState({ + isSearching: false, + isComparing: false, + }); + + // 缓存 + const searchCacheRef = useRef>(new Map()); + const comparisonCacheRef = useRef>(new Map()); + + // 防抖定时器 + const debounceTimerRef = useRef(); + + // 生成搜索缓存键 + const getSearchCacheKey = useCallback((favoriteId: string, page: number, pageSize: number) => { + return `${favoriteId}-${page}-${pageSize}`; + }, []); + + // 生成对比缓存键 + const getComparisonCacheKey = useCallback((favoriteId1: string, favoriteId2: string) => { + return `${favoriteId1}-${favoriteId2}`; + }, []); + + // 检查搜索缓存是否有效 + const isSearchCacheValid = useCallback((cacheItem: SearchCacheItem) => { + return Date.now() - cacheItem.timestamp < cacheTime; + }, [cacheTime]); + + // 检查对比缓存是否有效 + const isComparisonCacheValid = useCallback((cacheItem: ComparisonCacheItem) => { + return Date.now() - cacheItem.timestamp < cacheTime; + }, [cacheTime]); + + // 清除过期缓存 + const clearExpiredCache = useCallback(() => { + const now = Date.now(); + + // 清除过期的搜索缓存 + for (const [key, item] of searchCacheRef.current.entries()) { + if (now - item.timestamp >= cacheTime) { + searchCacheRef.current.delete(key); + } + } + + // 清除过期的对比缓存 + for (const [key, item] of comparisonCacheRef.current.entries()) { + if (now - item.timestamp >= cacheTime) { + comparisonCacheRef.current.delete(key); + } + } + }, [cacheTime]); + + // 基于收藏方案检索素材 + const searchByFavorite = useCallback(async ( + favorite: OutfitFavorite, + page: number = 1, + pageSize: number = 9 + ): Promise => { + const cacheKey = getSearchCacheKey(favorite.id, page, pageSize); + + // 检查缓存 + const cachedItem = searchCacheRef.current.get(cacheKey); + if (cachedItem && isSearchCacheValid(cachedItem)) { + return cachedItem.data; + } + + // 清除之前的防抖定时器 + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + return new Promise((resolve) => { + debounceTimerRef.current = setTimeout(async () => { + setState(prev => ({ ...prev, isSearching: true, error: undefined })); + + try { + const response = await OutfitFavoriteService.searchMaterialsByFavorite( + favorite.id, + page, + pageSize + ); + + // 更新缓存 + searchCacheRef.current.set(cacheKey, { + data: response, + timestamp: Date.now(), + favoriteId: favorite.id, + page, + pageSize, + }); + + // 清除过期缓存 + clearExpiredCache(); + + resolve(response); + } catch (error) { + console.error('素材检索失败:', error); + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : '素材检索失败' + })); + resolve(null); + } finally { + setState(prev => ({ ...prev, isSearching: false })); + } + }, debounceDelay); + }); + }, [getSearchCacheKey, isSearchCacheValid, debounceDelay, clearExpiredCache]); + + // 对比两个收藏方案 + const compareFavorites = useCallback(async ( + favorite1: OutfitFavorite, + favorite2: OutfitFavorite, + page: number = 1, + pageSize: number = 9 + ): Promise => { + const cacheKey = getComparisonCacheKey(favorite1.id, favorite2.id); + + // 检查缓存 + const cachedItem = comparisonCacheRef.current.get(cacheKey); + if (cachedItem && isComparisonCacheValid(cachedItem)) { + return cachedItem.data; + } + + setState(prev => ({ ...prev, isComparing: true, error: undefined })); + + try { + const response = await OutfitFavoriteService.compareOutfitFavorites( + favorite1.id, + favorite2.id, + page, + pageSize + ); + + // 更新缓存 + comparisonCacheRef.current.set(cacheKey, { + data: response, + timestamp: Date.now(), + favoriteId1: favorite1.id, + favoriteId2: favorite2.id, + }); + + // 清除过期缓存 + clearExpiredCache(); + + return response; + } catch (error) { + console.error('方案对比失败:', error); + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : '方案对比失败' + })); + return null; + } finally { + setState(prev => ({ ...prev, isComparing: false })); + } + }, [getComparisonCacheKey, isComparisonCacheValid, clearExpiredCache]); + + // 清除所有缓存 + const clearCache = useCallback(() => { + searchCacheRef.current.clear(); + comparisonCacheRef.current.clear(); + }, []); + + // 组件卸载时清理定时器 + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + // 定期清理过期缓存 + useEffect(() => { + const interval = setInterval(clearExpiredCache, cacheTime); + return () => clearInterval(interval); + }, [clearExpiredCache, cacheTime]); + + return { + state, + searchByFavorite, + compareFavorites, + clearCache, + }; +}; + +export default useMaterialSearch; diff --git a/apps/desktop/src/hooks/useOutfitFavorites.ts b/apps/desktop/src/hooks/useOutfitFavorites.ts new file mode 100644 index 0000000..b1c502d --- /dev/null +++ b/apps/desktop/src/hooks/useOutfitFavorites.ts @@ -0,0 +1,196 @@ +/** + * 穿搭方案收藏管理 Hook + * 提供缓存和状态管理功能,优化性能 + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { OutfitFavorite, FavoriteOperationState } from '../types/outfitFavorite'; +import { OutfitRecommendation } from '../types/outfitRecommendation'; +import { OutfitFavoriteService } from '../services/outfitFavoriteService'; + +interface UseOutfitFavoritesOptions { + /** 是否自动加载 */ + autoLoad?: boolean; + /** 缓存时间(毫秒) */ + cacheTime?: number; +} + +interface UseOutfitFavoritesReturn { + /** 收藏列表 */ + favorites: OutfitFavorite[]; + /** 操作状态 */ + state: FavoriteOperationState; + /** 加载收藏列表 */ + loadFavorites: () => Promise; + /** 保存到收藏 */ + saveToFavorites: (recommendation: OutfitRecommendation, customName?: string) => Promise; + /** 从收藏中移除 */ + removeFromFavorites: (favoriteId: string) => Promise; + /** 检查是否已收藏 */ + isOutfitFavorited: (recommendationId: string) => Promise; + /** 刷新缓存 */ + refreshCache: () => void; +} + +/** + * 穿搭方案收藏管理 Hook + */ +export const useOutfitFavorites = ( + options: UseOutfitFavoritesOptions = {} +): UseOutfitFavoritesReturn => { + const { autoLoad = true, cacheTime = 5 * 60 * 1000 } = options; // 默认缓存5分钟 + + const [favorites, setFavorites] = useState([]); + const [state, setState] = useState({ + isSaving: false, + isLoading: false, + isDeleting: false, + isSearching: false, + isComparing: false, + }); + + // 缓存相关状态 + const cacheRef = useRef<{ + data: OutfitFavorite[]; + timestamp: number; + } | null>(null); + + // 检查缓存是否有效 + const isCacheValid = useCallback(() => { + if (!cacheRef.current) return false; + return Date.now() - cacheRef.current.timestamp < cacheTime; + }, [cacheTime]); + + // 更新缓存 + const updateCache = useCallback((data: OutfitFavorite[]) => { + cacheRef.current = { + data: [...data], + timestamp: Date.now(), + }; + }, []); + + // 清除缓存 + const clearCache = useCallback(() => { + cacheRef.current = null; + }, []); + + // 加载收藏列表 + const loadFavorites = useCallback(async () => { + // 如果缓存有效,直接使用缓存 + if (isCacheValid() && cacheRef.current) { + setFavorites(cacheRef.current.data); + return; + } + + setState(prev => ({ ...prev, isLoading: true, error: undefined })); + + try { + const response = await OutfitFavoriteService.getFavoriteOutfits(); + setFavorites(response.favorites); + updateCache(response.favorites); + } catch (error) { + console.error('加载收藏列表失败:', error); + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : '加载收藏列表失败' + })); + } finally { + setState(prev => ({ ...prev, isLoading: false })); + } + }, [isCacheValid, updateCache]); + + // 保存到收藏 + const saveToFavorites = useCallback(async ( + recommendation: OutfitRecommendation, + customName?: string + ): Promise => { + setState(prev => ({ ...prev, isSaving: true, error: undefined })); + + try { + const response = await OutfitFavoriteService.saveToFavorites(recommendation, customName); + + // 更新本地状态 + const newFavorites = [response.favorite, ...favorites]; + setFavorites(newFavorites); + updateCache(newFavorites); + + return response.favorite_id; + } catch (error) { + console.error('保存收藏失败:', error); + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : '保存收藏失败' + })); + return null; + } finally { + setState(prev => ({ ...prev, isSaving: false })); + } + }, [favorites, updateCache]); + + // 从收藏中移除 + const removeFromFavorites = useCallback(async (favoriteId: string): Promise => { + setState(prev => ({ ...prev, isDeleting: true, error: undefined })); + + try { + const success = await OutfitFavoriteService.removeFromFavorites(favoriteId); + + if (success) { + // 更新本地状态 + const newFavorites = favorites.filter(f => f.id !== favoriteId); + setFavorites(newFavorites); + updateCache(newFavorites); + } + + return success; + } catch (error) { + console.error('删除收藏失败:', error); + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : '删除收藏失败' + })); + return false; + } finally { + setState(prev => ({ ...prev, isDeleting: false })); + } + }, [favorites, updateCache]); + + // 检查是否已收藏 + const isOutfitFavorited = useCallback(async (recommendationId: string): Promise => { + try { + // 首先检查本地缓存 + const localFavorite = favorites.find(f => f.recommendation_data.id === recommendationId); + if (localFavorite) return true; + + // 如果本地没有,调用API检查 + return await OutfitFavoriteService.isOutfitFavorited(recommendationId); + } catch (error) { + console.error('检查收藏状态失败:', error); + return false; + } + }, [favorites]); + + // 刷新缓存 + const refreshCache = useCallback(() => { + clearCache(); + loadFavorites(); + }, [clearCache, loadFavorites]); + + // 自动加载 + useEffect(() => { + if (autoLoad) { + loadFavorites(); + } + }, [autoLoad, loadFavorites]); + + return { + favorites, + state, + loadFavorites, + saveToFavorites, + removeFromFavorites, + isOutfitFavorited, + refreshCache, + }; +}; + +export default useOutfitFavorites; diff --git a/apps/desktop/src/pages/tools/MaterialSearchTool.tsx b/apps/desktop/src/pages/tools/MaterialSearchTool.tsx new file mode 100644 index 0000000..d8641e1 --- /dev/null +++ b/apps/desktop/src/pages/tools/MaterialSearchTool.tsx @@ -0,0 +1,347 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { Search, Heart, Grid, RefreshCw, Filter, RotateCcw } from 'lucide-react'; +import { OutfitFavorite } from '../../types/outfitFavorite'; +import { MaterialSearchResponse } from '../../types/materialSearch'; +import { OutfitFavoriteService } from '../../services/outfitFavoriteService'; +import FavoriteSelector from '../../components/outfit/FavoriteSelector'; + +/** + * 素材检索工具 + * 支持基于收藏方案的智能素材检索 + */ +const MaterialSearchTool: React.FC = () => { + const [selectedFavorite, setSelectedFavorite] = useState(null); + const [searchResults, setSearchResults] = useState(null); + const [isSearching, setIsSearching] = useState(false); + const [searchError, setSearchError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize] = useState(9); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [hasMoreData, setHasMoreData] = useState(true); + const scrollContainerRef = useRef(null); + + // 执行检索 + const handleSearch = useCallback(async (page: number = 1, favorite?: OutfitFavorite | null, isLoadMore: boolean = false) => { + const targetFavorite = favorite || selectedFavorite; + if (!targetFavorite) return; + + if (isLoadMore) { + setIsLoadingMore(true); + } else { + setIsSearching(true); + } + setSearchError(null); + setCurrentPage(page); + + try { + const response = await OutfitFavoriteService.searchMaterialsByFavorite( + targetFavorite.id, + page, + pageSize + ); + + if (isLoadMore && searchResults) { + // 加载更多:合并结果 + setSearchResults({ + ...response, + results: [...searchResults.results, ...response.results] + }); + } else { + // 新搜索:替换结果 + setSearchResults(response); + } + + // 检查是否还有更多数据 + setHasMoreData(page < response.total_pages); + } catch (error) { + console.error('素材检索失败:', error); + setSearchError(error instanceof Error ? error.message : '素材检索失败'); + } finally { + setIsSearching(false); + setIsLoadingMore(false); + } + }, [selectedFavorite, pageSize, searchResults]); + + // 处理收藏方案选择 + const handleFavoriteSelect = useCallback((favorite: OutfitFavorite | null) => { + setSelectedFavorite(favorite); + setSearchResults(null); + setSearchError(null); + setCurrentPage(1); + + // 如果选择了方案,自动执行检索 + if (favorite) { + // 直接传递favorite参数,避免状态更新延迟问题 + handleSearch(1, favorite); + } + }, [handleSearch]); + + + + // 加载更多 + const handleLoadMore = useCallback(() => { + if (!isLoadingMore && hasMoreData && searchResults) { + const nextPage = currentPage + 1; + handleSearch(nextPage, selectedFavorite, true); + } + }, [isLoadingMore, hasMoreData, searchResults, currentPage, handleSearch, selectedFavorite]); + + // 刷新数据 + const handleRefresh = useCallback(async () => { + if (!selectedFavorite || isRefreshing) return; + + setIsRefreshing(true); + setCurrentPage(1); + setHasMoreData(true); + + try { + await handleSearch(1, selectedFavorite, false); + } finally { + setIsRefreshing(false); + } + }, [selectedFavorite, isRefreshing, handleSearch]); + + // 滚动监听,实现无限滚动 + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = container; + const isNearBottom = scrollTop + clientHeight >= scrollHeight - 100; // 提前100px触发 + + if (isNearBottom && hasMoreData && !isLoadingMore && !isSearching) { + handleLoadMore(); + } + }; + + container.addEventListener('scroll', handleScroll); + return () => container.removeEventListener('scroll', handleScroll); + }, [hasMoreData, isLoadingMore, isSearching, handleLoadMore]); + + return ( +
+ {/* 页面标题 */} +
+
+
+ +
+
+

+ 素材检索 +

+

基于收藏方案的智能素材检索

+
+
+
+ + {/* 主要内容区域 */} +
+ {/* 左侧:检索结果展示区域 (70%) */} +
+
+
+

检索结果

+
+ {/* 刷新按钮 */} + {selectedFavorite && ( + + )} + + {/* 状态指示器 */} + {isSearching && ( +
+ + 检索中... +
+ )} + {isRefreshing && ( +
+ + 刷新中... +
+ )} + + {/* 结果统计 */} + {searchResults && ( + + 已显示 {searchResults.results.length} / {searchResults.total_size} 个结果 + {searchResults.search_time_ms && ( + ({searchResults.search_time_ms}ms) + )} + + )} +
+
+ + {/* 检索错误显示 */} + {searchError && ( +
+

{searchError}

+
+ )} + + {/* 检索结果网格 */} +
+ {!selectedFavorite ? ( +
+
+ +

选择收藏方案开始检索

+

从右侧选择一个收藏的穿搭方案

+
+
+ ) : searchResults && searchResults.results.length > 0 ? ( +
+
+ {searchResults.results.map((result, index) => ( +
+ Material { + e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik0xMDAgNzBMMTMwIDEwMEgxMTBWMTMwSDkwVjEwMEg3MEwxMDAgNzBaIiBmaWxsPSIjOUI5QkEwIi8+Cjx0ZXh0IHg9IjEwMCIgeT0iMTUwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOUI5QkEwIiBmb250LXNpemU9IjEyIj7lm77niYfliKDpmaTlpLHotKU8L3RleHQ+Cjwvc3ZnPg=='; + }} + /> +
+

+ {result.style_description} +

+
+ {result.environment_tags?.slice(0, 3).map((tag, i) => ( + + {tag} + + ))} +
+ {result.relevance_score && ( +
+ 相关度: {(result.relevance_score * 100).toFixed(1)}% +
+ )} +
+
+ ))} +
+ + {/* 加载更多指示器 */} + {isLoadingMore && ( +
+ + 加载更多... +
+ )} + + {/* 没有更多数据提示 */} + {!hasMoreData && searchResults.results.length > 0 && ( +
+ 已显示全部 {searchResults.total_size} 个结果 +
+ )} + + {/* 手动加载更多按钮(备用) */} + {hasMoreData && !isLoadingMore && searchResults.results.length > 0 && ( +
+ +
+ )} +
+ ) : selectedFavorite && !isSearching ? ( +
+
+ +

没有找到相关素材

+

尝试选择其他收藏方案

+
+
+ ) : null} +
+
+
+ + {/* 右侧:收藏方案选择面板 (30%) */} +
+ {/* 收藏方案选择 */} +
+

选择收藏方案

+ + + {selectedFavorite && ( +
+

当前方案

+
+

名称: {OutfitFavoriteService.getDisplayName(selectedFavorite)}

+

描述: {OutfitFavoriteService.getDescription(selectedFavorite)}

+
+ 风格标签: +
+ {OutfitFavoriteService.getStyleTags(selectedFavorite).map((tag, index) => ( + + {tag} + + ))} +
+
+
+
+ )} +
+ + {/* 检索操作 */} + {selectedFavorite && ( +
+

检索操作

+
+ + + +
+
+ )} +
+
+
+ ); +}; + +export default MaterialSearchTool; diff --git a/apps/desktop/src/pages/tools/OutfitComparisonTool.tsx b/apps/desktop/src/pages/tools/OutfitComparisonTool.tsx new file mode 100644 index 0000000..481012c --- /dev/null +++ b/apps/desktop/src/pages/tools/OutfitComparisonTool.tsx @@ -0,0 +1,276 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { ArrowLeftRight, Heart, Plus } from 'lucide-react'; +import { OutfitFavorite } from '../../types/outfitFavorite'; +import { OutfitFavoriteService } from '../../services/outfitFavoriteService'; +import OutfitComparisonView from '../../components/outfit/OutfitComparisonView'; + +/** + * 穿搭方案对比工具 + * 支持选择两个收藏方案进行分屏对比 + */ +const OutfitComparisonTool: React.FC = () => { + const [favorites, setFavorites] = useState([]); + const [selectedFavorite1, setSelectedFavorite1] = useState(null); + const [selectedFavorite2, setSelectedFavorite2] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isTemporary, setIsTemporary] = useState(false); + + // 加载收藏列表 + const loadFavorites = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const response = await OutfitFavoriteService.getFavoriteOutfits(); + setFavorites(response.favorites); + } catch (err) { + console.error('加载收藏列表失败:', err); + setError(err instanceof Error ? err.message : '加载收藏列表失败'); + } finally { + setIsLoading(false); + } + }, []); + + // 初始加载 + useEffect(() => { + loadFavorites(); + + // 检查是否有从推荐页面传来的对比数据 + try { + const comparisonData = localStorage.getItem('outfit-comparison-data'); + if (comparisonData) { + const data = JSON.parse(comparisonData); + if (data.leftFavorite && data.rightFavorite) { + // 将推荐数据转换为收藏格式 + const leftFavorite: OutfitFavorite = { + id: data.leftFavorite.id, + recommendation_data: data.leftFavorite.recommendation_data, + custom_name: data.leftFavorite.custom_name, + created_at: data.leftFavorite.created_at + }; + + const rightFavorite: OutfitFavorite = { + id: data.rightFavorite.id, + recommendation_data: data.rightFavorite.recommendation_data, + custom_name: data.rightFavorite.custom_name, + created_at: data.rightFavorite.created_at + }; + + setSelectedFavorite1(leftFavorite); + setSelectedFavorite2(rightFavorite); + setIsTemporary(true); + + // 清除临时数据 + localStorage.removeItem('outfit-comparison-data'); + } + } + } catch (error) { + console.warn('读取对比数据失败:', error); + } + }, [loadFavorites]); + + // 重置选择 + const handleReset = useCallback(() => { + setSelectedFavorite1(null); + setSelectedFavorite2(null); + }, []); + + // 渲染收藏选择器 + const renderFavoriteSelector = ( + title: string, + selectedFavorite: OutfitFavorite | null, + onSelect: (favorite: OutfitFavorite) => void, + excludeId?: string + ) => { + const availableFavorites = favorites.filter(f => f.id !== excludeId); + + return ( +
+

{title}

+ + {selectedFavorite ? ( +
+ {/* 已选择的方案 */} +
+
+
+ +
+
+

+ {OutfitFavoriteService.getDisplayName(selectedFavorite)} +

+

+ {OutfitFavoriteService.getDescription(selectedFavorite)} +

+
+ {OutfitFavoriteService.getStyleTags(selectedFavorite).slice(0, 2).map((tag, index) => ( + + {tag} + + ))} +
+
+
+
+ + {/* 重新选择按钮 */} + +
+ ) : ( +
+ {availableFavorites.length === 0 ? ( +
+ +

没有可选择的收藏方案

+
+ ) : ( + availableFavorites.map((favorite) => ( + + )) + )} +
+ )} +
+ ); + }; + + // 如果两个方案都已选择,显示对比视图 + if (selectedFavorite1 && selectedFavorite2) { + return ( +
+ {/* 页面标题 */} +
+
+
+ +
+
+

+ 方案对比 +

+

对比两个收藏方案的素材检索结果

+
+
+ +
+ + {/* 对比视图 */} +
+ +
+
+ ); + } + + return ( +
+ {/* 页面标题 */} +
+
+
+ +
+
+

+ 方案对比 +

+

选择两个收藏方案进行对比分析

+
+
+
+ + {/* 选择界面 */} +
+ {isLoading ? ( +
+
+
+

加载收藏列表...

+
+
+ ) : error ? ( +
+

{error}

+ +
+ ) : favorites.length < 2 ? ( +
+ +

需要至少两个收藏方案

+

请先收藏一些穿搭方案,然后再进行对比

+ +
+ ) : ( +
+ {/* 方案1选择器 */} + {renderFavoriteSelector( + '选择第一个方案', + selectedFavorite1, + setSelectedFavorite1, + selectedFavorite2?.id + )} + + {/* 方案2选择器 */} + {renderFavoriteSelector( + '选择第二个方案', + selectedFavorite2, + setSelectedFavorite2, + selectedFavorite1?.id + )} +
+ )} +
+
+ ); +}; + +export default OutfitComparisonTool; diff --git a/apps/desktop/src/pages/tools/OutfitFavoritesTool.tsx b/apps/desktop/src/pages/tools/OutfitFavoritesTool.tsx new file mode 100644 index 0000000..bc7eebb --- /dev/null +++ b/apps/desktop/src/pages/tools/OutfitFavoritesTool.tsx @@ -0,0 +1,205 @@ +import React, { useState, useCallback } from 'react'; +import { Heart, Search, ArrowLeft } from 'lucide-react'; +import { OutfitFavorite } from '../../types/outfitFavorite'; +import FavoriteOutfitList from '../../components/outfit/FavoriteOutfitList'; +import { OutfitFavoriteService } from '../../services/outfitFavoriteService'; + +/** + * 穿搭方案收藏管理工具 + * 遵循 Tauri 开发规范和 UI/UX 设计标准 + */ +const OutfitFavoritesTool: React.FC = () => { + const [selectedFavorite, setSelectedFavorite] = useState(null); + const [searchResults, setSearchResults] = useState(null); + const [isSearching, setIsSearching] = useState(false); + const [searchError, setSearchError] = useState(null); + + // 处理收藏方案选择 + const handleFavoriteSelect = useCallback(async (favorite: OutfitFavorite) => { + setSelectedFavorite(favorite); + setIsSearching(true); + setSearchError(null); + + try { + const results = await OutfitFavoriteService.searchMaterialsByFavorite(favorite.id); + setSearchResults(results); + } catch (error) { + console.error('基于收藏方案检索素材失败:', error); + setSearchError(error instanceof Error ? error.message : '检索失败'); + } finally { + setIsSearching(false); + } + }, []); + + // 处理收藏删除 + const handleFavoriteDelete = useCallback((favoriteId: string) => { + // 如果删除的是当前选中的收藏,清除选择 + if (selectedFavorite?.id === favoriteId) { + setSelectedFavorite(null); + setSearchResults(null); + } + }, [selectedFavorite]); + + // 返回收藏列表 + const handleBackToList = useCallback(() => { + setSelectedFavorite(null); + setSearchResults(null); + setSearchError(null); + }, []); + + return ( +
+ {/* 页面标题 */} +
+
+ {selectedFavorite && ( + + )} +
+ +
+
+

+ {selectedFavorite ? '素材检索' : '收藏管理'} +

+

+ {selectedFavorite + ? `基于"${OutfitFavoriteService.getDisplayName(selectedFavorite)}"的素材检索` + : '管理您收藏的穿搭方案' + } +

+
+
+
+ + {/* 主要内容区域 */} +
+ {!selectedFavorite ? ( + /* 收藏列表视图 */ +
+ +
+ ) : ( + /* 素材检索结果视图 */ +
+ {/* 方案信息卡片 */} +
+
+
+ +
+
+

+ {OutfitFavoriteService.getDisplayName(selectedFavorite)} +

+

+ {OutfitFavoriteService.getDescription(selectedFavorite)} +

+
+ {OutfitFavoriteService.getStyleTags(selectedFavorite).slice(0, 3).map((tag, index) => ( + + {tag} + + ))} + {OutfitFavoriteService.getOccasions(selectedFavorite).slice(0, 2).map((occasion, index) => ( + + {occasion} + + ))} +
+
+
+
+ + {/* 检索结果 */} +
+
+

检索结果

+ {searchResults && ( + + 找到 {searchResults.total_size} 个结果 + + )} +
+ + {isSearching ? ( +
+ + 正在检索素材... +
+ ) : searchError ? ( +
+

{searchError}

+ +
+ ) : searchResults ? ( +
+ {searchResults.results.length > 0 ? ( +
+ {searchResults.results.map((result: any, index: number) => ( +
+ Material { + e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik0xMDAgNzBMMTMwIDEwMEgxMTBWMTMwSDkwVjEwMEg3MEwxMDAgNzBaIiBmaWxsPSIjOUI5QkEwIi8+Cjx0ZXh0IHg9IjEwMCIgeT0iMTUwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOUI5QkEwIiBmb250LXNpemU9IjEyIj7lm77niYfliKDpmaTlpLHotKU8L3RleHQ+Cjwvc3ZnPg=='; + }} + /> +
+

+ {result.style_description} +

+
+ {result.environment_tags?.slice(0, 3).map((tag: string, i: number) => ( + + {tag} + + ))} +
+ {result.relevance_score && ( +
+ 相关度: {(result.relevance_score * 100).toFixed(1)}% +
+ )} +
+
+ ))} +
+ ) : ( +
+ +

没有找到相关素材

+
+ )} +
+ ) : null} +
+
+ )} +
+
+ ); +}; + +export default OutfitFavoritesTool; diff --git a/apps/desktop/src/pages/tools/OutfitRecommendationTool.tsx b/apps/desktop/src/pages/tools/OutfitRecommendationTool.tsx index d64e5f4..6055791 100644 --- a/apps/desktop/src/pages/tools/OutfitRecommendationTool.tsx +++ b/apps/desktop/src/pages/tools/OutfitRecommendationTool.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { Sparkles, Wand2, @@ -7,7 +7,9 @@ import { Copy, RefreshCw, Settings, - Info + Info, + Heart, + ArrowLeftRight } from 'lucide-react'; import OutfitRecommendationService from '../../services/outfitRecommendationService'; import { @@ -22,6 +24,23 @@ import OutfitRecommendationList from '../../components/outfit/OutfitRecommendati import { CustomSelect } from '../../components/CustomSelect'; import { MaterialSearchPanel, MaterialDetailModal } from '../../components/material'; +// localStorage 键名 +const STORAGE_KEY = 'outfit-recommendation-state'; + +// 状态接口 +interface SavedState { + query: string; + targetStyle: string; + occasions: string[]; + season: string; + colorPreferences: string[]; + count: number; + groups: OutfitRecommendationGroup[]; + groupingStrategy: GroupingStrategy | null; + recommendations: OutfitRecommendation[]; + showAdvanced: boolean; +} + /** * AI穿搭方案推荐小工具 * 遵循 Tauri 开发规范和 UI/UX 设计标准 @@ -51,6 +70,110 @@ const OutfitRecommendationTool: React.FC = () => { const [showMaterialDetail, setShowMaterialDetail] = useState(false); const [selectedMaterial, setSelectedMaterial] = useState(null); + // 方案对比状态 + const [selectedForComparison, setSelectedForComparison] = useState([]); + + // 状态恢复提示 + const [showRestoredTip, setShowRestoredTip] = useState(false); + + // 保存状态到localStorage + const saveState = useCallback(() => { + try { + const state: SavedState = { + query, + targetStyle, + occasions, + season, + colorPreferences, + count, + groups, + groupingStrategy, + recommendations, + showAdvanced, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (error) { + console.warn('保存状态失败:', error); + } + }, [query, targetStyle, occasions, season, colorPreferences, count, groups, groupingStrategy, recommendations, showAdvanced]); + + // 从localStorage恢复状态 + const restoreState = useCallback(() => { + try { + const savedState = localStorage.getItem(STORAGE_KEY); + if (savedState) { + const state: SavedState = JSON.parse(savedState); + setQuery(state.query || ''); + setTargetStyle(state.targetStyle || ''); + setOccasions(state.occasions || []); + setSeason(state.season || ''); + setColorPreferences(state.colorPreferences || []); + setCount(state.count || 3); + setGroups(state.groups || []); + setGroupingStrategy(state.groupingStrategy || null); + setRecommendations(state.recommendations || []); + setShowAdvanced(state.showAdvanced || false); + return true; // 表示成功恢复了状态 + } + } catch (error) { + console.warn('恢复状态失败:', error); + } + return false; // 表示没有恢复状态 + }, []); + + // 组件加载时恢复状态 + useEffect(() => { + const restored = restoreState(); + if (restored) { + setShowRestoredTip(true); + // 3秒后自动隐藏提示 + setTimeout(() => { + setShowRestoredTip(false); + }, 3000); + } + }, [restoreState]); + + // 状态变化时自动保存(防抖) + useEffect(() => { + const timeoutId = setTimeout(() => { + if (recommendations.length > 0) { + saveState(); + } + }, 1000); // 1秒防抖 + + return () => clearTimeout(timeoutId); + }, [recommendations, saveState]); + + // 监听对比选择,当选择了2个方案时自动跳转到对比页面 + useEffect(() => { + if (selectedForComparison.length === 2) { + // 将选中的方案保存到localStorage,供对比页面使用 + try { + localStorage.setItem('outfit-comparison-data', JSON.stringify({ + leftFavorite: { + id: selectedForComparison[0].id, + recommendation_data: selectedForComparison[0], + custom_name: selectedForComparison[0].title, + created_at: new Date().toISOString() + }, + rightFavorite: { + id: selectedForComparison[1].id, + recommendation_data: selectedForComparison[1], + custom_name: selectedForComparison[1].title, + created_at: new Date().toISOString() + } + })); + + // 短暂延迟后跳转,让用户看到选择反馈 + setTimeout(() => { + window.location.href = '/tools/outfit-comparison'; + }, 500); + } catch (error) { + console.error('保存对比数据失败:', error); + } + } + }, [selectedForComparison]); + // 生成穿搭方案 const handleGenerate = useCallback(async () => { if (!query.trim()) { @@ -83,6 +206,11 @@ const OutfitRecommendationTool: React.FC = () => { setGroupingStrategy(null); setRecommendations(response.recommendations || []); } + + // 生成成功后立即保存状态 + setTimeout(() => { + saveState(); + }, 100); } catch (err) { console.error('穿搭方案生成失败:', err); setError(err instanceof Error ? err.message : '穿搭方案生成失败'); @@ -167,6 +295,14 @@ const OutfitRecommendationTool: React.FC = () => { setGroupingStrategy(null); setRecommendations([]); setError(null); + setShowRestoredTip(false); + + // 清除保存的状态 + try { + localStorage.removeItem(STORAGE_KEY); + } catch (error) { + console.warn('清除保存状态失败:', error); + } }, []); // 导出结果 @@ -203,6 +339,34 @@ const OutfitRecommendationTool: React.FC = () => { URL.revokeObjectURL(url); }, [recommendations, query]); + // 打开收藏管理 + const handleOpenFavoriteManagement = useCallback(() => { + // 使用路由跳转到收藏管理页面 + window.location.href = '/tools/outfit-favorites'; + }, []); + + // 打开方案对比 + const handleOpenComparison = useCallback(() => { + // 使用路由跳转到方案对比页面 + window.location.href = '/tools/outfit-comparison'; + }, []); + + // 添加到对比 + const handleAddToComparison = useCallback((recommendation: OutfitRecommendation) => { + setSelectedForComparison(prev => { + if (prev.find(r => r.id === recommendation.id)) { + // 如果已存在,则移除 + return prev.filter(r => r.id !== recommendation.id); + } else if (prev.length < 2) { + // 如果少于2个,则添加 + return [...prev, recommendation]; + } else { + // 如果已有2个,替换第一个 + return [prev[1], recommendation]; + } + }); + }, []); + // 复制结果 const handleCopy = useCallback(() => { if (recommendations.length === 0) { @@ -255,13 +419,54 @@ const OutfitRecommendationTool: React.FC = () => {
-

AI穿搭方案推荐

+
+

AI穿搭方案推荐

+ {showRestoredTip && ( +
+ 已恢复上次结果 +
+ )} +

基于TikTok视觉趋势的智能穿搭建议

+ {/* 收藏管理入口 */} + + + {/* 方案对比入口 */} + + + {/* 分隔线 */} + {recommendations.length > 0 && ( +
+ )} + {recommendations.length > 0 && ( <>
+ + {/* 对比状态面板 */} + {selectedForComparison.length > 0 && ( +
+
+ +
+

方案对比

+

+ 已选择 {selectedForComparison.length}/2 个方案 + {selectedForComparison.length === 2 && ',即将自动跳转到对比页面...'} +

+
+ {selectedForComparison.map((rec, index) => ( +
+ {index + 1}. {rec.title} +
+ ))} +
+ {selectedForComparison.length === 1 && ( +

+ 再选择一个方案即可开始对比 +

+ )} +
+
+
+ )} @@ -452,6 +686,8 @@ const OutfitRecommendationTool: React.FC = () => { onRegenerate={handleRegenerate} onMaterialSearch={handleMaterialSearch} onLoadMoreForGroup={handleLoadMoreForGroup} + onAddToComparison={handleAddToComparison} + selectedForComparison={selectedForComparison} className="min-h-[600px]" /> diff --git a/apps/desktop/src/pages/tools/SimilaritySearchTool.tsx b/apps/desktop/src/pages/tools/SimilaritySearchTool.tsx index bffb173..417f2e5 100644 --- a/apps/desktop/src/pages/tools/SimilaritySearchTool.tsx +++ b/apps/desktop/src/pages/tools/SimilaritySearchTool.tsx @@ -191,7 +191,7 @@ const SimilaritySearchTool: React.FC = () => { try { const response = await OutfitRecommendationService.quickGenerate(query.trim(), 3); - setOutfitRecommendations(response.recommendations); + setOutfitRecommendations(response.recommendations || []); } catch (error) { console.error('穿搭方案生成失败:', error); setOutfitError(error instanceof Error ? error.message : '穿搭方案生成失败'); @@ -211,7 +211,7 @@ const SimilaritySearchTool: React.FC = () => { try { const response = await OutfitRecommendationService.quickGenerate(query.trim(), 3); - setOutfitRecommendations(response.recommendations); + setOutfitRecommendations(response.recommendations || []); } catch (error) { console.error('穿搭方案重新生成失败:', error); setOutfitError(error instanceof Error ? error.message : '穿搭方案生成失败'); diff --git a/apps/desktop/src/services/outfitFavoriteService.ts b/apps/desktop/src/services/outfitFavoriteService.ts new file mode 100644 index 0000000..4b004b0 --- /dev/null +++ b/apps/desktop/src/services/outfitFavoriteService.ts @@ -0,0 +1,268 @@ +/** + * 穿搭方案收藏API服务 + * 遵循 Tauri 开发规范的API服务设计原则 + */ + +import { invoke } from '@tauri-apps/api/core'; +import { + OutfitFavorite, + SaveOutfitToFavoritesRequest, + SaveOutfitToFavoritesResponse, + GetFavoriteOutfitsResponse, + SearchMaterialsByFavoriteRequest, + CompareOutfitFavoritesRequest, + CompareOutfitFavoritesResponse, +} from '../types/outfitFavorite'; +import { OutfitRecommendation } from '../types/outfitRecommendation'; +import { MaterialSearchResponse } from '../types/materialSearch'; + +export class OutfitFavoriteService { + /** + * 保存方案到收藏 + */ + static async saveToFavorites( + recommendation: OutfitRecommendation, + customName?: string + ): Promise { + try { + const request: SaveOutfitToFavoritesRequest = { + recommendation, + custom_name: customName, + }; + + console.log('💖 保存方案到收藏:', request); + + const response = await invoke( + 'save_outfit_to_favorites', + { request } + ); + + console.log('✅ 方案收藏成功:', response); + return response; + } catch (error) { + console.error('❌ 方案收藏失败:', error); + throw new Error(`方案收藏失败: ${error}`); + } + } + + /** + * 获取所有收藏的方案 + */ + static async getFavoriteOutfits(): Promise { + try { + console.log('📋 获取收藏方案列表'); + + const response = await invoke( + 'get_favorite_outfits' + ); + + console.log('✅ 获取收藏列表成功:', response); + return response; + } catch (error) { + console.error('❌ 获取收藏列表失败:', error); + throw new Error(`获取收藏列表失败: ${error}`); + } + } + + /** + * 从收藏中移除方案 + */ + static async removeFromFavorites(favoriteId: string): Promise { + try { + console.log('🗑️ 从收藏中移除方案:', favoriteId); + + const response = await invoke( + 'remove_from_favorites', + { favoriteId } + ); + + console.log('✅ 收藏移除成功:', response); + return response; + } catch (error) { + console.error('❌ 移除收藏失败:', error); + throw new Error(`移除收藏失败: ${error}`); + } + } + + /** + * 基于收藏方案检索素材 + */ + static async searchMaterialsByFavorite( + favoriteId: string, + page: number = 1, + pageSize: number = 9 + ): Promise { + try { + const request: SearchMaterialsByFavoriteRequest = { + favorite_id: favoriteId, + page, + page_size: pageSize, + }; + + console.log('🔍 基于收藏方案检索素材:', request); + + const response = await invoke( + 'search_materials_by_favorite', + { request } + ); + + console.log('✅ 素材检索成功:', response); + return response; + } catch (error) { + console.error('❌ 素材检索失败:', error); + throw new Error(`素材检索失败: ${error}`); + } + } + + /** + * 对比两个收藏方案的素材检索结果 + */ + static async compareOutfitFavorites( + favoriteId1: string, + favoriteId2: string, + page: number = 1, + pageSize: number = 9 + ): Promise { + try { + const request: CompareOutfitFavoritesRequest = { + favorite_id_1: favoriteId1, + favorite_id_2: favoriteId2, + page, + page_size: pageSize, + }; + + console.log('🔄 对比收藏方案:', request); + + const response = await invoke( + 'compare_outfit_favorites', + { request } + ); + + console.log('✅ 方案对比成功:', response); + return response; + } catch (error) { + console.error('❌ 方案对比失败:', error); + throw new Error(`方案对比失败: ${error}`); + } + } + + /** + * 检查方案是否已收藏 + */ + static async isOutfitFavorited(recommendationId: string): Promise { + try { + console.log('🔍 检查方案收藏状态:', recommendationId); + + const response = await invoke( + 'is_outfit_favorited', + { recommendationId } + ); + + console.log('✅ 收藏状态检查完成:', response); + return response; + } catch (error) { + console.error('❌ 检查收藏状态失败:', error); + throw new Error(`检查收藏状态失败: ${error}`); + } + } + + /** + * 获取收藏方案的显示名称 + */ + static getDisplayName(favorite: OutfitFavorite): string { + return favorite.custom_name || favorite.recommendation_data.title; + } + + /** + * 获取收藏方案的描述 + */ + static getDescription(favorite: OutfitFavorite): string { + return favorite.recommendation_data.description; + } + + /** + * 获取收藏方案的风格标签 + */ + static getStyleTags(favorite: OutfitFavorite): string[] { + return favorite.recommendation_data.style_tags; + } + + /** + * 获取收藏方案的适合场合 + */ + static getOccasions(favorite: OutfitFavorite): string[] { + return favorite.recommendation_data.occasions; + } + + /** + * 格式化收藏时间 + */ + static formatCreatedAt(favorite: OutfitFavorite): string { + const date = new Date(favorite.created_at); + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } + + /** + * 过滤收藏方案 + */ + static filterFavorites( + favorites: OutfitFavorite[], + searchTerm: string + ): OutfitFavorite[] { + if (!searchTerm.trim()) { + return favorites; + } + + const term = searchTerm.toLowerCase(); + return favorites.filter(favorite => { + const displayName = this.getDisplayName(favorite).toLowerCase(); + const description = this.getDescription(favorite).toLowerCase(); + const styleTags = this.getStyleTags(favorite).join(' ').toLowerCase(); + const occasions = this.getOccasions(favorite).join(' ').toLowerCase(); + + return ( + displayName.includes(term) || + description.includes(term) || + styleTags.includes(term) || + occasions.includes(term) + ); + }); + } + + /** + * 排序收藏方案 + */ + static sortFavorites( + favorites: OutfitFavorite[], + sortBy: string + ): OutfitFavorite[] { + const sorted = [...favorites]; + + switch (sortBy) { + case 'created_at_desc': + return sorted.sort((a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + case 'created_at_asc': + return sorted.sort((a, b) => + new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ); + case 'custom_name_asc': + return sorted.sort((a, b) => + this.getDisplayName(a).localeCompare(this.getDisplayName(b)) + ); + case 'custom_name_desc': + return sorted.sort((a, b) => + this.getDisplayName(b).localeCompare(this.getDisplayName(a)) + ); + default: + return sorted; + } + } +} diff --git a/apps/desktop/src/types/materialSearch.ts b/apps/desktop/src/types/materialSearch.ts new file mode 100644 index 0000000..e4ed14a --- /dev/null +++ b/apps/desktop/src/types/materialSearch.ts @@ -0,0 +1,134 @@ +/** + * 素材检索相关类型定义 + * 遵循 Tauri 开发规范的类型安全设计 + */ + +// 素材检索结果项 +export interface MaterialSearchResult { + /** 素材ID */ + id: string; + /** 图片URL */ + image_url: string; + /** 风格描述 */ + style_description: string; + /** 环境标签 */ + environment_tags?: string[]; + /** 相关度评分 */ + relevance_score?: number; + /** 素材类型 */ + material_type?: string; + /** 色彩信息 */ + color_info?: { + primary_colors: string[]; + color_scheme: string; + }; + /** 风格标签 */ + style_tags?: string[]; + /** 场合标签 */ + occasion_tags?: string[]; +} + +// 素材检索响应 +export interface MaterialSearchResponse { + /** 检索结果列表 */ + results: MaterialSearchResult[]; + /** 总结果数量 */ + total_size: number; + /** 当前页码 */ + current_page: number; + /** 每页大小 */ + page_size: number; + /** 总页数 */ + total_pages: number; + /** 检索耗时(毫秒) */ + search_time_ms?: number; + /** 检索时间 */ + searched_at: string; + /** 下一页令牌 */ + next_page_token?: string; +} + +// 素材检索请求 +export interface MaterialSearchRequest { + /** 检索查询 */ + query: string; + /** 推荐方案ID */ + recommendation_id: string; + /** 检索配置 */ + search_config: MaterialSearchConfig; + /** 分页信息 */ + pagination: MaterialSearchPagination; +} + +// 素材检索配置 +export interface MaterialSearchConfig { + /** 相关度阈值 */ + relevance_threshold: number; + /** 最大结果数 */ + max_results: number; + /** 是否包含风格过滤 */ + include_style_filter: boolean; + /** 是否包含色彩过滤 */ + include_color_filter: boolean; + /** 是否包含场合过滤 */ + include_occasion_filter: boolean; +} + +// 分页信息 +export interface MaterialSearchPagination { + /** 页码 */ + page: number; + /** 每页大小 */ + page_size: number; +} + +// 生成检索查询请求 +export interface GenerateSearchQueryRequest { + /** 穿搭方案 */ + recommendation: any; // OutfitRecommendation类型 + /** 生成选项 */ + options?: GenerateSearchQueryOptions; +} + +// 生成检索查询选项 +export interface GenerateSearchQueryOptions { + /** 是否包含风格 */ + include_styles?: boolean; + /** 是否包含场合 */ + include_occasions?: boolean; + /** 是否包含季节 */ + include_seasons?: boolean; + /** 是否包含色彩 */ + include_colors?: boolean; +} + +// 生成检索查询响应 +export interface GenerateSearchQueryResponse { + /** 生成的查询字符串 */ + query: string; + /** 检索配置 */ + search_config: MaterialSearchConfig; + /** 生成的关键词 */ + generated_keywords: string[]; + /** 生成耗时(毫秒) */ + generation_time_ms: number; + /** 生成时间 */ + generated_at: string; +} + +// 默认检索配置 +export const DEFAULT_MATERIAL_SEARCH_CONFIG: MaterialSearchConfig = { + relevance_threshold: 0.6, + max_results: 50, + include_style_filter: true, + include_color_filter: true, + include_occasion_filter: true, +}; + +// 默认生成选项 +export const DEFAULT_GENERATE_OPTIONS: GenerateSearchQueryOptions = { + include_styles: true, + include_occasions: true, + include_seasons: false, + include_colors: true, +}; diff --git a/apps/desktop/src/types/outfitFavorite.ts b/apps/desktop/src/types/outfitFavorite.ts new file mode 100644 index 0000000..17c95a0 --- /dev/null +++ b/apps/desktop/src/types/outfitFavorite.ts @@ -0,0 +1,114 @@ +/** + * 穿搭方案收藏相关类型定义 + * 遵循 Tauri 开发规范的类型安全设计 + */ + +import { OutfitRecommendation } from './outfitRecommendation'; +import { MaterialSearchResponse } from './materialSearch'; + +// 收藏的穿搭方案 +export interface OutfitFavorite { + /** 收藏ID */ + id: string; + /** 原始方案数据 */ + recommendation_data: OutfitRecommendation; + /** 用户自定义名称 */ + custom_name?: string; + /** 创建时间 */ + created_at: string; +} + +// 保存方案到收藏请求 +export interface SaveOutfitToFavoritesRequest { + /** 要收藏的方案 */ + recommendation: OutfitRecommendation; + /** 用户自定义名称 */ + custom_name?: string; +} + +// 保存方案到收藏响应 +export interface SaveOutfitToFavoritesResponse { + /** 收藏ID */ + favorite_id: string; + /** 收藏的方案 */ + favorite: OutfitFavorite; +} + +// 获取收藏列表响应 +export interface GetFavoriteOutfitsResponse { + /** 收藏方案列表 */ + favorites: OutfitFavorite[]; + /** 总数量 */ + total_count: number; +} + +// 基于收藏方案的素材检索请求 +export interface SearchMaterialsByFavoriteRequest { + /** 收藏方案ID */ + favorite_id: string; + /** 分页信息 */ + page?: number; + /** 每页大小 */ + page_size?: number; +} + +// 方案对比请求 +export interface CompareOutfitFavoritesRequest { + /** 第一个收藏方案ID */ + favorite_id_1: string; + /** 第二个收藏方案ID */ + favorite_id_2: string; + /** 分页信息 */ + page?: number; + /** 每页大小 */ + page_size?: number; +} + +// 方案对比响应 +export interface CompareOutfitFavoritesResponse { + /** 第一个方案信息 */ + favorite_1: OutfitFavorite; + /** 第一个方案的检索结果 */ + materials_1: MaterialSearchResponse; + /** 第二个方案信息 */ + favorite_2: OutfitFavorite; + /** 第二个方案的检索结果 */ + materials_2: MaterialSearchResponse; + /** 对比生成时间 */ + compared_at: string; +} + +// 收藏方案显示模式 +export enum FavoriteViewMode { + Grid = 'grid', + List = 'list', +} + +// 收藏方案排序选项 +export interface FavoriteSortOption { + value: string; + label: string; +} + +export const FAVORITE_SORT_OPTIONS: FavoriteSortOption[] = [ + { value: 'created_at_desc', label: '最新收藏' }, + { value: 'created_at_asc', label: '最早收藏' }, + { value: 'custom_name_asc', label: '名称 A-Z' }, + { value: 'custom_name_desc', label: '名称 Z-A' }, +]; + +// 收藏操作状态 +export interface FavoriteOperationState { + /** 是否正在保存收藏 */ + isSaving: boolean; + /** 是否正在加载收藏列表 */ + isLoading: boolean; + /** 是否正在删除收藏 */ + isDeleting: boolean; + /** 是否正在检索素材 */ + isSearching: boolean; + /** 是否正在对比 */ + isComparing: boolean; + /** 错误信息 */ + error?: string; +} diff --git a/apps/desktop/src/types/outfitRecommendation.ts b/apps/desktop/src/types/outfitRecommendation.ts index e8ab422..283ad9b 100644 --- a/apps/desktop/src/types/outfitRecommendation.ts +++ b/apps/desktop/src/types/outfitRecommendation.ts @@ -174,6 +174,10 @@ export interface OutfitRecommendationCardProps { onSceneSearch?: (recommendation: OutfitRecommendation) => void; /** 素材检索事件处理 */ onMaterialSearch?: (recommendation: OutfitRecommendation) => void; + /** 添加到对比事件处理 */ + onAddToComparison?: (recommendation: OutfitRecommendation) => void; + /** 是否已选择用于对比 */ + isSelectedForComparison?: boolean; /** 是否显示详细信息 */ showDetails?: boolean; /** 是否紧凑模式 */ @@ -204,6 +208,10 @@ export interface OutfitRecommendationListProps { onRegenerate?: () => void; /** 获取更多同类方案事件 */ onLoadMoreForGroup?: (groupId: string, styleKeywords: string[]) => void; + /** 添加到对比事件 */ + onAddToComparison?: (recommendation: OutfitRecommendation) => void; + /** 已选择用于对比的方案 */ + selectedForComparison?: OutfitRecommendation[]; /** 自定义样式类名 */ className?: string; } diff --git a/apps/desktop/src/utils/testChatFunction.ts b/apps/desktop/src/utils/testChatFunction.ts index 844d394..8deba53 100644 --- a/apps/desktop/src/utils/testChatFunction.ts +++ b/apps/desktop/src/utils/testChatFunction.ts @@ -213,21 +213,3 @@ export async function runAllTests(): Promise { console.log('⚠️ 部分测试失败,请检查相关配置和服务状态。'); } } - -// 在浏览器控制台中可以直接调用的测试函数 -if (typeof window !== 'undefined') { - (window as any).testChatFunction = { - testConnection, - testConfig, - testSimpleQuery, - testContextRetention, - runAllTests - }; - - console.log('🔧 聊天功能测试工具已加载,可在控制台中使用:'); - console.log('- window.testChatFunction.testConnection()'); - console.log('- window.testChatFunction.testConfig()'); - console.log('- window.testChatFunction.testSimpleQuery()'); - console.log('- window.testChatFunction.testContextRetention()'); - console.log('- window.testChatFunction.runAllTests()'); -} diff --git a/package.json b/package.json index f8a75d3..0ca6285 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "docker:down": "docker-compose down", "tauri:dev": "pnpm --filter=@mixvideo/desktop tauri:dev", "tauri:build": "pnpm --filter=@mixvideo/desktop tauri:build", + "tauri:web:build": "pnpm --filter=@mixvideo/desktop build", "python:dev": "pnpm --filter python-service dev", "services:start": "docker-compose up python-service -d" },