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 gemini_analysis;
|
||||||
pub mod outfit_recommendation;
|
pub mod outfit_recommendation;
|
||||||
pub mod material_search;
|
pub mod material_search;
|
||||||
|
pub mod outfit_favorite;
|
||||||
pub mod custom_tag;
|
pub mod custom_tag;
|
||||||
pub mod watermark;
|
pub mod watermark;
|
||||||
pub mod thumbnail;
|
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 video_generation_repository;
|
||||||
pub mod conversation_repository;
|
pub mod conversation_repository;
|
||||||
pub mod custom_tag_repository;
|
pub mod custom_tag_repository;
|
||||||
|
pub mod outfit_favorite_repository;
|
||||||
pub mod watermark_template_repository;
|
pub mod watermark_template_repository;
|
||||||
pub mod template_segment_weight_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(),
|
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()),
|
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>)> {
|
fn parse_outfit_recommendation_groups(&self, raw_response: &str, _request: &OutfitRecommendationRequest) -> Result<(GroupingStrategy, Vec<OutfitRecommendationGroup>)> {
|
||||||
println!("🔍 开始解析穿搭方案分组响应...");
|
println!("🔍 开始解析穿搭方案分组响应...");
|
||||||
println!("📄 原始响应: {}", raw_response);
|
|
||||||
|
|
||||||
// 尝试提取JSON部分
|
// 尝试提取JSON部分
|
||||||
let json_str = self.extract_json_from_response(raw_response)?;
|
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>> {
|
fn parse_outfit_recommendations(&self, raw_response: &str, _request: &OutfitRecommendationRequest) -> Result<Vec<OutfitRecommendation>> {
|
||||||
println!("🔍 开始解析穿搭方案响应...");
|
println!("🔍 开始解析穿搭方案响应...");
|
||||||
println!("📄 原始响应: {}", raw_response);
|
|
||||||
|
|
||||||
// 尝试提取JSON部分
|
// 尝试提取JSON部分
|
||||||
let json_str = self.extract_json_from_response(raw_response)?;
|
let json_str = self.extract_json_from_response(raw_response)?;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod infrastructure;
|
|||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod business;
|
pub mod business;
|
||||||
pub mod presentation;
|
pub mod presentation;
|
||||||
|
pub mod services;
|
||||||
|
|
||||||
// 应用状态和配置
|
// 应用状态和配置
|
||||||
pub mod app_state;
|
pub mod app_state;
|
||||||
@@ -302,6 +303,13 @@ pub fn run() {
|
|||||||
commands::material_search_commands::generate_material_search_query,
|
commands::material_search_commands::generate_material_search_query,
|
||||||
commands::material_search_commands::search_materials_for_outfit,
|
commands::material_search_commands::search_materials_for_outfit,
|
||||||
commands::material_search_commands::quick_material_search,
|
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::quick_similarity_search,
|
||||||
commands::similarity_search_commands::get_similarity_search_suggestions,
|
commands::similarity_search_commands::get_similarity_search_suggestions,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub mod video_generation_commands;
|
|||||||
pub mod tools_commands;
|
pub mod tools_commands;
|
||||||
pub mod outfit_search_commands;
|
pub mod outfit_search_commands;
|
||||||
pub mod material_search_commands;
|
pub mod material_search_commands;
|
||||||
|
pub mod outfit_favorite_commands;
|
||||||
pub mod similarity_search_commands;
|
pub mod similarity_search_commands;
|
||||||
pub mod custom_tag_commands;
|
pub mod custom_tag_commands;
|
||||||
pub mod tolerant_json_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 OutfitRecommendationTool from './pages/tools/OutfitRecommendationTool';
|
||||||
import AdvancedFilterTool from './pages/tools/AdvancedFilterTool';
|
import AdvancedFilterTool from './pages/tools/AdvancedFilterTool';
|
||||||
import OutfitSearchTool from './pages/tools/OutfitSearchTool';
|
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 { EnrichedAnalysisDemo } from './pages/tools/EnrichedAnalysisDemo';
|
||||||
|
|
||||||
import Navigation from './components/Navigation';
|
import Navigation from './components/Navigation';
|
||||||
@@ -33,10 +36,6 @@ import { screenAdaptationService } from './services/screenAdaptationService';
|
|||||||
import "./App.css";
|
import "./App.css";
|
||||||
import './styles/design-system.css';
|
import './styles/design-system.css';
|
||||||
import './styles/animations.css';
|
import './styles/animations.css';
|
||||||
// 导入测试工具(仅在开发环境)
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
import('./utils/testChatFunction');
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* 主应用组件
|
* 主应用组件
|
||||||
* 遵循 Tauri 开发规范的应用架构设计
|
* 遵循 Tauri 开发规范的应用架构设计
|
||||||
@@ -128,6 +127,9 @@ function App() {
|
|||||||
<Route path="/tools/batch-thumbnail-generator" element={<BatchThumbnailGenerator />} />
|
<Route path="/tools/batch-thumbnail-generator" element={<BatchThumbnailGenerator />} />
|
||||||
<Route path="/tools/outfit-recommendation" element={<OutfitRecommendationTool />} />
|
<Route path="/tools/outfit-recommendation" element={<OutfitRecommendationTool />} />
|
||||||
<Route path="/tools/outfit-search" element={<OutfitSearchTool />} />
|
<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/advanced-filter-demo" element={<AdvancedFilterTool />} />
|
||||||
<Route path="/tools/enriched-analysis-demo" element={<EnrichedAnalysisDemo />} />
|
<Route path="/tools/enriched-analysis-demo" element={<EnrichedAnalysisDemo />} />
|
||||||
</Routes>
|
</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 React, { useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Sparkles,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
Clock,
|
Clock,
|
||||||
Palette,
|
Palette,
|
||||||
@@ -13,8 +12,10 @@ import {
|
|||||||
Sunrise,
|
Sunrise,
|
||||||
Sunset,
|
Sunset,
|
||||||
Search,
|
Search,
|
||||||
|
ArrowLeftRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { OutfitRecommendationCardProps } from '../../types/outfitRecommendation';
|
import { OutfitRecommendationCardProps } from '../../types/outfitRecommendation';
|
||||||
|
import FavoriteButton from './FavoriteButton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 穿搭方案推荐卡片组件
|
* 穿搭方案推荐卡片组件
|
||||||
@@ -25,6 +26,8 @@ export const OutfitRecommendationCard: React.FC<OutfitRecommendationCardProps> =
|
|||||||
onSelect,
|
onSelect,
|
||||||
onSceneSearch,
|
onSceneSearch,
|
||||||
onMaterialSearch,
|
onMaterialSearch,
|
||||||
|
onAddToComparison,
|
||||||
|
isSelectedForComparison = false,
|
||||||
showDetails = true,
|
showDetails = true,
|
||||||
compact = false,
|
compact = false,
|
||||||
className = '',
|
className = '',
|
||||||
@@ -66,6 +69,14 @@ export const OutfitRecommendationCard: React.FC<OutfitRecommendationCardProps> =
|
|||||||
setIsExpanded(!isExpanded);
|
setIsExpanded(!isExpanded);
|
||||||
}, [isExpanded]);
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
// 处理添加到对比
|
||||||
|
const handleAddToComparison = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onAddToComparison) {
|
||||||
|
onAddToComparison(recommendation);
|
||||||
|
}
|
||||||
|
}, [recommendation, onAddToComparison]);
|
||||||
|
|
||||||
// 获取时间段图标
|
// 获取时间段图标
|
||||||
const getTimeIcon = (timeOfDay: string) => {
|
const getTimeIcon = (timeOfDay: string) => {
|
||||||
switch (timeOfDay.toLowerCase()) {
|
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="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="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">
|
<h3 className="text-lg font-bold text-high-emphasis mb-2 group-hover:text-primary-600 transition-colors duration-200">
|
||||||
{recommendation.title}
|
{recommendation.title}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -275,7 +295,7 @@ export const OutfitRecommendationCard: React.FC<OutfitRecommendationCardProps> =
|
|||||||
{recommendation.color_theme}
|
{recommendation.color_theme}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{onMaterialSearch && (
|
{onMaterialSearch && (
|
||||||
<button
|
<button
|
||||||
onClick={handleMaterialSearch}
|
onClick={handleMaterialSearch}
|
||||||
@@ -296,6 +316,21 @@ export const OutfitRecommendationCard: React.FC<OutfitRecommendationCardProps> =
|
|||||||
<ExternalLink className="w-3 h-3" />
|
<ExternalLink className="w-3 h-3" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export const OutfitRecommendationList: React.FC<OutfitRecommendationListProps> =
|
|||||||
onMaterialSearch,
|
onMaterialSearch,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
onLoadMoreForGroup,
|
onLoadMoreForGroup,
|
||||||
|
onAddToComparison,
|
||||||
|
selectedForComparison = [],
|
||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
// 加载状态
|
// 加载状态
|
||||||
@@ -228,6 +230,8 @@ export const OutfitRecommendationList: React.FC<OutfitRecommendationListProps> =
|
|||||||
onSelect={onRecommendationSelect}
|
onSelect={onRecommendationSelect}
|
||||||
onSceneSearch={onSceneSearch}
|
onSceneSearch={onSceneSearch}
|
||||||
onMaterialSearch={onMaterialSearch}
|
onMaterialSearch={onMaterialSearch}
|
||||||
|
onAddToComparison={onAddToComparison}
|
||||||
|
isSelectedForComparison={selectedForComparison.some(r => r.id === recommendation.id)}
|
||||||
showDetails={true}
|
showDetails={true}
|
||||||
compact={false}
|
compact={false}
|
||||||
className="animate-fade-in-up"
|
className="animate-fade-in-up"
|
||||||
@@ -247,6 +251,8 @@ export const OutfitRecommendationList: React.FC<OutfitRecommendationListProps> =
|
|||||||
onSelect={onRecommendationSelect}
|
onSelect={onRecommendationSelect}
|
||||||
onSceneSearch={onSceneSearch}
|
onSceneSearch={onSceneSearch}
|
||||||
onMaterialSearch={onMaterialSearch}
|
onMaterialSearch={onMaterialSearch}
|
||||||
|
onAddToComparison={onAddToComparison}
|
||||||
|
isSelectedForComparison={selectedForComparison.some(r => r.id === recommendation.id)}
|
||||||
showDetails={true}
|
showDetails={true}
|
||||||
compact={false}
|
compact={false}
|
||||||
className="animate-fade-in-up"
|
className="animate-fade-in-up"
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
FileText,
|
|
||||||
Code,
|
Code,
|
||||||
Bug,
|
|
||||||
Wrench,
|
Wrench,
|
||||||
Database,
|
Database,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
MessageCircle,
|
|
||||||
Droplets,
|
|
||||||
ImageIcon,
|
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Filter,
|
Heart,
|
||||||
BarChart3
|
ArrowLeftRight
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Tool, ToolCategory, ToolStatus } from '../types/tool';
|
import { Tool, ToolCategory, ToolStatus } from '../types/tool';
|
||||||
|
|
||||||
@@ -20,92 +15,6 @@ import { Tool, ToolCategory, ToolStatus } from '../types/tool';
|
|||||||
* 定义所有可用的工具及其属性
|
* 定义所有可用的工具及其属性
|
||||||
*/
|
*/
|
||||||
export const TOOLS_DATA: 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',
|
id: 'similarity-search',
|
||||||
name: '相似度检索工具',
|
name: '相似度检索工具',
|
||||||
@@ -136,20 +45,6 @@ export const TOOLS_DATA: Tool[] = [
|
|||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
lastUpdated: '2024-01-25'
|
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',
|
id: 'outfit-search',
|
||||||
name: '智能服装搜索',
|
name: '智能服装搜索',
|
||||||
@@ -166,19 +61,49 @@ export const TOOLS_DATA: Tool[] = [
|
|||||||
lastUpdated: '2024-01-26'
|
lastUpdated: '2024-01-26'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'enriched-analysis-demo',
|
id: 'outfit-favorites',
|
||||||
name: '🎨 丰富图像分析演示',
|
name: '穿搭方案收藏管理',
|
||||||
description: '展示智能服装分析的详细结果,包含颜色分析、风格分析、产品分析等丰富信息',
|
description: '管理收藏的穿搭方案,支持基于收藏方案的智能素材检索',
|
||||||
longDescription: '专业的图像分析结果展示工具,提供极其详细和丰富的分析信息。包含颜色和谐度分析、色彩温度匹配、风格一致性评估、产品匹配度统计、环境适配性分析、拍摄质量评估、个性化搭配建议等功能。支持可展开的分析报告、统计图表、建议卡片和完整的分析摘要。',
|
longDescription: '专业的穿搭方案收藏管理工具,提供完整的收藏管理功能。支持收藏方案的添加、删除、搜索和筛选,以及基于收藏方案的智能素材检索功能。帮助用户建立个人穿搭方案库,快速找到适合的素材。',
|
||||||
icon: BarChart3,
|
icon: Heart,
|
||||||
route: '/tools/enriched-analysis-demo',
|
route: '/tools/outfit-favorites',
|
||||||
category: ToolCategory.AI_TOOLS,
|
category: ToolCategory.AI_TOOLS,
|
||||||
status: ToolStatus.BETA,
|
status: ToolStatus.STABLE,
|
||||||
tags: ['图像分析', '详细报告', '颜色分析', '风格分析', '搭配建议', '统计图表'],
|
tags: ['收藏管理', '穿搭方案', '素材检索', '个人库', '智能搜索'],
|
||||||
isNew: true,
|
isNew: true,
|
||||||
isPopular: true,
|
isPopular: true,
|
||||||
version: '1.0.0',
|
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 {
|
import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Wand2,
|
Wand2,
|
||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Settings,
|
Settings,
|
||||||
Info
|
Info,
|
||||||
|
Heart,
|
||||||
|
ArrowLeftRight
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import OutfitRecommendationService from '../../services/outfitRecommendationService';
|
import OutfitRecommendationService from '../../services/outfitRecommendationService';
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +24,23 @@ import OutfitRecommendationList from '../../components/outfit/OutfitRecommendati
|
|||||||
import { CustomSelect } from '../../components/CustomSelect';
|
import { CustomSelect } from '../../components/CustomSelect';
|
||||||
import { MaterialSearchPanel, MaterialDetailModal } from '../../components/material';
|
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穿搭方案推荐小工具
|
* AI穿搭方案推荐小工具
|
||||||
* 遵循 Tauri 开发规范和 UI/UX 设计标准
|
* 遵循 Tauri 开发规范和 UI/UX 设计标准
|
||||||
@@ -51,6 +70,110 @@ const OutfitRecommendationTool: React.FC = () => {
|
|||||||
const [showMaterialDetail, setShowMaterialDetail] = useState(false);
|
const [showMaterialDetail, setShowMaterialDetail] = useState(false);
|
||||||
const [selectedMaterial, setSelectedMaterial] = useState<any>(null);
|
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 () => {
|
const handleGenerate = useCallback(async () => {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
@@ -83,6 +206,11 @@ const OutfitRecommendationTool: React.FC = () => {
|
|||||||
setGroupingStrategy(null);
|
setGroupingStrategy(null);
|
||||||
setRecommendations(response.recommendations || []);
|
setRecommendations(response.recommendations || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成成功后立即保存状态
|
||||||
|
setTimeout(() => {
|
||||||
|
saveState();
|
||||||
|
}, 100);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('穿搭方案生成失败:', err);
|
console.error('穿搭方案生成失败:', err);
|
||||||
setError(err instanceof Error ? err.message : '穿搭方案生成失败');
|
setError(err instanceof Error ? err.message : '穿搭方案生成失败');
|
||||||
@@ -167,6 +295,14 @@ const OutfitRecommendationTool: React.FC = () => {
|
|||||||
setGroupingStrategy(null);
|
setGroupingStrategy(null);
|
||||||
setRecommendations([]);
|
setRecommendations([]);
|
||||||
setError(null);
|
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);
|
URL.revokeObjectURL(url);
|
||||||
}, [recommendations, query]);
|
}, [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(() => {
|
const handleCopy = useCallback(() => {
|
||||||
if (recommendations.length === 0) {
|
if (recommendations.length === 0) {
|
||||||
@@ -255,13 +419,54 @@ const OutfitRecommendationTool: React.FC = () => {
|
|||||||
<Sparkles className="w-5 h-5 text-white" />
|
<Sparkles className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-sm text-gray-600">基于TikTok视觉趋势的智能穿搭建议</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{recommendations.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -433,10 +638,39 @@ const OutfitRecommendationTool: React.FC = () => {
|
|||||||
<li>• 可以指定场合、风格、颜色偏好</li>
|
<li>• 可以指定场合、风格、颜色偏好</li>
|
||||||
<li>• 每个方案都包含TikTok优化建议</li>
|
<li>• 每个方案都包含TikTok优化建议</li>
|
||||||
<li>• 支持导出和复制结果</li>
|
<li>• 支持导出和复制结果</li>
|
||||||
|
<li>• 点击方案卡片的"对比"按钮选择方案进行对比</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,6 +686,8 @@ const OutfitRecommendationTool: React.FC = () => {
|
|||||||
onRegenerate={handleRegenerate}
|
onRegenerate={handleRegenerate}
|
||||||
onMaterialSearch={handleMaterialSearch}
|
onMaterialSearch={handleMaterialSearch}
|
||||||
onLoadMoreForGroup={handleLoadMoreForGroup}
|
onLoadMoreForGroup={handleLoadMoreForGroup}
|
||||||
|
onAddToComparison={handleAddToComparison}
|
||||||
|
selectedForComparison={selectedForComparison}
|
||||||
className="min-h-[600px]"
|
className="min-h-[600px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ const SimilaritySearchTool: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await OutfitRecommendationService.quickGenerate(query.trim(), 3);
|
const response = await OutfitRecommendationService.quickGenerate(query.trim(), 3);
|
||||||
setOutfitRecommendations(response.recommendations);
|
setOutfitRecommendations(response.recommendations || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('穿搭方案生成失败:', error);
|
console.error('穿搭方案生成失败:', error);
|
||||||
setOutfitError(error instanceof Error ? error.message : '穿搭方案生成失败');
|
setOutfitError(error instanceof Error ? error.message : '穿搭方案生成失败');
|
||||||
@@ -211,7 +211,7 @@ const SimilaritySearchTool: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await OutfitRecommendationService.quickGenerate(query.trim(), 3);
|
const response = await OutfitRecommendationService.quickGenerate(query.trim(), 3);
|
||||||
setOutfitRecommendations(response.recommendations);
|
setOutfitRecommendations(response.recommendations || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('穿搭方案重新生成失败:', error);
|
console.error('穿搭方案重新生成失败:', error);
|
||||||
setOutfitError(error instanceof Error ? error.message : '穿搭方案生成失败');
|
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;
|
onSceneSearch?: (recommendation: OutfitRecommendation) => void;
|
||||||
/** 素材检索事件处理 */
|
/** 素材检索事件处理 */
|
||||||
onMaterialSearch?: (recommendation: OutfitRecommendation) => void;
|
onMaterialSearch?: (recommendation: OutfitRecommendation) => void;
|
||||||
|
/** 添加到对比事件处理 */
|
||||||
|
onAddToComparison?: (recommendation: OutfitRecommendation) => void;
|
||||||
|
/** 是否已选择用于对比 */
|
||||||
|
isSelectedForComparison?: boolean;
|
||||||
/** 是否显示详细信息 */
|
/** 是否显示详细信息 */
|
||||||
showDetails?: boolean;
|
showDetails?: boolean;
|
||||||
/** 是否紧凑模式 */
|
/** 是否紧凑模式 */
|
||||||
@@ -204,6 +208,10 @@ export interface OutfitRecommendationListProps {
|
|||||||
onRegenerate?: () => void;
|
onRegenerate?: () => void;
|
||||||
/** 获取更多同类方案事件 */
|
/** 获取更多同类方案事件 */
|
||||||
onLoadMoreForGroup?: (groupId: string, styleKeywords: string[]) => void;
|
onLoadMoreForGroup?: (groupId: string, styleKeywords: string[]) => void;
|
||||||
|
/** 添加到对比事件 */
|
||||||
|
onAddToComparison?: (recommendation: OutfitRecommendation) => void;
|
||||||
|
/** 已选择用于对比的方案 */
|
||||||
|
selectedForComparison?: OutfitRecommendation[];
|
||||||
/** 自定义样式类名 */
|
/** 自定义样式类名 */
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,21 +213,3 @@ export async function runAllTests(): Promise<void> {
|
|||||||
console.log('⚠️ 部分测试失败,请检查相关配置和服务状态。');
|
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",
|
"docker:down": "docker-compose down",
|
||||||
"tauri:dev": "pnpm --filter=@mixvideo/desktop tauri:dev",
|
"tauri:dev": "pnpm --filter=@mixvideo/desktop tauri:dev",
|
||||||
"tauri:build": "pnpm --filter=@mixvideo/desktop tauri:build",
|
"tauri:build": "pnpm --filter=@mixvideo/desktop tauri:build",
|
||||||
|
"tauri:web:build": "pnpm --filter=@mixvideo/desktop build",
|
||||||
"python:dev": "pnpm --filter python-service dev",
|
"python:dev": "pnpm --filter python-service dev",
|
||||||
"services:start": "docker-compose up python-service -d"
|
"services:start": "docker-compose up python-service -d"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user