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:
imeepos
2025-07-28 15:53:20 +08:00
parent 5c019b48df
commit 8a5988b3de
36 changed files with 4262 additions and 148 deletions

View File

@@ -16,6 +16,7 @@ pub mod outfit_search;
pub mod gemini_analysis;
pub mod outfit_recommendation;
pub mod material_search;
pub mod outfit_favorite;
pub mod custom_tag;
pub mod watermark;
pub mod thumbnail;

View 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>,
}

View File

@@ -11,5 +11,6 @@ pub mod export_record_repository;
pub mod video_generation_repository;
pub mod conversation_repository;
pub mod custom_tag_repository;
pub mod outfit_favorite_repository;
pub mod watermark_template_repository;
pub mod template_segment_weight_repository;

View File

@@ -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)
}
}

View File

@@ -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);
}
}

View File

@@ -196,6 +196,14 @@ impl MigrationManager {
up_sql: include_str!("migrations/020_create_template_segment_weights_table.sql").to_string(),
down_sql: Some(include_str!("migrations/020_create_template_segment_weights_table_down.sql").to_string()),
});
// 迁移 21: 创建穿搭方案收藏表
self.add_migration(Migration {
version: 21,
description: "创建穿搭方案收藏表".to_string(),
up_sql: include_str!("migrations/021_outfit_favorites.sql").to_string(),
down_sql: Some(include_str!("migrations/021_outfit_favorites_down.sql").to_string()),
});
}
/// 添加迁移

View File

@@ -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);

View File

@@ -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;

View File

@@ -1927,7 +1927,6 @@ impl GeminiService {
/// 解析穿搭方案分组响应
fn parse_outfit_recommendation_groups(&self, raw_response: &str, _request: &OutfitRecommendationRequest) -> Result<(GroupingStrategy, Vec<OutfitRecommendationGroup>)> {
println!("🔍 开始解析穿搭方案分组响应...");
println!("📄 原始响应: {}", raw_response);
// 尝试提取JSON部分
let json_str = self.extract_json_from_response(raw_response)?;
@@ -2051,7 +2050,6 @@ impl GeminiService {
/// 解析穿搭方案推荐响应 (向后兼容)
fn parse_outfit_recommendations(&self, raw_response: &str, _request: &OutfitRecommendationRequest) -> Result<Vec<OutfitRecommendation>> {
println!("🔍 开始解析穿搭方案响应...");
println!("📄 原始响应: {}", raw_response);
// 尝试提取JSON部分
let json_str = self.extract_json_from_response(raw_response)?;

View File

@@ -6,6 +6,7 @@ pub mod infrastructure;
pub mod data;
pub mod business;
pub mod presentation;
pub mod services;
// 应用状态和配置
pub mod app_state;
@@ -302,6 +303,13 @@ pub fn run() {
commands::material_search_commands::generate_material_search_query,
commands::material_search_commands::search_materials_for_outfit,
commands::material_search_commands::quick_material_search,
// 穿搭方案收藏命令
commands::outfit_favorite_commands::save_outfit_to_favorites,
commands::outfit_favorite_commands::get_favorite_outfits,
commands::outfit_favorite_commands::remove_from_favorites,
commands::outfit_favorite_commands::search_materials_by_favorite,
commands::outfit_favorite_commands::compare_outfit_favorites,
commands::outfit_favorite_commands::is_outfit_favorited,
// 相似度检索工具命令
commands::similarity_search_commands::quick_similarity_search,
commands::similarity_search_commands::get_similarity_search_suggestions,

View File

@@ -20,6 +20,7 @@ pub mod video_generation_commands;
pub mod tools_commands;
pub mod outfit_search_commands;
pub mod material_search_commands;
pub mod outfit_favorite_commands;
pub mod similarity_search_commands;
pub mod custom_tag_commands;
pub mod tolerant_json_commands;

View File

@@ -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)
})
}

View File

@@ -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"));
}
}

View 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
}

