feat: add Outfit Comparison Tool and Outfit Favorites Tool
- Implemented OutfitComparisonTool for comparing two favorite outfits side by side. - Added OutfitFavoritesTool for managing and searching favorite outfits. - Created OutfitFavoriteService for handling API interactions related to outfit favorites. - Defined types for material search and outfit favorites to ensure type safety. - Enhanced UI components for better user experience in selecting and displaying outfits.
This commit is contained in:
@@ -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;
|
||||
|
||||
114
apps/desktop/src-tauri/src/data/models/outfit_favorite.rs
Normal file
114
apps/desktop/src-tauri/src/data/models/outfit_favorite.rs
Normal file
@@ -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<String>,
|
||||
/// 创建时间
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl OutfitFavorite {
|
||||
/// 创建新的收藏方案
|
||||
pub fn new(recommendation: OutfitRecommendation, custom_name: Option<String>) -> 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<String>,
|
||||
}
|
||||
|
||||
/// 收藏方案响应
|
||||
#[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<OutfitFavorite>,
|
||||
/// 总数量
|
||||
pub total_count: usize,
|
||||
}
|
||||
|
||||
/// 基于收藏方案的素材检索请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchMaterialsByFavoriteRequest {
|
||||
/// 收藏方案ID
|
||||
pub favorite_id: String,
|
||||
/// 分页信息
|
||||
pub page: Option<u32>,
|
||||
/// 每页大小
|
||||
pub page_size: Option<u32>,
|
||||
}
|
||||
|
||||
/// 方案对比请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompareOutfitFavoritesRequest {
|
||||
/// 第一个收藏方案ID
|
||||
pub favorite_id_1: String,
|
||||
/// 第二个收藏方案ID
|
||||
pub favorite_id_2: String,
|
||||
/// 分页信息
|
||||
pub page: Option<u32>,
|
||||
/// 每页大小
|
||||
pub page_size: Option<u32>,
|
||||
}
|
||||
|
||||
/// 方案对比响应
|
||||
#[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<Utc>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Database>,
|
||||
}
|
||||
|
||||
impl OutfitFavoriteRepository {
|
||||
/// 创建新的收藏仓库实例
|
||||
pub fn new(database: Arc<Database>) -> Self {
|
||||
Self { database }
|
||||
}
|
||||
|
||||
/// 保存方案到收藏
|
||||
pub async fn save_to_favorites(
|
||||
&self,
|
||||
recommendation: OutfitRecommendation,
|
||||
custom_name: Option<String>,
|
||||
) -> Result<OutfitFavorite> {
|
||||
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<Vec<OutfitFavorite>> {
|
||||
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<String> = 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::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(favorites)
|
||||
})?)
|
||||
}
|
||||
|
||||
/// 根据ID获取收藏方案
|
||||
pub async fn get_favorite_by_id(&self, favorite_id: &str) -> Result<Option<OutfitFavorite>> {
|
||||
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<String> = 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<bool> {
|
||||
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<String>,
|
||||
) -> Result<bool> {
|
||||
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<bool> {
|
||||
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<usize> {
|
||||
let count: i64 = self.database.with_connection(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM outfit_favorites",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(count as usize)
|
||||
}
|
||||
}
|
||||
@@ -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<Database> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
});
|
||||
}
|
||||
|
||||
/// 添加迁移
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -1927,7 +1927,6 @@ impl GeminiService {
|
||||
/// 解析穿搭方案分组响应
|
||||
fn parse_outfit_recommendation_groups(&self, raw_response: &str, _request: &OutfitRecommendationRequest) -> Result<(GroupingStrategy, Vec<OutfitRecommendationGroup>)> {
|
||||
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<Vec<OutfitRecommendation>> {
|
||||
println!("🔍 开始解析穿搭方案响应...");
|
||||
println!("📄 原始响应: {}", raw_response);
|
||||
|
||||
// 尝试提取JSON部分
|
||||
let json_str = self.extract_json_from_response(raw_response)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SaveOutfitToFavoritesResponse, String> {
|
||||
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<GetFavoriteOutfitsResponse, String> {
|
||||
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<bool, String> {
|
||||
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<crate::data::models::material_search::MaterialSearchResponse, String> {
|
||||
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<CompareOutfitFavoritesResponse, String> {
|
||||
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<bool, String> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
170
apps/desktop/src-tauri/src/services/material_search_service.rs
Normal file
170
apps/desktop/src-tauri/src/services/material_search_service.rs
Normal file
@@ -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<GenerateSearchQueryOptions>,
|
||||
) -> Result<GenerateSearchQueryResponse> {
|
||||
let request = GenerateSearchQueryRequest {
|
||||
recommendation: recommendation.clone(),
|
||||
options,
|
||||
};
|
||||
|
||||
// 调用现有的生成检索条件逻辑
|
||||
self.generate_search_query_internal(&request).await
|
||||
}
|
||||
|
||||
/// 执行素材检索
|
||||
pub async fn search_materials(
|
||||
&self,
|
||||
request: &MaterialSearchRequest,
|
||||
) -> Result<MaterialSearchResponse> {
|
||||
// 转换为标准搜索请求
|
||||
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<crate::data::models::material_search::MaterialSearchResult> =
|
||||
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<GenerateSearchQueryResponse> {
|
||||
// 获取生成选项,使用默认值如果未提供
|
||||
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<crate::data::models::outfit_search::SearchResponse> {
|
||||
// 直接使用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
|
||||
}
|
||||
1
apps/desktop/src-tauri/src/services/mod.rs
Normal file
1
apps/desktop/src-tauri/src/services/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod material_search_service;
|
||||
@@ -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() {
|
||||
<Route path="/tools/batch-thumbnail-generator" element={<BatchThumbnailGenerator />} />
|
||||
<Route path="/tools/outfit-recommendation" element={<OutfitRecommendationTool />} />
|
||||
<Route path="/tools/outfit-search" element={<OutfitSearchTool />} />
|
||||
<Route path="/tools/outfit-favorites" element={<OutfitFavoritesTool />} />
|
||||
<Route path="/tools/outfit-comparison" element={<OutfitComparisonTool />} />
|
||||
<Route path="/tools/material-search" element={<MaterialSearchTool />} />
|
||||
<Route path="/tools/advanced-filter-demo" element={<AdvancedFilterTool />} />
|
||||
<Route path="/tools/enriched-analysis-demo" element={<EnrichedAnalysisDemo />} />
|
||||
</Routes>
|
||||
|
||||
160
apps/desktop/src/components/outfit/FavoriteButton.tsx
Normal file
160
apps/desktop/src/components/outfit/FavoriteButton.tsx
Normal file
@@ -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<FavoriteButtonProps> = ({
|
||||
recommendation,
|
||||
isFavorited = false,
|
||||
onFavoriteChange,
|
||||
size = 'md',
|
||||
showText = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const [favorited, setFavorited] = useState(isFavorited);
|
||||
const [favoriteId, setFavoriteId] = useState<string>();
|
||||
|
||||
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 (
|
||||
<button
|
||||
onClick={handleFavoriteClick}
|
||||
disabled={state.isSaving || state.isDeleting}
|
||||
className={`
|
||||
inline-flex items-center justify-center gap-2 rounded-full
|
||||
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
${favorited
|
||||
? 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500 shadow-lg'
|
||||
: 'bg-white bg-opacity-80 text-gray-600 hover:bg-red-500 hover:text-white focus:ring-red-500 backdrop-blur-sm'
|
||||
}
|
||||
${(state.isSaving || state.isDeleting) ? 'opacity-75 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
|
||||
${getSizeClasses()}
|
||||
${className}
|
||||
`}
|
||||
title={favorited ? '取消收藏' : '添加到收藏'}
|
||||
>
|
||||
{(state.isSaving || state.isDeleting) ? (
|
||||
<Loader2 size={iconSize} className="animate-spin" />
|
||||
) : favorited ? (
|
||||
<Heart size={iconSize} className="fill-current" />
|
||||
) : (
|
||||
<HeartOutline size={iconSize} />
|
||||
)}
|
||||
|
||||
{showText && (
|
||||
<span className={`font-medium ${getTextClasses()}`}>
|
||||
{(state.isSaving || state.isDeleting) ? '处理中...' : favorited ? '已收藏' : '收藏'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default FavoriteButton;
|
||||
265
apps/desktop/src/components/outfit/FavoriteOutfitList.tsx
Normal file
265
apps/desktop/src/components/outfit/FavoriteOutfitList.tsx
Normal file
@@ -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<FavoriteOutfitListProps> = ({
|
||||
onFavoriteSelect,
|
||||
onFavoriteDelete,
|
||||
onViewDetails,
|
||||
className = '',
|
||||
}) => {
|
||||
const [favorites, setFavorites] = useState<OutfitFavorite[]>([]);
|
||||
const [filteredFavorites, setFilteredFavorites] = useState<OutfitFavorite[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState('created_at_desc');
|
||||
const [viewMode, setViewMode] = useState<FavoriteViewMode>(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 (
|
||||
<div
|
||||
key={favorite.id}
|
||||
className={`
|
||||
bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md
|
||||
transition-all duration-200 cursor-pointer group
|
||||
${viewMode === FavoriteViewMode.List ? 'flex items-center p-4' : 'p-4'}
|
||||
`}
|
||||
onClick={() => onFavoriteSelect?.(favorite)}
|
||||
>
|
||||
{/* 收藏图标 */}
|
||||
<div className={`
|
||||
flex-shrink-0 w-12 h-12 bg-gradient-to-br from-red-500 to-pink-600
|
||||
rounded-xl flex items-center justify-center shadow-lg
|
||||
${viewMode === FavoriteViewMode.List ? 'mr-4' : 'mb-3'}
|
||||
`}>
|
||||
<Heart className="w-6 h-6 text-white fill-current" />
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{displayName}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewDetails?.(favorite);
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 transition-colors"
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(favorite.id);
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
|
||||
title="删除收藏"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-3 line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{styleTags.slice(0, 3).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{occasions.slice(0, 2).map((occasion, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full"
|
||||
>
|
||||
{occasion}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 收藏时间 */}
|
||||
<div className="text-xs text-gray-500">
|
||||
收藏于 {createdAt}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-primary-500 animate-spin" />
|
||||
<span className="ml-3 text-gray-600">加载收藏列表...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={loadFavorites}
|
||||
className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* 搜索框 */}
|
||||
<div className="flex-1 max-w-md relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 排序和视图切换 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{FAVORITE_SORT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex items-center border border-gray-300 rounded-lg">
|
||||
<button
|
||||
onClick={() => setViewMode(FavoriteViewMode.Grid)}
|
||||
className={`p-2 ${viewMode === FavoriteViewMode.Grid ? 'bg-primary-500 text-white' : 'text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
<Grid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode(FavoriteViewMode.List)}
|
||||
className={`p-2 ${viewMode === FavoriteViewMode.List ? 'bg-primary-500 text-white' : 'text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 收藏列表 */}
|
||||
{filteredFavorites.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Heart className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{searchTerm ? '没有找到匹配的收藏' : '暂无收藏方案'}
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
{searchTerm ? '尝试调整搜索条件' : '开始收藏您喜欢的穿搭方案吧'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={
|
||||
viewMode === FavoriteViewMode.Grid
|
||||
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{filteredFavorites.map(renderFavoriteCard)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FavoriteOutfitList;
|
||||
225
apps/desktop/src/components/outfit/FavoriteSelector.tsx
Normal file
225
apps/desktop/src/components/outfit/FavoriteSelector.tsx
Normal file
@@ -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<FavoriteSelectorProps> = ({
|
||||
selectedFavorite,
|
||||
onSelectionChange,
|
||||
placeholder = '选择收藏方案...',
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const [favorites, setFavorites] = useState<OutfitFavorite[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div ref={containerRef} className={`relative favorite-selector ${className}`} style={{ position: 'relative', zIndex: 100 }}>
|
||||
{/* 选择器按钮 */}
|
||||
<button
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
w-full flex items-center justify-between px-4 py-3 bg-white border border-gray-300 rounded-lg
|
||||
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400 cursor-pointer'}
|
||||
${isOpen ? 'border-primary-500 ring-2 ring-primary-500' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{selectedFavorite ? (
|
||||
<>
|
||||
<div className="w-6 h-6 bg-gradient-to-br from-red-500 to-pink-600 rounded-md flex items-center justify-center shadow-sm flex-shrink-0">
|
||||
<Heart className="w-3 h-3 text-white fill-current" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{OutfitFavoriteService.getDisplayName(selectedFavorite)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{OutfitFavoriteService.getStyleTags(selectedFavorite).slice(0, 2).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Heart className="w-5 h-5 text-gray-400" />
|
||||
<span className="text-gray-500">{placeholder}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className={`w-5 h-5 text-gray-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* 下拉菜单 */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-300 rounded-lg shadow-xl z-[9999] max-h-80 overflow-hidden" style={{ boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)' }}>
|
||||
{/* 搜索框 */}
|
||||
<div className="p-3 border-b border-gray-200">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选项列表 */}
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="w-5 h-5 text-primary-500 animate-spin" />
|
||||
<span className="ml-2 text-sm text-gray-600">加载中...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-red-600 text-sm mb-2">{error}</p>
|
||||
<button
|
||||
onClick={loadFavorites}
|
||||
className="text-primary-600 text-sm hover:text-primary-700"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
) : filteredFavorites.length === 0 ? (
|
||||
<div className="p-4 text-center">
|
||||
<Heart className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-gray-500 text-sm">
|
||||
{searchTerm ? '没有找到匹配的收藏' : '暂无收藏方案'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 清除选择选项 */}
|
||||
{selectedFavorite && (
|
||||
<button
|
||||
onClick={() => handleSelect(null)}
|
||||
className="w-full px-4 py-3 text-left hover:bg-gray-50 border-b border-gray-100 text-sm text-gray-600"
|
||||
>
|
||||
清除选择
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 收藏方案列表 */}
|
||||
{filteredFavorites.map((favorite) => (
|
||||
<button
|
||||
key={favorite.id}
|
||||
onClick={() => handleSelect(favorite)}
|
||||
className={`
|
||||
w-full px-4 py-3 text-left hover:bg-gray-50 transition-colors
|
||||
${selectedFavorite?.id === favorite.id ? 'bg-primary-50 border-l-4 border-primary-500' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 bg-gradient-to-br from-red-500 to-pink-600 rounded-md flex items-center justify-center shadow-sm flex-shrink-0 mt-0.5">
|
||||
<Heart className="w-3 h-3 text-white fill-current" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 mb-1 truncate">
|
||||
{OutfitFavoriteService.getDisplayName(favorite)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 line-clamp-2 mb-1">
|
||||
{OutfitFavoriteService.getDescription(favorite)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{OutfitFavoriteService.getStyleTags(favorite).slice(0, 2).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-1.5 py-0.5 bg-blue-100 text-blue-700 text-xs rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{OutfitFavoriteService.formatCreatedAt(favorite)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FavoriteSelector;
|
||||
286
apps/desktop/src/components/outfit/OutfitComparisonView.tsx
Normal file
286
apps/desktop/src/components/outfit/OutfitComparisonView.tsx
Normal file
@@ -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<OutfitComparisonViewProps> = ({
|
||||
favorite1,
|
||||
favorite2,
|
||||
onClose,
|
||||
isTemporary = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const [comparisonData, setComparisonData] = useState<CompareOutfitFavoritesResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-red-500 to-pink-600 rounded-lg flex items-center justify-center shadow-md flex-shrink-0">
|
||||
<Heart className="w-5 h-5 text-white fill-current" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1 truncate">
|
||||
{displayName}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-2 line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{styleTags.slice(0, 2).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{occasions.slice(0, 1).map((occasion, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full"
|
||||
>
|
||||
{occasion}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染素材网格
|
||||
const renderMaterialGrid = (materials: any[], _side: 'left' | 'right') => {
|
||||
if (materials.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<Search className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500">没有找到相关素材</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{materials.map((material, index) => (
|
||||
<div key={material.id || index} className="bg-white rounded-lg border border-gray-200 p-3 hover:shadow-md transition-shadow">
|
||||
<img
|
||||
src={material.image_url}
|
||||
alt="Material"
|
||||
className="w-full h-32 object-cover rounded-lg mb-2"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik0xMDAgNzBMMTMwIDEwMEgxMTBWMTMwSDkwVjEwMEg3MEwxMDAgNzBaIiBmaWxsPSIjOUI5QkEwIi8+Cjx0ZXh0IHg9IjEwMCIgeT0iMTUwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOUI5QkEwIiBmb250LXNpemU9IjEyIj7lm77niYfliKDpmaTlpLHotKU8L3RleHQ+Cjwvc3ZnPg==';
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-gray-600 line-clamp-2">
|
||||
{material.style_description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{material.environment_tags?.slice(0, 2).map((tag: string, i: number) => (
|
||||
<span key={i} className="px-1.5 py-0.5 bg-gray-100 text-xs rounded">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{material.relevance_score && (
|
||||
<div className="text-xs text-gray-500">
|
||||
相关度: {(material.relevance_score * 100).toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`h-full flex items-center justify-center ${className}`}>
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 text-primary-500 animate-spin mx-auto mb-3" />
|
||||
<p className="text-gray-600">正在对比方案...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`h-full flex items-center justify-center ${className}`}>
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={performComparison}
|
||||
className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
重新对比
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!comparisonData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-col ${className}`}>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<ArrowLeftRight className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">方案对比</h2>
|
||||
{isTemporary && (
|
||||
<p className="text-xs text-blue-600">来自推荐页面的临时对比</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 对比内容 */}
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{/* 左侧方案 */}
|
||||
<div className="flex-1 p-4 border-r border-gray-200">
|
||||
<div className="h-full flex flex-col">
|
||||
{renderFavoriteCard(comparisonData.favorite_1, 'left')}
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-700">检索结果</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{comparisonData.materials_1.total_size} 个结果
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-full overflow-y-auto">
|
||||
{renderMaterialGrid(comparisonData.materials_1.results, 'left')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧方案 */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="h-full flex flex-col">
|
||||
{renderFavoriteCard(comparisonData.favorite_2, 'right')}
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-700">检索结果</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{comparisonData.materials_2.total_size} 个结果
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-full overflow-y-auto">
|
||||
{renderMaterialGrid(comparisonData.materials_2.results, 'right')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutfitComparisonView;
|
||||
@@ -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<OutfitRecommendationCardProps> =
|
||||
onSelect,
|
||||
onSceneSearch,
|
||||
onMaterialSearch,
|
||||
onAddToComparison,
|
||||
isSelectedForComparison = false,
|
||||
showDetails = true,
|
||||
compact = false,
|
||||
className = '',
|
||||
@@ -66,6 +69,14 @@ export const OutfitRecommendationCard: React.FC<OutfitRecommendationCardProps> =
|
||||
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<OutfitRecommendationCardProps> =
|
||||
<div className="absolute top-0 right-0 w-24 h-24 bg-gradient-to-br from-primary-100 to-primary-200 rounded-full -translate-y-12 translate-x-12 opacity-40 group-hover:opacity-60 transition-all duration-500 group-hover:scale-110"></div>
|
||||
|
||||
<div className="relative">
|
||||
{/* 收藏按钮 */}
|
||||
<div className="absolute top-0 right-0 z-10">
|
||||
<FavoriteButton
|
||||
recommendation={recommendation}
|
||||
size="sm"
|
||||
className="shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标题和描述 */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-4 pr-10">
|
||||
<h3 className="text-lg font-bold text-high-emphasis mb-2 group-hover:text-primary-600 transition-colors duration-200">
|
||||
{recommendation.title}
|
||||
</h3>
|
||||
@@ -275,7 +295,7 @@ export const OutfitRecommendationCard: React.FC<OutfitRecommendationCardProps> =
|
||||
{recommendation.color_theme}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{onMaterialSearch && (
|
||||
<button
|
||||
onClick={handleMaterialSearch}
|
||||
@@ -296,6 +316,21 @@ export const OutfitRecommendationCard: React.FC<OutfitRecommendationCardProps> =
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onAddToComparison && (
|
||||
<button
|
||||
onClick={handleAddToComparison}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors duration-200 text-sm font-medium ${
|
||||
isSelectedForComparison
|
||||
? 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-blue-50 hover:text-blue-600'
|
||||
}`}
|
||||
title={isSelectedForComparison ? '已选择对比' : '添加到对比'}
|
||||
>
|
||||
<ArrowLeftRight className="w-4 h-4" />
|
||||
{isSelectedForComparison ? '已选择' : '对比'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,8 @@ export const OutfitRecommendationList: React.FC<OutfitRecommendationListProps> =
|
||||
onMaterialSearch,
|
||||
onRegenerate,
|
||||
onLoadMoreForGroup,
|
||||
onAddToComparison,
|
||||
selectedForComparison = [],
|
||||
className = '',
|
||||
}) => {
|
||||
// 加载状态
|
||||
@@ -228,6 +230,8 @@ export const OutfitRecommendationList: React.FC<OutfitRecommendationListProps> =
|
||||
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<OutfitRecommendationListProps> =
|
||||
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"
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
256
apps/desktop/src/hooks/useMaterialSearch.ts
Normal file
256
apps/desktop/src/hooks/useMaterialSearch.ts
Normal file
@@ -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<MaterialSearchResponse | null>;
|
||||
/** 对比两个收藏方案 */
|
||||
compareFavorites: (
|
||||
favorite1: OutfitFavorite,
|
||||
favorite2: OutfitFavorite,
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
) => Promise<CompareOutfitFavoritesResponse | null>;
|
||||
/** 清除缓存 */
|
||||
clearCache: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材检索管理 Hook
|
||||
*/
|
||||
export const useMaterialSearch = (
|
||||
options: UseMaterialSearchOptions = {}
|
||||
): UseMaterialSearchReturn => {
|
||||
const { debounceDelay = 300, cacheTime = 3 * 60 * 1000 } = options; // 默认缓存3分钟
|
||||
|
||||
const [state, setState] = useState<MaterialSearchState>({
|
||||
isSearching: false,
|
||||
isComparing: false,
|
||||
});
|
||||
|
||||
// 缓存
|
||||
const searchCacheRef = useRef<Map<string, SearchCacheItem>>(new Map());
|
||||
const comparisonCacheRef = useRef<Map<string, ComparisonCacheItem>>(new Map());
|
||||
|
||||
// 防抖定时器
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
// 生成搜索缓存键
|
||||
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<MaterialSearchResponse | null> => {
|
||||
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<CompareOutfitFavoritesResponse | null> => {
|
||||
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;
|
||||
196
apps/desktop/src/hooks/useOutfitFavorites.ts
Normal file
196
apps/desktop/src/hooks/useOutfitFavorites.ts
Normal file
@@ -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<void>;
|
||||
/** 保存到收藏 */
|
||||
saveToFavorites: (recommendation: OutfitRecommendation, customName?: string) => Promise<string | null>;
|
||||
/** 从收藏中移除 */
|
||||
removeFromFavorites: (favoriteId: string) => Promise<boolean>;
|
||||
/** 检查是否已收藏 */
|
||||
isOutfitFavorited: (recommendationId: string) => Promise<boolean>;
|
||||
/** 刷新缓存 */
|
||||
refreshCache: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 穿搭方案收藏管理 Hook
|
||||
*/
|
||||
export const useOutfitFavorites = (
|
||||
options: UseOutfitFavoritesOptions = {}
|
||||
): UseOutfitFavoritesReturn => {
|
||||
const { autoLoad = true, cacheTime = 5 * 60 * 1000 } = options; // 默认缓存5分钟
|
||||
|
||||
const [favorites, setFavorites] = useState<OutfitFavorite[]>([]);
|
||||
const [state, setState] = useState<FavoriteOperationState>({
|
||||
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<string | null> => {
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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;
|
||||
347
apps/desktop/src/pages/tools/MaterialSearchTool.tsx
Normal file
347
apps/desktop/src/pages/tools/MaterialSearchTool.tsx
Normal file
@@ -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<OutfitFavorite | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<MaterialSearchResponse | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(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<HTMLDivElement>(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 (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 页面标题 */}
|
||||
<div className="page-header flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-green-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Search className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-green-600 bg-clip-text text-transparent">
|
||||
素材检索
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">基于收藏方案的智能素材检索</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="flex-1 flex gap-6 min-h-0">
|
||||
{/* 左侧:检索结果展示区域 (70%) */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="card p-6 flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">检索结果</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 刷新按钮 */}
|
||||
{selectedFavorite && (
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing || isSearching}
|
||||
className="p-2 text-gray-500 hover:text-primary-600 rounded-lg hover:bg-primary-50 transition-colors disabled:opacity-50"
|
||||
title="刷新数据"
|
||||
>
|
||||
<RotateCcw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 状态指示器 */}
|
||||
{isSearching && (
|
||||
<div className="flex items-center gap-2 text-primary-600">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">检索中...</span>
|
||||
</div>
|
||||
)}
|
||||
{isRefreshing && (
|
||||
<div className="flex items-center gap-2 text-blue-600">
|
||||
<RotateCcw className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">刷新中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果统计 */}
|
||||
{searchResults && (
|
||||
<span className="text-sm text-gray-500">
|
||||
已显示 {searchResults.results.length} / {searchResults.total_size} 个结果
|
||||
{searchResults.search_time_ms && (
|
||||
<span className="ml-2">({searchResults.search_time_ms}ms)</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 检索错误显示 */}
|
||||
{searchError && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-600 text-sm">{searchError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 检索结果网格 */}
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
|
||||
{!selectedFavorite ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Heart className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-lg mb-2">选择收藏方案开始检索</p>
|
||||
<p className="text-sm text-gray-400">从右侧选择一个收藏的穿搭方案</p>
|
||||
</div>
|
||||
</div>
|
||||
) : searchResults && searchResults.results.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{searchResults.results.map((result, index) => (
|
||||
<div key={result.id || index} className="card p-4 hover:shadow-lg transition-shadow">
|
||||
<img
|
||||
src={result.image_url}
|
||||
alt="Material"
|
||||
className="w-full h-48 object-cover rounded-lg mb-3"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik0xMDAgNzBMMTMwIDEwMEgxMTBWMTMwSDkwVjEwMEg3MEwxMDAgNzBaIiBmaWxsPSIjOUI5QkEwIi8+Cjx0ZXh0IHg9IjEwMCIgeT0iMTUwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOUI5QkEwIiBmb250LXNpemU9IjEyIj7lm77niYfliKDpmaTlpLHotKU8L3RleHQ+Cjwvc3ZnPg==';
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-600 line-clamp-2">
|
||||
{result.style_description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{result.environment_tags?.slice(0, 3).map((tag, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-gray-100 text-xs rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{result.relevance_score && (
|
||||
<div className="text-xs text-gray-500">
|
||||
相关度: {(result.relevance_score * 100).toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 加载更多指示器 */}
|
||||
{isLoadingMore && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<RefreshCw className="w-5 h-5 text-primary-500 animate-spin mr-2" />
|
||||
<span className="text-gray-600">加载更多...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 没有更多数据提示 */}
|
||||
{!hasMoreData && searchResults.results.length > 0 && (
|
||||
<div className="text-center py-6 text-gray-500 text-sm border-t border-gray-200">
|
||||
已显示全部 {searchResults.total_size} 个结果
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 手动加载更多按钮(备用) */}
|
||||
{hasMoreData && !isLoadingMore && searchResults.results.length > 0 && (
|
||||
<div className="text-center py-4">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
className="px-6 py-2 text-primary-600 border border-primary-300 rounded-lg hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : selectedFavorite && !isSearching ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Grid className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">没有找到相关素材</p>
|
||||
<p className="text-sm text-gray-400 mt-2">尝试选择其他收藏方案</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:收藏方案选择面板 (30%) */}
|
||||
<div className="w-96 flex flex-col space-y-4">
|
||||
{/* 收藏方案选择 */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm hover:shadow-lg hover:shadow-primary-500/10 transition-all duration-300 p-4" style={{ overflow: 'visible' }}>
|
||||
<h3 className="text-lg font-semibold mb-3">选择收藏方案</h3>
|
||||
<FavoriteSelector
|
||||
selectedFavorite={selectedFavorite}
|
||||
onSelectionChange={handleFavoriteSelect}
|
||||
placeholder="选择一个收藏方案..."
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{selectedFavorite && (
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-blue-800 mb-2">当前方案</h4>
|
||||
<div className="space-y-2 text-sm text-blue-700">
|
||||
<p><strong>名称:</strong> {OutfitFavoriteService.getDisplayName(selectedFavorite)}</p>
|
||||
<p><strong>描述:</strong> {OutfitFavoriteService.getDescription(selectedFavorite)}</p>
|
||||
<div>
|
||||
<strong>风格标签:</strong>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{OutfitFavoriteService.getStyleTags(selectedFavorite).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 检索操作 */}
|
||||
{selectedFavorite && (
|
||||
<div className="card p-4">
|
||||
<h3 className="text-lg font-semibold mb-3">检索操作</h3>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => handleSearch(1)}
|
||||
disabled={isSearching}
|
||||
className="w-full btn-primary flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSearching ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4" />
|
||||
)}
|
||||
{isSearching ? '检索中...' : '重新检索'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedFavorite(null)}
|
||||
className="w-full btn-secondary flex items-center justify-center gap-2"
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
清除选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialSearchTool;
|
||||
276
apps/desktop/src/pages/tools/OutfitComparisonTool.tsx
Normal file
276
apps/desktop/src/pages/tools/OutfitComparisonTool.tsx
Normal file
@@ -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<OutfitFavorite[]>([]);
|
||||
const [selectedFavorite1, setSelectedFavorite1] = useState<OutfitFavorite | null>(null);
|
||||
const [selectedFavorite2, setSelectedFavorite2] = useState<OutfitFavorite | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm hover:shadow-lg hover:shadow-primary-500/10 transition-all duration-300 p-6" style={{ overflow: 'visible' }}>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
|
||||
|
||||
{selectedFavorite ? (
|
||||
<div className="space-y-4">
|
||||
{/* 已选择的方案 */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-4 border border-blue-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-red-500 to-pink-600 rounded-lg flex items-center justify-center shadow-md flex-shrink-0">
|
||||
<Heart className="w-5 h-5 text-white fill-current" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-md font-semibold text-gray-900 mb-1">
|
||||
{OutfitFavoriteService.getDisplayName(selectedFavorite)}
|
||||
</h4>
|
||||
<p className="text-gray-600 text-sm mb-2 line-clamp-2">
|
||||
{OutfitFavoriteService.getDescription(selectedFavorite)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{OutfitFavoriteService.getStyleTags(selectedFavorite).slice(0, 2).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 重新选择按钮 */}
|
||||
<button
|
||||
onClick={() => onSelect(null as any)}
|
||||
className="w-full px-4 py-2 text-blue-600 border border-blue-300 rounded-lg hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
重新选择
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{availableFavorites.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Heart className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500">没有可选择的收藏方案</p>
|
||||
</div>
|
||||
) : (
|
||||
availableFavorites.map((favorite) => (
|
||||
<button
|
||||
key={favorite.id}
|
||||
onClick={() => onSelect(favorite)}
|
||||
className="w-full text-left p-3 border border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-all"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-red-500 to-pink-600 rounded-lg flex items-center justify-center shadow-sm flex-shrink-0">
|
||||
<Heart className="w-4 h-4 text-white fill-current" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1 truncate">
|
||||
{OutfitFavoriteService.getDisplayName(favorite)}
|
||||
</h4>
|
||||
<p className="text-gray-600 text-xs line-clamp-1">
|
||||
{OutfitFavoriteService.getDescription(favorite)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 如果两个方案都已选择,显示对比视图
|
||||
if (selectedFavorite1 && selectedFavorite2) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 页面标题 */}
|
||||
<div className="page-header flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<ArrowLeftRight className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-blue-600 bg-clip-text text-transparent">
|
||||
方案对比
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">对比两个收藏方案的素材检索结果</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
重新选择
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 对比视图 */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<OutfitComparisonView
|
||||
favorite1={selectedFavorite1}
|
||||
favorite2={selectedFavorite2}
|
||||
isTemporary={isTemporary}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 页面标题 */}
|
||||
<div className="page-header flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<ArrowLeftRight className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-blue-600 bg-clip-text text-transparent">
|
||||
方案对比
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">选择两个收藏方案进行对比分析</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选择界面 */}
|
||||
<div className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 border-4 border-primary-500 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-gray-600">加载收藏列表...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={loadFavorites}
|
||||
className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
) : favorites.length < 2 ? (
|
||||
<div className="text-center py-12">
|
||||
<ArrowLeftRight className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">需要至少两个收藏方案</h3>
|
||||
<p className="text-gray-500 mb-6">请先收藏一些穿搭方案,然后再进行对比</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/tools/outfit-recommendation'}
|
||||
className="px-6 py-3 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
去收藏方案
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 方案1选择器 */}
|
||||
{renderFavoriteSelector(
|
||||
'选择第一个方案',
|
||||
selectedFavorite1,
|
||||
setSelectedFavorite1,
|
||||
selectedFavorite2?.id
|
||||
)}
|
||||
|
||||
{/* 方案2选择器 */}
|
||||
{renderFavoriteSelector(
|
||||
'选择第二个方案',
|
||||
selectedFavorite2,
|
||||
setSelectedFavorite2,
|
||||
selectedFavorite1?.id
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutfitComparisonTool;
|
||||
205
apps/desktop/src/pages/tools/OutfitFavoritesTool.tsx
Normal file
205
apps/desktop/src/pages/tools/OutfitFavoritesTool.tsx
Normal file
@@ -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<OutfitFavorite | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<any>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(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 (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 页面标题 */}
|
||||
<div className="page-header flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedFavorite && (
|
||||
<button
|
||||
onClick={handleBackToList}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-pink-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Heart className="w-6 h-6 text-white fill-current" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-red-600 bg-clip-text text-transparent">
|
||||
{selectedFavorite ? '素材检索' : '收藏管理'}
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
{selectedFavorite
|
||||
? `基于"${OutfitFavoriteService.getDisplayName(selectedFavorite)}"的素材检索`
|
||||
: '管理您收藏的穿搭方案'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{!selectedFavorite ? (
|
||||
/* 收藏列表视图 */
|
||||
<div className="card p-6 h-full">
|
||||
<FavoriteOutfitList
|
||||
onFavoriteSelect={handleFavoriteSelect}
|
||||
onFavoriteDelete={handleFavoriteDelete}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* 素材检索结果视图 */
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 方案信息卡片 */}
|
||||
<div className="card p-4 mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-pink-600 rounded-xl flex items-center justify-center shadow-lg flex-shrink-0">
|
||||
<Heart className="w-6 h-6 text-white fill-current" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">
|
||||
{OutfitFavoriteService.getDisplayName(selectedFavorite)}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-2">
|
||||
{OutfitFavoriteService.getDescription(selectedFavorite)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{OutfitFavoriteService.getStyleTags(selectedFavorite).slice(0, 3).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{OutfitFavoriteService.getOccasions(selectedFavorite).slice(0, 2).map((occasion, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full"
|
||||
>
|
||||
{occasion}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 检索结果 */}
|
||||
<div className="card p-6 flex-1 min-h-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">检索结果</h2>
|
||||
{searchResults && (
|
||||
<span className="text-sm text-gray-500">
|
||||
找到 {searchResults.total_size} 个结果
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Search className="w-8 h-8 text-primary-500 animate-pulse" />
|
||||
<span className="ml-3 text-gray-600">正在检索素材...</span>
|
||||
</div>
|
||||
) : searchError ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 mb-4">{searchError}</p>
|
||||
<button
|
||||
onClick={() => handleFavoriteSelect(selectedFavorite)}
|
||||
className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
重新检索
|
||||
</button>
|
||||
</div>
|
||||
) : searchResults ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
{searchResults.results.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{searchResults.results.map((result: any, index: number) => (
|
||||
<div key={result.id || index} className="card p-4 hover:shadow-lg transition-shadow">
|
||||
<img
|
||||
src={result.image_url}
|
||||
alt="Material"
|
||||
className="w-full h-48 object-cover rounded-lg mb-3"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik0xMDAgNzBMMTMwIDEwMEgxMTBWMTMwSDkwVjEwMEg3MEwxMDAgNzBaIiBmaWxsPSIjOUI5QkEwIi8+Cjx0ZXh0IHg9IjEwMCIgeT0iMTUwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOUI5QkEwIiBmb250LXNpemU9IjEyIj7lm77niYfliKDpmaTlpLHotKU8L3RleHQ+Cjwvc3ZnPg==';
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-600 line-clamp-2">
|
||||
{result.style_description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{result.environment_tags?.slice(0, 3).map((tag: string, i: number) => (
|
||||
<span key={i} className="px-2 py-1 bg-gray-100 text-xs rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{result.relevance_score && (
|
||||
<div className="text-xs text-gray-500">
|
||||
相关度: {(result.relevance_score * 100).toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">没有找到相关素材</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutfitFavoritesTool;
|
||||
@@ -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<any>(null);
|
||||
|
||||
// 方案对比状态
|
||||
const [selectedForComparison, setSelectedForComparison] = useState<OutfitRecommendation[]>([]);
|
||||
|
||||
// 状态恢复提示
|
||||
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 = () => {
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">AI穿搭方案推荐</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold text-gray-900">AI穿搭方案推荐</h1>
|
||||
{showRestoredTip && (
|
||||
<div className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full animate-fade-in">
|
||||
已恢复上次结果
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">基于TikTok视觉趋势的智能穿搭建议</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 收藏管理入口 */}
|
||||
<button
|
||||
onClick={handleOpenFavoriteManagement}
|
||||
className="flex items-center gap-2 px-3 py-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors duration-200"
|
||||
title="收藏管理"
|
||||
>
|
||||
<Heart className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">收藏管理</span>
|
||||
</button>
|
||||
|
||||
{/* 方案对比入口 */}
|
||||
<button
|
||||
onClick={handleOpenComparison}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors duration-200 relative ${
|
||||
selectedForComparison.length > 0
|
||||
? 'text-blue-600 bg-blue-50 hover:bg-blue-100'
|
||||
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
|
||||
}`}
|
||||
title={`方案对比 ${selectedForComparison.length > 0 ? `(已选择${selectedForComparison.length}个)` : ''}`}
|
||||
>
|
||||
<ArrowLeftRight className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">方案对比</span>
|
||||
{selectedForComparison.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-blue-500 text-white text-xs rounded-full flex items-center justify-center">
|
||||
{selectedForComparison.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 分隔线 */}
|
||||
{recommendations.length > 0 && (
|
||||
<div className="h-6 w-px bg-gray-300 mx-1"></div>
|
||||
)}
|
||||
|
||||
{recommendations.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
@@ -433,10 +638,39 @@ const OutfitRecommendationTool: React.FC = () => {
|
||||
<li>• 可以指定场合、风格、颜色偏好</li>
|
||||
<li>• 每个方案都包含TikTok优化建议</li>
|
||||
<li>• 支持导出和复制结果</li>
|
||||
<li>• 点击方案卡片的"对比"按钮选择方案进行对比</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 对比状态面板 */}
|
||||
{selectedForComparison.length > 0 && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<ArrowLeftRight className="w-5 h-5 text-green-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-green-800 flex-1">
|
||||
<p className="font-medium mb-1">方案对比</p>
|
||||
<p className="text-xs mb-2">
|
||||
已选择 {selectedForComparison.length}/2 个方案
|
||||
{selectedForComparison.length === 2 && ',即将自动跳转到对比页面...'}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{selectedForComparison.map((rec, index) => (
|
||||
<div key={rec.id} className="text-xs bg-white bg-opacity-50 rounded px-2 py-1">
|
||||
{index + 1}. {rec.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selectedForComparison.length === 1 && (
|
||||
<p className="text-xs mt-2 opacity-75">
|
||||
再选择一个方案即可开始对比
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -452,6 +686,8 @@ const OutfitRecommendationTool: React.FC = () => {
|
||||
onRegenerate={handleRegenerate}
|
||||
onMaterialSearch={handleMaterialSearch}
|
||||
onLoadMoreForGroup={handleLoadMoreForGroup}
|
||||
onAddToComparison={handleAddToComparison}
|
||||
selectedForComparison={selectedForComparison}
|
||||
className="min-h-[600px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 : '穿搭方案生成失败');
|
||||
|
||||
268
apps/desktop/src/services/outfitFavoriteService.ts
Normal file
268
apps/desktop/src/services/outfitFavoriteService.ts
Normal file
@@ -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<SaveOutfitToFavoritesResponse> {
|
||||
try {
|
||||
const request: SaveOutfitToFavoritesRequest = {
|
||||
recommendation,
|
||||
custom_name: customName,
|
||||
};
|
||||
|
||||
console.log('💖 保存方案到收藏:', request);
|
||||
|
||||
const response = await invoke<SaveOutfitToFavoritesResponse>(
|
||||
'save_outfit_to_favorites',
|
||||
{ request }
|
||||
);
|
||||
|
||||
console.log('✅ 方案收藏成功:', response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('❌ 方案收藏失败:', error);
|
||||
throw new Error(`方案收藏失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有收藏的方案
|
||||
*/
|
||||
static async getFavoriteOutfits(): Promise<GetFavoriteOutfitsResponse> {
|
||||
try {
|
||||
console.log('📋 获取收藏方案列表');
|
||||
|
||||
const response = await invoke<GetFavoriteOutfitsResponse>(
|
||||
'get_favorite_outfits'
|
||||
);
|
||||
|
||||
console.log('✅ 获取收藏列表成功:', response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('❌ 获取收藏列表失败:', error);
|
||||
throw new Error(`获取收藏列表失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从收藏中移除方案
|
||||
*/
|
||||
static async removeFromFavorites(favoriteId: string): Promise<boolean> {
|
||||
try {
|
||||
console.log('🗑️ 从收藏中移除方案:', favoriteId);
|
||||
|
||||
const response = await invoke<boolean>(
|
||||
'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<MaterialSearchResponse> {
|
||||
try {
|
||||
const request: SearchMaterialsByFavoriteRequest = {
|
||||
favorite_id: favoriteId,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
};
|
||||
|
||||
console.log('🔍 基于收藏方案检索素材:', request);
|
||||
|
||||
const response = await invoke<MaterialSearchResponse>(
|
||||
'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<CompareOutfitFavoritesResponse> {
|
||||
try {
|
||||
const request: CompareOutfitFavoritesRequest = {
|
||||
favorite_id_1: favoriteId1,
|
||||
favorite_id_2: favoriteId2,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
};
|
||||
|
||||
console.log('🔄 对比收藏方案:', request);
|
||||
|
||||
const response = await invoke<CompareOutfitFavoritesResponse>(
|
||||
'compare_outfit_favorites',
|
||||
{ request }
|
||||
);
|
||||
|
||||
console.log('✅ 方案对比成功:', response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('❌ 方案对比失败:', error);
|
||||
throw new Error(`方案对比失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查方案是否已收藏
|
||||
*/
|
||||
static async isOutfitFavorited(recommendationId: string): Promise<boolean> {
|
||||
try {
|
||||
console.log('🔍 检查方案收藏状态:', recommendationId);
|
||||
|
||||
const response = await invoke<boolean>(
|
||||
'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;
|
||||
}
|
||||
}
|
||||
}
|
||||
134
apps/desktop/src/types/materialSearch.ts
Normal file
134
apps/desktop/src/types/materialSearch.ts
Normal file
@@ -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,
|
||||
};
|
||||
114
apps/desktop/src/types/outfitFavorite.ts
Normal file
114
apps/desktop/src/types/outfitFavorite.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -213,21 +213,3 @@ export async function runAllTests(): Promise<void> {
|
||||
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()');
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user