View File

@@ -0,0 +1 @@
pub mod material_search_service;

View File

@@ -22,6 +22,9 @@ import BatchThumbnailGenerator from './pages/tools/BatchThumbnailGenerator';
import OutfitRecommendationTool from './pages/tools/OutfitRecommendationTool';
import AdvancedFilterTool from './pages/tools/AdvancedFilterTool';
import OutfitSearchTool from './pages/tools/OutfitSearchTool';
import OutfitFavoritesTool from './pages/tools/OutfitFavoritesTool';
import OutfitComparisonTool from './pages/tools/OutfitComparisonTool';
import MaterialSearchTool from './pages/tools/MaterialSearchTool';
import { EnrichedAnalysisDemo } from './pages/tools/EnrichedAnalysisDemo';
import Navigation from './components/Navigation';
@@ -33,10 +36,6 @@ import { screenAdaptationService } from './services/screenAdaptationService';
import "./App.css";
import './styles/design-system.css';
import './styles/animations.css';
// 导入测试工具(仅在开发环境)
if (import.meta.env.DEV) {
import('./utils/testChatFunction');
}
/**
* 主应用组件
* 遵循 Tauri 开发规范的应用架构设计
@@ -128,6 +127,9 @@ function App() {
<Route path="/tools/batch-thumbnail-generator" element={<BatchThumbnailGenerator />} />
<Route path="/tools/outfit-recommendation" element={<OutfitRecommendationTool />} />
<Route path="/tools/outfit-search" element={<OutfitSearchTool />} />
<Route path="/tools/outfit-favorites" element={<OutfitFavoritesTool />} />
<Route path="/tools/outfit-comparison" element={<OutfitComparisonTool />} />
<Route path="/tools/material-search" element={<MaterialSearchTool />} />
<Route path="/tools/advanced-filter-demo" element={<AdvancedFilterTool />} />
<Route path="/tools/enriched-analysis-demo" element={<EnrichedAnalysisDemo />} />
</Routes>

View 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;

View 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;

View 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;

View 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;

View File

@@ -1,6 +1,5 @@
import React, { useState, useCallback } from 'react';
import {
Sparkles,
MapPin,
Clock,
Palette,
@@ -13,8 +12,10 @@ import {
Sunrise,
Sunset,
Search,
ArrowLeftRight,
} from 'lucide-react';
import { OutfitRecommendationCardProps } from '../../types/outfitRecommendation';
import FavoriteButton from './FavoriteButton';
/**
* 穿搭方案推荐卡片组件
@@ -25,6 +26,8 @@ export const OutfitRecommendationCard: React.FC<OutfitRecommendationCardProps> =
onSelect,
onSceneSearch,
onMaterialSearch,
onAddToComparison,
isSelectedForComparison = false,
showDetails = true,
compact = false,
className = '',
@@ -66,6 +69,14 @@ export const OutfitRecommendationCard: React.FC<OutfitRecommendationCardProps> =
setIsExpanded(!isExpanded);
}, [isExpanded]);
// 处理添加到对比
const handleAddToComparison = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (onAddToComparison) {
onAddToComparison(recommendation);
}
}, [recommendation, onAddToComparison]);
// 获取时间段图标
const getTimeIcon = (timeOfDay: string) => {
switch (timeOfDay.toLowerCase()) {
@@ -103,8 +114,17 @@ export const OutfitRecommendationCard: React.FC<OutfitRecommendationCardProps> =
<div className="absolute top-0 right-0 w-24 h-24 bg-gradient-to-br from-primary-100 to-primary-200 rounded-full -translate-y-12 translate-x-12 opacity-40 group-hover:opacity-60 transition-all duration-500 group-hover:scale-110"></div>
<div className="relative">
{/* 收藏按钮 */}
<div className="absolute top-0 right-0 z-10">
<FavoriteButton
recommendation={recommendation}
size="sm"
className="shadow-sm"
/>
</div>
{/* 标题和描述 */}
<div className="mb-4">
<div className="mb-4 pr-10">
<h3 className="text-lg font-bold text-high-emphasis mb-2 group-hover:text-primary-600 transition-colors duration-200">
{recommendation.title}
</h3>
@@ -275,7 +295,7 @@ export const OutfitRecommendationCard: React.FC<OutfitRecommendationCardProps> =
{recommendation.color_theme}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{onMaterialSearch && (
<button
onClick={handleMaterialSearch}
@@ -296,6 +316,21 @@ export const OutfitRecommendationCard: React.FC<OutfitRecommendationCardProps> =
<ExternalLink className="w-3 h-3" />
</button>
)}
{onAddToComparison && (
<button
onClick={handleAddToComparison}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors duration-200 text-sm font-medium ${
isSelectedForComparison
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-100 text-gray-700 hover:bg-blue-50 hover:text-blue-600'
}`}
title={isSelectedForComparison ? '已选择对比' : '添加到对比'}
>
<ArrowLeftRight className="w-4 h-4" />
{isSelectedForComparison ? '已选择' : '对比'}
</button>
)}
</div>
</div>
</div>

View File

@@ -25,6 +25,8 @@ export const OutfitRecommendationList: React.FC<OutfitRecommendationListProps> =
onMaterialSearch,
onRegenerate,
onLoadMoreForGroup,
onAddToComparison,
selectedForComparison = [],
className = '',
}) => {
// 加载状态
@@ -228,6 +230,8 @@ export const OutfitRecommendationList: React.FC<OutfitRecommendationListProps> =
onSelect={onRecommendationSelect}
onSceneSearch={onSceneSearch}
onMaterialSearch={onMaterialSearch}
onAddToComparison={onAddToComparison}
isSelectedForComparison={selectedForComparison.some(r => r.id === recommendation.id)}
showDetails={true}
compact={false}
className="animate-fade-in-up"
@@ -247,6 +251,8 @@ export const OutfitRecommendationList: React.FC<OutfitRecommendationListProps> =
onSelect={onRecommendationSelect}
onSceneSearch={onSceneSearch}
onMaterialSearch={onMaterialSearch}
onAddToComparison={onAddToComparison}
isSelectedForComparison={selectedForComparison.some(r => r.id === recommendation.id)}
showDetails={true}
compact={false}
className="animate-fade-in-up"

View File

@@ -1,17 +1,12 @@
import {
FileText,
Code,
Bug,
Wrench,
Database,
FileSearch,
MessageCircle,
Droplets,
ImageIcon,
Search,
Sparkles,
Filter,
BarChart3
Heart,
ArrowLeftRight
} from 'lucide-react';
import { Tool, ToolCategory, ToolStatus } from '../types/tool';
@@ -20,92 +15,6 @@ import { Tool, ToolCategory, ToolStatus } from '../types/tool';
* 定义所有可用的工具及其属性
*/
export const TOOLS_DATA: Tool[] = [
{
id: 'data-cleaning',
name: 'AI检索图片/数据清洗',
description: 'JSONL格式数据去重处理工具支持基于URI字段的精确匹配去重',
longDescription: '专业的数据清洗工具支持JSONL格式文件的批量处理。通过URI字段进行精确匹配快速去除重复数据提供实时进度显示和详细的处理统计信息。',
icon: FileText,
route: '/tools/data-cleaning',
category: ToolCategory.DATA_PROCESSING,
status: ToolStatus.STABLE,
tags: ['JSONL', '数据去重', '批量处理', 'URI匹配'],
isPopular: true,
version: '1.0.0',
lastUpdated: '2024-01-15'
},
{
id: 'json-parser',
name: '容错JSON解析器',
description: '基于Tree-sitter的大模型JSON容错解析器支持处理不规范的JSON数据',
longDescription: '高性能的JSON解析工具专门处理大模型返回的不规范JSON数据。支持注释、无引号键名、尾随逗号等非标准格式提供多种错误恢复策略。',
icon: Code,
route: '/tools/json-parser',
category: ToolCategory.DEVELOPMENT,
status: ToolStatus.STABLE,
tags: ['JSON解析', 'Tree-sitter', '容错处理', '大模型'],
isNew: true,
version: '2.1.0',
lastUpdated: '2024-01-20'
},
{
id: 'debug-panel',
name: 'JSON解析器调试面板',
description: '用于测试后端命令是否正常工作的调试工具面板',
longDescription: '开发者调试工具提供完整的后端命令测试功能。支持实时测试各种JSON解析场景查看详细的错误信息和性能统计数据。',
icon: Bug,
route: '/tools/debug-panel',
category: ToolCategory.DEVELOPMENT,
status: ToolStatus.BETA,
tags: ['调试工具', '后端测试', '命令测试', '开发辅助'],
version: '1.2.0',
lastUpdated: '2024-01-18'
},
{
id: 'ai-chat',
name: 'AI 智能聊天',
description: '基于 RAG 检索增强生成的智能对话助手,支持上下文保持和知识问答',
longDescription: '先进的AI聊天工具基于RAG检索增强生成技术能够根据知识库提供准确的答案。支持上下文保持最多保留3条对话记录实时显示响应时间和参考来源。',
icon: MessageCircle,
route: '/tools/ai-chat',
category: ToolCategory.AI_TOOLS,
status: ToolStatus.STABLE,
tags: ['AI聊天', 'RAG', '知识问答', '智能助手', '上下文保持'],
isNew: true,
isPopular: true,
version: '1.0.0',
lastUpdated: '2024-01-21'
},
{
id: 'watermark-tool',
name: '水印处理工具',
description: '专业的视频水印检测、移除和添加工具,支持批量处理和多种水印类型',
longDescription: '强大的水印处理工具集提供智能水印检测、精确移除和自定义添加功能。支持视频和图片格式提供多种移除算法AI修复、模糊处理、裁剪等和丰富的水印样式选择。',
icon: Droplets,
route: '/tools/watermark',
category: ToolCategory.FILE_PROCESSING,
status: ToolStatus.STABLE,
tags: ['水印检测', '水印移除', '水印添加', '批量处理', '视频处理'],
isNew: true,
isPopular: true,
version: '1.0.0',
lastUpdated: '2024-01-23'
},
{
id: 'batch-thumbnail-generator',
name: '批量缩略图生成器',
description: '为视频文件批量生成预览缩略图和时间轴,支持自定义时间戳、尺寸和格式',
longDescription: '专业的批量缩略图生成工具,支持多种视频格式的批量处理。提供灵活的时间戳配置、多种尺寸预设、智能场景检测和时间轴缩略图生成功能。支持并发处理、进度监控和错误恢复机制。',
icon: ImageIcon,
route: '/tools/batch-thumbnail-generator',
category: ToolCategory.FILE_PROCESSING,
status: ToolStatus.STABLE,
tags: ['缩略图生成', '批量处理', '视频处理', '时间轴', '场景检测'],
isNew: true,
isPopular: true,
version: '1.0.0',
lastUpdated: '2024-01-24'
},
{
id: 'similarity-search',
name: '相似度检索工具',
@@ -136,20 +45,6 @@ export const TOOLS_DATA: Tool[] = [
version: '1.0.0',
lastUpdated: '2024-01-25'
},
{
id: 'advanced-filter-demo',
name: '高级过滤器演示',
description: '展示和测试高级过滤器组件功能,包括类别、环境、设计风格和颜色检测',
longDescription: '专业的高级过滤器演示工具展示完整的过滤器组件功能。包括类别过滤器、环境标签选择器、设计风格选择器和颜色检测过滤器。支持实时配置预览、JSON导出和过滤器摘要显示是开发和测试过滤器功能的理想工具。',
icon: Filter,
route: '/tools/advanced-filter-demo',
category: ToolCategory.DEVELOPMENT,
status: ToolStatus.BETA,
tags: ['过滤器', '组件演示', '开发工具', 'UI组件', '配置管理'],
isNew: true,
version: '1.0.0',
lastUpdated: '2024-01-25'
},
{
id: 'outfit-search',
name: '智能服装搜索',
@@ -166,19 +61,49 @@ export const TOOLS_DATA: Tool[] = [
lastUpdated: '2024-01-26'
},
{
id: 'enriched-analysis-demo',
name: '🎨 丰富图像分析演示',
description: '展示智能服装分析的详细结果,包含颜色分析、风格分析、产品分析等丰富信息',
longDescription: '专业的图像分析结果展示工具,提供极其详细和丰富的分析信息。包含颜色和谐度分析、色彩温度匹配、风格一致性评估、产品匹配度统计、环境适配性分析、拍摄质量评估、个性化搭配建议等功能。支持可展开的分析报告、统计图表、建议卡片和完整的分析摘要。',
icon: BarChart3,
route: '/tools/enriched-analysis-demo',
id: 'outfit-favorites',
name: '穿搭方案收藏管理',
description: '管理收藏的穿搭方案,支持基于收藏方案的智能素材检索',
longDescription: '专业的穿搭方案收藏管理工具,提供完整的收藏管理功能。支持收藏方案的添加、删除、搜索和筛选,以及基于收藏方案的智能素材检索功能。帮助用户建立个人穿搭方案库,快速找到适合的素材。',
icon: Heart,
route: '/tools/outfit-favorites',
category: ToolCategory.AI_TOOLS,
status: ToolStatus.BETA,
tags: ['图像分析', '详细报告', '颜色分析', '风格分析', '搭配建议', '统计图表'],
status: ToolStatus.STABLE,
tags: ['收藏管理', '穿搭方案', '素材检索', '个人库', '智能搜索'],
isNew: true,
isPopular: true,
version: '1.0.0',
lastUpdated: '2024-01-26'
lastUpdated: '2024-01-27'
},
{
id: 'outfit-comparison',
name: '穿搭方案对比分析',
description: '分屏对比两个收藏方案的素材检索结果,分析方案差异',
longDescription: '强大的穿搭方案对比分析工具,支持同时选择两个收藏方案进行分屏对比。并行执行素材检索,直观展示两个方案的检索结果差异,帮助用户更好地理解不同方案的特点和适用场景。',
icon: ArrowLeftRight,
route: '/tools/outfit-comparison',
category: ToolCategory.AI_TOOLS,
status: ToolStatus.STABLE,
tags: ['方案对比', '分屏展示', '差异分析', '素材检索', '对比分析'],
isNew: true,
isPopular: true,
version: '1.0.0',
lastUpdated: '2024-01-27'
},
{
id: 'material-search',
name: '智能素材检索',
description: '基于收藏穿搭方案的智能素材检索工具,快速找到匹配的素材',
longDescription: '专业的智能素材检索工具,基于收藏的穿搭方案进行精准的素材匹配。支持多维度检索条件生成、相关度排序、分页浏览等功能。提供直观的检索界面和丰富的筛选选项,帮助用户快速找到最适合的素材。',
icon: Search,
route: '/tools/material-search',
category: ToolCategory.AI_TOOLS,
status: ToolStatus.STABLE,
tags: ['素材检索', '智能匹配', '方案关联', '相关度排序', '精准搜索'],
isNew: true,
isPopular: true,
version: '1.0.0',
lastUpdated: '2024-01-27'
}
];

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import {
Sparkles,
Wand2,
@@ -7,7 +7,9 @@ import {
Copy,
RefreshCw,
Settings,
Info
Info,
Heart,
ArrowLeftRight
} from 'lucide-react';
import OutfitRecommendationService from '../../services/outfitRecommendationService';
import {
@@ -22,6 +24,23 @@ import OutfitRecommendationList from '../../components/outfit/OutfitRecommendati
import { CustomSelect } from '../../components/CustomSelect';
import { MaterialSearchPanel, MaterialDetailModal } from '../../components/material';
// localStorage 键名
const STORAGE_KEY = 'outfit-recommendation-state';
// 状态接口
interface SavedState {
query: string;
targetStyle: string;
occasions: string[];
season: string;
colorPreferences: string[];
count: number;
groups: OutfitRecommendationGroup[];
groupingStrategy: GroupingStrategy | null;
recommendations: OutfitRecommendation[];
showAdvanced: boolean;
}
/**
* AI穿搭方案推荐小工具
* 遵循 Tauri 开发规范和 UI/UX 设计标准
@@ -51,6 +70,110 @@ const OutfitRecommendationTool: React.FC = () => {
const [showMaterialDetail, setShowMaterialDetail] = useState(false);
const [selectedMaterial, setSelectedMaterial] = useState<any>(null);
// 方案对比状态
const [selectedForComparison, setSelectedForComparison] = useState<OutfitRecommendation[]>([]);
// 状态恢复提示
const [showRestoredTip, setShowRestoredTip] = useState(false);
// 保存状态到localStorage
const saveState = useCallback(() => {
try {
const state: SavedState = {
query,
targetStyle,
occasions,
season,
colorPreferences,
count,
groups,
groupingStrategy,
recommendations,
showAdvanced,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('保存状态失败:', error);
}
}, [query, targetStyle, occasions, season, colorPreferences, count, groups, groupingStrategy, recommendations, showAdvanced]);
// 从localStorage恢复状态
const restoreState = useCallback(() => {
try {
const savedState = localStorage.getItem(STORAGE_KEY);
if (savedState) {
const state: SavedState = JSON.parse(savedState);
setQuery(state.query || '');
setTargetStyle(state.targetStyle || '');
setOccasions(state.occasions || []);
setSeason(state.season || '');
setColorPreferences(state.colorPreferences || []);
setCount(state.count || 3);
setGroups(state.groups || []);
setGroupingStrategy(state.groupingStrategy || null);
setRecommendations(state.recommendations || []);
setShowAdvanced(state.showAdvanced || false);
return true; // 表示成功恢复了状态
}
} catch (error) {
console.warn('恢复状态失败:', error);
}
return false; // 表示没有恢复状态
}, []);
// 组件加载时恢复状态
useEffect(() => {
const restored = restoreState();
if (restored) {
setShowRestoredTip(true);
// 3秒后自动隐藏提示
setTimeout(() => {
setShowRestoredTip(false);
}, 3000);
}
}, [restoreState]);
// 状态变化时自动保存(防抖)
useEffect(() => {
const timeoutId = setTimeout(() => {
if (recommendations.length > 0) {
saveState();
}
}, 1000); // 1秒防抖
return () => clearTimeout(timeoutId);
}, [recommendations, saveState]);
// 监听对比选择当选择了2个方案时自动跳转到对比页面
useEffect(() => {
if (selectedForComparison.length === 2) {
// 将选中的方案保存到localStorage供对比页面使用
try {
localStorage.setItem('outfit-comparison-data', JSON.stringify({
leftFavorite: {
id: selectedForComparison[0].id,
recommendation_data: selectedForComparison[0],
custom_name: selectedForComparison[0].title,
created_at: new Date().toISOString()
},
rightFavorite: {
id: selectedForComparison[1].id,
recommendation_data: selectedForComparison[1],
custom_name: selectedForComparison[1].title,
created_at: new Date().toISOString()
}
}));
// 短暂延迟后跳转,让用户看到选择反馈
setTimeout(() => {
window.location.href = '/tools/outfit-comparison';
}, 500);
} catch (error) {
console.error('保存对比数据失败:', error);
}
}
}, [selectedForComparison]);
// 生成穿搭方案
const handleGenerate = useCallback(async () => {
if (!query.trim()) {
@@ -83,6 +206,11 @@ const OutfitRecommendationTool: React.FC = () => {
setGroupingStrategy(null);
setRecommendations(response.recommendations || []);
}
// 生成成功后立即保存状态
setTimeout(() => {
saveState();
}, 100);
} catch (err) {
console.error('穿搭方案生成失败:', err);
setError(err instanceof Error ? err.message : '穿搭方案生成失败');
@@ -167,6 +295,14 @@ const OutfitRecommendationTool: React.FC = () => {
setGroupingStrategy(null);
setRecommendations([]);
setError(null);
setShowRestoredTip(false);
// 清除保存的状态
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.warn('清除保存状态失败:', error);
}
}, []);
// 导出结果
@@ -203,6 +339,34 @@ const OutfitRecommendationTool: React.FC = () => {
URL.revokeObjectURL(url);
}, [recommendations, query]);
// 打开收藏管理
const handleOpenFavoriteManagement = useCallback(() => {
// 使用路由跳转到收藏管理页面
window.location.href = '/tools/outfit-favorites';
}, []);
// 打开方案对比
const handleOpenComparison = useCallback(() => {
// 使用路由跳转到方案对比页面
window.location.href = '/tools/outfit-comparison';
}, []);
// 添加到对比
const handleAddToComparison = useCallback((recommendation: OutfitRecommendation) => {
setSelectedForComparison(prev => {
if (prev.find(r => r.id === recommendation.id)) {
// 如果已存在,则移除
return prev.filter(r => r.id !== recommendation.id);
} else if (prev.length < 2) {
// 如果少于2个则添加
return [...prev, recommendation];
} else {
// 如果已有2个替换第一个
return [prev[1], recommendation];
}
});
}, []);
// 复制结果
const handleCopy = useCallback(() => {
if (recommendations.length === 0) {
@@ -255,13 +419,54 @@ const OutfitRecommendationTool: React.FC = () => {
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">AI穿搭方案推荐</h1>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-gray-900">AI穿搭方案推荐</h1>
{showRestoredTip && (
<div className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full animate-fade-in">
</div>
)}
</div>
<p className="text-sm text-gray-600">TikTok视觉趋势的智能穿搭建议</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* 收藏管理入口 */}
<button
onClick={handleOpenFavoriteManagement}
className="flex items-center gap-2 px-3 py-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors duration-200"
title="收藏管理"
>
<Heart className="w-4 h-4" />
<span className="text-sm font-medium"></span>
</button>
{/* 方案对比入口 */}
<button
onClick={handleOpenComparison}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors duration-200 relative ${
selectedForComparison.length > 0
? 'text-blue-600 bg-blue-50 hover:bg-blue-100'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
}`}
title={`方案对比 ${selectedForComparison.length > 0 ? `(已选择${selectedForComparison.length}个)` : ''}`}
>
<ArrowLeftRight className="w-4 h-4" />
<span className="text-sm font-medium"></span>
{selectedForComparison.length > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-blue-500 text-white text-xs rounded-full flex items-center justify-center">
{selectedForComparison.length}
</span>
)}
</button>
{/* 分隔线 */}
{recommendations.length > 0 && (
<div className="h-6 w-px bg-gray-300 mx-1"></div>
)}
{recommendations.length > 0 && (
<>
<button
@@ -433,10 +638,39 @@ const OutfitRecommendationTool: React.FC = () => {
<li> </li>
<li> TikTok优化建议</li>
<li> </li>
<li> "对比"</li>
</ul>
</div>
</div>
</div>
{/* 对比状态面板 */}
{selectedForComparison.length > 0 && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<ArrowLeftRight className="w-5 h-5 text-green-600 mt-0.5 flex-shrink-0" />
<div className="text-sm text-green-800 flex-1">
<p className="font-medium mb-1"></p>
<p className="text-xs mb-2">
{selectedForComparison.length}/2
{selectedForComparison.length === 2 && ',即将自动跳转到对比页面...'}
</p>
<div className="space-y-1">
{selectedForComparison.map((rec, index) => (
<div key={rec.id} className="text-xs bg-white bg-opacity-50 rounded px-2 py-1">
{index + 1}. {rec.title}
</div>
))}
</div>
{selectedForComparison.length === 1 && (
<p className="text-xs mt-2 opacity-75">
</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
@@ -452,6 +686,8 @@ const OutfitRecommendationTool: React.FC = () => {
onRegenerate={handleRegenerate}
onMaterialSearch={handleMaterialSearch}
onLoadMoreForGroup={handleLoadMoreForGroup}
onAddToComparison={handleAddToComparison}
selectedForComparison={selectedForComparison}
className="min-h-[600px]"
/>
</div>

View File

@@ -191,7 +191,7 @@ const SimilaritySearchTool: React.FC = () => {
try {
const response = await OutfitRecommendationService.quickGenerate(query.trim(), 3);
setOutfitRecommendations(response.recommendations);
setOutfitRecommendations(response.recommendations || []);
} catch (error) {
console.error('穿搭方案生成失败:', error);
setOutfitError(error instanceof Error ? error.message : '穿搭方案生成失败');
@@ -211,7 +211,7 @@ const SimilaritySearchTool: React.FC = () => {
try {
const response = await OutfitRecommendationService.quickGenerate(query.trim(), 3);
setOutfitRecommendations(response.recommendations);
setOutfitRecommendations(response.recommendations || []);
} catch (error) {
console.error('穿搭方案重新生成失败:', error);
setOutfitError(error instanceof Error ? error.message : '穿搭方案生成失败');

View 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;
}
}
}

View 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,
};

View 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;
}

View File

@@ -174,6 +174,10 @@ export interface OutfitRecommendationCardProps {
onSceneSearch?: (recommendation: OutfitRecommendation) => void;
/** 素材检索事件处理 */
onMaterialSearch?: (recommendation: OutfitRecommendation) => void;
/** 添加到对比事件处理 */
onAddToComparison?: (recommendation: OutfitRecommendation) => void;
/** 是否已选择用于对比 */
isSelectedForComparison?: boolean;
/** 是否显示详细信息 */
showDetails?: boolean;
/** 是否紧凑模式 */
@@ -204,6 +208,10 @@ export interface OutfitRecommendationListProps {
onRegenerate?: () => void;
/** 获取更多同类方案事件 */
onLoadMoreForGroup?: (groupId: string, styleKeywords: string[]) => void;
/** 添加到对比事件 */
onAddToComparison?: (recommendation: OutfitRecommendation) => void;
/** 已选择用于对比的方案 */
selectedForComparison?: OutfitRecommendation[];
/** 自定义样式类名 */
className?: string;
}

View File

@@ -213,21 +213,3 @@ export async function runAllTests(): Promise<void> {
console.log('⚠️ 部分测试失败,请检查相关配置和服务状态。');
}
}
// 在浏览器控制台中可以直接调用的测试函数
if (typeof window !== 'undefined') {
(window as any).testChatFunction = {
testConnection,
testConfig,
testSimpleQuery,
testContextRetention,
runAllTests
};
console.log('🔧 聊天功能测试工具已加载,可在控制台中使用:');
console.log('- window.testChatFunction.testConnection()');
console.log('- window.testChatFunction.testConfig()');
console.log('- window.testChatFunction.testSimpleQuery()');
console.log('- window.testChatFunction.testContextRetention()');
console.log('- window.testChatFunction.runAllTests()');
}

View File

@@ -17,6 +17,7 @@
"docker:down": "docker-compose down",
"tauri:dev": "pnpm --filter=@mixvideo/desktop tauri:dev",
"tauri:build": "pnpm --filter=@mixvideo/desktop tauri:build",
"tauri:web:build": "pnpm --filter=@mixvideo/desktop build",
"python:dev": "pnpm --filter python-service dev",
"services:start": "docker-compose up python-service -d"
},