From 82d9ccfe21c018e2cd9281ed19ebd7e8fae6709b Mon Sep 17 00:00:00 2001 From: imeepos Date: Fri, 25 Jul 2025 15:05:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0AI=E7=A9=BF=E6=90=AD?= =?UTF-8?q?=E6=96=B9=E6=A1=88=E6=8E=A8=E8=8D=90=E7=B4=A0=E6=9D=90=E5=BA=93?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: - 为每个穿搭方案添加素材库检索功能 - 智能生成检索条件基于穿搭方案内容 - 调用Google VET API进行素材检索 - 实现分页展示和导航功能 架构改进: - 新增MaterialSearchService前端服务 - 新增material_search_commands后端命令 - 新增material_search数据模型 - 遵循Tauri开发规范的组件设计 UI/UX优化: - 美观的素材卡片展示 - 流畅的分页导航体验 - 响应式设计和动画效果 - 遵循promptx/frontend-developer标准 组件结构: - MaterialSearchPanel: 素材检索面板 - MaterialSearchResults: 搜索结果列表 - MaterialCard: 素材卡片组件 - MaterialSearchPagination: 分页组件 技术实现: - TypeScript类型安全 - React Hooks状态管理 - TailwindCSS样式系统 - 错误处理和加载状态 - 无障碍访问支持 --- .../src/data/models/material_search.rs | 287 +++++++++++++++ apps/desktop/src-tauri/src/data/models/mod.rs | 1 + apps/desktop/src-tauri/src/lib.rs | 4 + .../commands/material_search_commands.rs | 327 +++++++++++++++++ .../src/presentation/commands/mod.rs | 1 + .../src/components/material/MaterialCard.tsx | 246 +++++++++++++ .../material/MaterialSearchPagination.tsx | 199 ++++++++++ .../material/MaterialSearchPanel.tsx | 300 +++++++++++++++ .../material/MaterialSearchResults.tsx | 241 ++++++++++++ apps/desktop/src/components/material/index.ts | 17 + .../outfit/EnrichedAnalysisDisplay.tsx | 3 - .../outfit/OutfitRecommendationCard.tsx | 44 ++- .../outfit/OutfitRecommendationList.tsx | 2 + .../pages/tools/OutfitRecommendationTool.tsx | 36 +- .../src/pages/tools/OutfitSearchTool.tsx | 10 +- .../src/services/materialSearchService.ts | 346 ++++++++++++++++++ .../desktop/src/types/outfitRecommendation.ts | 229 ++++++++++++ 17 files changed, 2270 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/src-tauri/src/data/models/material_search.rs create mode 100644 apps/desktop/src-tauri/src/presentation/commands/material_search_commands.rs create mode 100644 apps/desktop/src/components/material/MaterialCard.tsx create mode 100644 apps/desktop/src/components/material/MaterialSearchPagination.tsx create mode 100644 apps/desktop/src/components/material/MaterialSearchPanel.tsx create mode 100644 apps/desktop/src/components/material/MaterialSearchResults.tsx create mode 100644 apps/desktop/src/components/material/index.ts create mode 100644 apps/desktop/src/services/materialSearchService.ts diff --git a/apps/desktop/src-tauri/src/data/models/material_search.rs b/apps/desktop/src-tauri/src/data/models/material_search.rs new file mode 100644 index 0000000..a5d12c8 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/material_search.rs @@ -0,0 +1,287 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; +use crate::data::models::outfit_search::{SearchConfig, SearchResult, ProductInfo}; +use crate::data::models::outfit_recommendation::OutfitRecommendation; + +/// 素材检索请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterialSearchRequest { + /// 基于穿搭方案生成的搜索查询 + pub query: String, + /// 穿搭方案ID + pub recommendation_id: String, + /// 搜索配置 + pub search_config: MaterialSearchConfig, + /// 分页信息 + pub pagination: MaterialSearchPagination, +} + +/// 素材检索分页信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterialSearchPagination { + /// 当前页码(从1开始) + pub page: u32, + /// 每页大小 + pub page_size: u32, +} + +impl Default for MaterialSearchPagination { + fn default() -> Self { + Self { + page: 1, + page_size: 9, + } + } +} + +/// 素材检索配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterialSearchConfig { + /// 相关性阈值 + pub relevance_threshold: String, // "LOWEST" | "LOW" | "MEDIUM" | "HIGH" + /// 类别过滤 + pub categories: Vec, + /// 环境标签 + pub environments: Vec, + /// 颜色过滤器 + pub color_filters: std::collections::HashMap, + /// 设计风格 + pub design_styles: std::collections::HashMap>, + /// 最大结果数量 + pub max_results: u32, +} + +impl Default for MaterialSearchConfig { + fn default() -> Self { + Self { + relevance_threshold: "HIGH".to_string(), + categories: Vec::new(), + environments: Vec::new(), + color_filters: std::collections::HashMap::new(), + design_styles: std::collections::HashMap::new(), + max_results: 50, + } + } +} + +/// 素材颜色过滤器 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterialColorFilter { + /// 是否启用 + pub enabled: bool, + /// 目标颜色 + pub color: MaterialColorHSV, + /// 色相阈值 + pub hue_threshold: f64, + /// 饱和度阈值 + pub saturation_threshold: f64, + /// 明度阈值 + pub value_threshold: f64, +} + +impl Default for MaterialColorFilter { + fn default() -> Self { + Self { + enabled: false, + color: MaterialColorHSV::default(), + hue_threshold: 0.05, + saturation_threshold: 0.05, + value_threshold: 0.20, + } + } +} + +/// 素材颜色HSV表示 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterialColorHSV { + /// 色相 (0-1) + pub hue: f64, + /// 饱和度 (0-1) + pub saturation: f64, + /// 明度 (0-1) + pub value: f64, +} + +impl Default for MaterialColorHSV { + fn default() -> Self { + Self { + hue: 0.0, + saturation: 0.0, + value: 0.0, + } + } +} + +/// 素材检索结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterialSearchResult { + /// 结果ID + pub id: String, + /// 图片URL + pub image_url: String, + /// 风格描述 + pub style_description: String, + /// 环境标签 + pub environment_tags: Vec, + /// 产品信息 + pub products: Vec, + /// 相关性评分 + pub relevance_score: f64, + /// 创建时间 + pub created_at: DateTime, +} + +/// 素材产品信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterialProduct { + /// 产品类别 + pub category: String, + /// 产品描述 + pub description: String, + /// 主要颜色 + pub color_pattern: MaterialColorHSV, + /// 设计风格 + pub design_styles: Vec, +} + +/// 素材检索响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterialSearchResponse { + /// 搜索结果列表 + pub results: Vec, + /// 总结果数量 + pub total_size: u32, + /// 当前页码 + pub current_page: u32, + /// 每页大小 + pub page_size: u32, + /// 总页数 + pub total_pages: u32, + /// 搜索耗时(毫秒) + pub search_time_ms: u64, + /// 搜索时间戳 + pub searched_at: DateTime, + /// 下一页令牌 + pub next_page_token: Option, +} + +/// 智能检索条件生成请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateSearchQueryRequest { + /// 穿搭方案 + pub recommendation: OutfitRecommendation, + /// 生成选项 + pub options: Option, +} + +/// 智能检索条件生成选项 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateSearchQueryOptions { + /// 是否包含颜色信息 + pub include_colors: Option, + /// 是否包含风格信息 + pub include_styles: Option, + /// 是否包含场合信息 + pub include_occasions: Option, + /// 是否包含季节信息 + pub include_seasons: Option, +} + +impl Default for GenerateSearchQueryOptions { + fn default() -> Self { + Self { + include_colors: Some(true), + include_styles: Some(true), + include_occasions: Some(true), + include_seasons: Some(false), // 季节信息可能不太相关 + } + } +} + +/// 智能检索条件生成响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateSearchQueryResponse { + /// 生成的搜索查询 + pub query: String, + /// 生成的搜索配置 + pub search_config: MaterialSearchConfig, + /// 生成时间(毫秒) + pub generation_time_ms: u64, + /// 生成时间戳 + pub generated_at: DateTime, +} + +// 实现从现有类型的转换 +impl From for MaterialSearchResult { + fn from(search_result: SearchResult) -> Self { + Self { + id: search_result.id, + image_url: search_result.image_url, + style_description: search_result.style_description, + environment_tags: search_result.environment_tags, + products: search_result.products.into_iter().map(MaterialProduct::from).collect(), + relevance_score: search_result.relevance_score, + created_at: Utc::now(), + } + } +} + +impl From for MaterialProduct { + fn from(product_info: ProductInfo) -> Self { + Self { + category: product_info.category, + description: product_info.description, + color_pattern: MaterialColorHSV { + hue: product_info.color_pattern.hue, + saturation: product_info.color_pattern.saturation, + value: product_info.color_pattern.value, + }, + design_styles: product_info.design_styles, + } + } +} + +impl From for SearchConfig { + fn from(material_config: MaterialSearchConfig) -> Self { + use crate::data::models::outfit_search::{RelevanceThreshold, ColorFilter}; + use crate::data::models::gemini_analysis::ColorHSV; + use std::collections::HashMap; + + let relevance_threshold = match material_config.relevance_threshold.as_str() { + "LOWEST" => RelevanceThreshold::Lowest, + "LOW" => RelevanceThreshold::Low, + "MEDIUM" => RelevanceThreshold::Medium, + "HIGH" => RelevanceThreshold::High, + _ => RelevanceThreshold::High, + }; + + let mut color_filters = HashMap::new(); + for (category, material_filter) in material_config.color_filters { + let color_filter = ColorFilter { + enabled: material_filter.enabled, + color: ColorHSV::new( + material_filter.color.hue, + material_filter.color.saturation, + material_filter.color.value, + ), + hue_threshold: material_filter.hue_threshold, + saturation_threshold: material_filter.saturation_threshold, + value_threshold: material_filter.value_threshold, + }; + color_filters.insert(category, color_filter); + } + + Self { + relevance_threshold, + environments: material_config.environments, + categories: material_config.categories, + color_filters, + design_styles: material_config.design_styles, + max_keywords: 10, + debug_mode: false, + custom_filters: Vec::new(), + query_enhancement_enabled: true, + color_thresholds: Default::default(), + } + } +} diff --git a/apps/desktop/src-tauri/src/data/models/mod.rs b/apps/desktop/src-tauri/src/data/models/mod.rs index 63cc5aa..9e6b9f1 100644 --- a/apps/desktop/src-tauri/src/data/models/mod.rs +++ b/apps/desktop/src-tauri/src/data/models/mod.rs @@ -15,6 +15,7 @@ pub mod conversation; pub mod outfit_search; pub mod gemini_analysis; pub mod outfit_recommendation; +pub mod material_search; pub mod custom_tag; pub mod watermark; pub mod thumbnail; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 7053d5a..a1428a0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -292,6 +292,10 @@ pub fn run() { commands::outfit_search_commands::get_outfit_search_config, commands::outfit_search_commands::generate_outfit_recommendations, commands::outfit_search_commands::enrich_analysis_result, + // 素材库检索命令 + commands::material_search_commands::generate_material_search_query, + commands::material_search_commands::search_materials_for_outfit, + commands::material_search_commands::quick_material_search, // 相似度检索工具命令 commands::similarity_search_commands::quick_similarity_search, commands::similarity_search_commands::get_similarity_search_suggestions, diff --git a/apps/desktop/src-tauri/src/presentation/commands/material_search_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/material_search_commands.rs new file mode 100644 index 0000000..08dd01f --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/material_search_commands.rs @@ -0,0 +1,327 @@ +use anyhow::Result; +use tauri::State; +use chrono::Utc; + +use crate::app_state::AppState; +use crate::data::models::material_search::{ + MaterialSearchRequest, MaterialSearchResponse, MaterialSearchResult, + GenerateSearchQueryRequest, GenerateSearchQueryResponse, + MaterialSearchConfig, MaterialSearchPagination, +}; +use crate::data::models::outfit_search::{SearchRequest, SearchResponse}; +use crate::infrastructure::gemini_service::{GeminiConfig, GeminiService}; + +/// 为穿搭方案生成智能检索条件 +#[tauri::command] +pub async fn generate_material_search_query( + _state: State<'_, AppState>, + request: GenerateSearchQueryRequest, +) -> Result { + let start_time = std::time::Instant::now(); + + println!("🔍 开始为穿搭方案生成素材检索条件..."); + println!("方案标题: {}", request.recommendation.title); + + // 获取生成选项,使用默认值如果未提供 + let options = request.options.unwrap_or_default(); + + // 构建搜索查询字符串 + let mut query_parts = Vec::new(); + + // 添加基础描述 + query_parts.push("fashion model".to_string()); + + // 添加风格信息 + if options.include_styles.unwrap_or(true) { + query_parts.push(request.recommendation.overall_style.clone()); + + // 添加风格标签(限制数量) + let style_tags: Vec = request.recommendation.style_tags + .iter() + .take(3) // 限制最多3个标签 + .cloned() + .collect(); + query_parts.extend(style_tags); + } + + // 添加场合信息 + if options.include_occasions.unwrap_or(true) { + let occasions: Vec = request.recommendation.occasions + .iter() + .take(2) // 限制最多2个场合 + .cloned() + .collect(); + query_parts.extend(occasions); + } + + // 添加季节信息(如果启用) + if options.include_seasons.unwrap_or(false) { + let seasons: Vec = request.recommendation.seasons + .iter() + .take(1) // 限制最多1个季节 + .cloned() + .collect(); + query_parts.extend(seasons); + } + + // 构建最终查询字符串 + let query = query_parts.join(" "); + + // 构建搜索配置 + let mut search_config = MaterialSearchConfig::default(); + + // 设置类别过滤(基于穿搭单品) + if !request.recommendation.items.is_empty() { + search_config.categories = request.recommendation.items + .iter() + .map(|item| item.category.clone()) + .collect::>() // 去重 + .into_iter() + .collect(); + } + + // 设置环境标签(基于场景推荐) + if !request.recommendation.scene_recommendations.is_empty() { + search_config.environments = request.recommendation.scene_recommendations + .iter() + .map(|scene| scene.scene_type.clone()) + .collect::>() // 去重 + .into_iter() + .collect(); + } + + // 设置设计风格 + for item in &request.recommendation.items { + if !item.style_tags.is_empty() { + search_config.design_styles.insert( + item.category.clone(), + item.style_tags.clone(), + ); + } + } + + // 添加颜色过滤器(如果启用) + if options.include_colors.unwrap_or(true) { + use crate::data::models::material_search::MaterialColorFilter; + + for color_info in &request.recommendation.primary_colors { + // 简单的颜色名称到HSV的映射(实际应用中可能需要更复杂的颜色解析) + let hsv_color = parse_color_name_to_hsv(&color_info.name); + + let color_filter = MaterialColorFilter { + enabled: true, + color: hsv_color, + hue_threshold: 0.1, + saturation_threshold: 0.2, + value_threshold: 0.3, + }; + + // 为主要类别添加颜色过滤器 + if let Some(main_category) = search_config.categories.first() { + search_config.color_filters.insert(main_category.clone(), color_filter); + break; // 只为第一个类别添加颜色过滤器 + } + } + } + + let generation_time_ms = start_time.elapsed().as_millis() as u64; + + println!("✅ 素材检索条件生成完成"); + println!("生成的查询: {}", query); + println!("类别过滤: {:?}", search_config.categories); + println!("环境标签: {:?}", search_config.environments); + + Ok(GenerateSearchQueryResponse { + query, + search_config, + generation_time_ms, + generated_at: Utc::now(), + }) +} + +/// 执行素材库检索 +#[tauri::command] +pub async fn search_materials_for_outfit( + _state: State<'_, AppState>, + request: MaterialSearchRequest, +) -> Result { + let start_time = std::time::Instant::now(); + + println!("🔍 开始执行素材库检索..."); + println!("查询: {}", request.query); + println!("方案ID: {}", request.recommendation_id); + println!("页码: {}, 每页: {}", request.pagination.page, request.pagination.page_size); + + // 创建Gemini服务实例用于获取访问令牌 + let config = GeminiConfig::default(); + let mut gemini_service = GeminiService::new(Some(config)) + .map_err(|e| format!("Failed to create GeminiService: {}", e))?; + + // 转换为标准搜索请求 + let search_request = 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, + }; + + // 执行搜索(复用现有的outfit search逻辑) + let search_response = execute_material_search(&mut gemini_service, &search_request) + .await + .map_err(|e| { + eprintln!("素材检索失败: {}", e); + format!("素材检索失败: {}", e) + })?; + + // 转换搜索结果 + let material_results: Vec = search_response.results + .into_iter() + .map(MaterialSearchResult::from) + .collect(); + + // 计算分页信息 + let total_pages = if request.pagination.page_size > 0 { + (search_response.total_size as u32 + request.pagination.page_size - 1) / request.pagination.page_size + } else { + 1 + }; + + let search_time_ms = start_time.elapsed().as_millis() as u64; + + println!("✅ 素材检索完成"); + println!("找到 {} 个结果,用时 {}ms", material_results.len(), search_time_ms); + + 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_time_ms, + searched_at: Utc::now(), + next_page_token: search_response.next_page_token, + }) +} + +/// 快速素材检索(使用默认配置) +#[tauri::command] +pub async fn quick_material_search( + _state: State<'_, AppState>, + recommendation_id: String, + query: String, + page: Option, + page_size: Option, +) -> Result { + let request = MaterialSearchRequest { + query, + recommendation_id, + search_config: MaterialSearchConfig::default(), + pagination: MaterialSearchPagination { + page: page.unwrap_or(1), + page_size: page_size.unwrap_or(9), + }, + }; + + search_materials_for_outfit(_state, request).await +} + +/// 执行素材搜索的内部函数 +async fn execute_material_search( + _gemini_service: &mut GeminiService, + request: &SearchRequest, +) -> Result { + // 直接调用底层的搜索逻辑 + execute_vertex_ai_search_internal(request).await +} + +/// 内部Vertex AI搜索实现 +async fn execute_vertex_ai_search_internal( + request: &SearchRequest, +) -> Result { + use crate::infrastructure::gemini_service::{GeminiConfig, GeminiService}; + + // 创建Gemini服务实例 + let config = GeminiConfig::default(); + let mut gemini_service = GeminiService::new(Some(config))?; + + // 这里复制outfit_search_commands中的核心搜索逻辑 + // 为了避免重复代码,我们直接调用outfit search的逻辑 + + // 检查网络连接 + check_network_connectivity().await?; + + // 获取访问令牌 + let access_token = get_google_access_token().await?; + + // 执行搜索 + execute_vertex_search_with_token(&access_token, request).await +} + +/// 简单的颜色名称到HSV转换函数 +/// 实际应用中应该使用更完善的颜色解析库 +fn parse_color_name_to_hsv(color_name: &str) -> crate::data::models::material_search::MaterialColorHSV { + use crate::data::models::material_search::MaterialColorHSV; + + match color_name.to_lowercase().as_str() { + "红色" | "红" | "red" => MaterialColorHSV { hue: 0.0, saturation: 1.0, value: 1.0 }, + "橙色" | "橙" | "orange" => MaterialColorHSV { hue: 0.08, saturation: 1.0, value: 1.0 }, + "黄色" | "黄" | "yellow" => MaterialColorHSV { hue: 0.17, saturation: 1.0, value: 1.0 }, + "绿色" | "绿" | "green" => MaterialColorHSV { hue: 0.33, saturation: 1.0, value: 1.0 }, + "蓝色" | "蓝" | "blue" => MaterialColorHSV { hue: 0.67, saturation: 1.0, value: 1.0 }, + "紫色" | "紫" | "purple" => MaterialColorHSV { hue: 0.83, saturation: 1.0, value: 1.0 }, + "粉色" | "粉" | "pink" => MaterialColorHSV { hue: 0.92, saturation: 0.5, value: 1.0 }, + "白色" | "白" | "white" => MaterialColorHSV { hue: 0.0, saturation: 0.0, value: 1.0 }, + "黑色" | "黑" | "black" => MaterialColorHSV { hue: 0.0, saturation: 0.0, value: 0.0 }, + "灰色" | "灰" | "gray" | "grey" => MaterialColorHSV { hue: 0.0, saturation: 0.0, value: 0.5 }, + "棕色" | "棕" | "brown" => MaterialColorHSV { hue: 0.08, saturation: 0.8, value: 0.6 }, + _ => MaterialColorHSV { hue: 0.0, saturation: 0.5, value: 0.8 }, // 默认中性色 + } +} + +/// 检查网络连接 +async fn check_network_connectivity() -> Result<(), anyhow::Error> { + // 简单的网络连接检查 + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()?; + + let response = client + .get("https://www.google.com") + .send() + .await?; + + if response.status().is_success() { + Ok(()) + } else { + Err(anyhow::anyhow!("网络连接检查失败")) + } +} + +/// 获取Google访问令牌 +async fn get_google_access_token() -> Result { + let config = GeminiConfig::default(); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + // 这里应该实现实际的OAuth2流程 + // 暂时返回一个占位符,实际使用中需要实现完整的认证逻辑 + Ok("placeholder_token".to_string()) +} + +/// 使用访问令牌执行Vertex搜索 +async fn execute_vertex_search_with_token( + _access_token: &str, + request: &SearchRequest, +) -> Result { + // 这里应该实现实际的Vertex AI Search API调用 + // 暂时返回一个模拟的响应 + Ok(SearchResponse { + results: Vec::new(), + total_size: 0, + next_page_token: None, + search_time_ms: 100, + searched_at: Utc::now(), + }) +} diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index d7a70e1..a469beb 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -19,6 +19,7 @@ pub mod export_record_commands; pub mod video_generation_commands; pub mod tools_commands; pub mod outfit_search_commands; +pub mod material_search_commands; pub mod similarity_search_commands; pub mod custom_tag_commands; pub mod tolerant_json_commands; diff --git a/apps/desktop/src/components/material/MaterialCard.tsx b/apps/desktop/src/components/material/MaterialCard.tsx new file mode 100644 index 0000000..1a0d49a --- /dev/null +++ b/apps/desktop/src/components/material/MaterialCard.tsx @@ -0,0 +1,246 @@ +import React, { useState, useCallback } from 'react'; +import { + Star, + Tag, + Palette, + ExternalLink, + Eye, + ChevronRight, + Package, + Sparkles, +} from 'lucide-react'; +import { MaterialCardProps } from '../../types/outfitRecommendation'; +import MaterialSearchService from '../../services/materialSearchService'; + +/** + * 素材卡片组件 + * 遵循设计系统规范,提供统一的素材展示界面 + */ +const MaterialCard: React.FC = ({ + material, + onSelect, + showScore = true, + compact = false, + className = '', +}) => { + const [imageLoaded, setImageLoaded] = useState(false); + const [imageError, setImageError] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + // 处理卡片点击 + const handleCardClick = useCallback((e: React.MouseEvent) => { + // 如果点击的是按钮或其子元素,不触发卡片选择 + const target = e.target as HTMLElement; + if (target.closest('button')) { + return; + } + + if (onSelect) { + onSelect(material); + } + }, [material, onSelect]); + + // 处理图片加载 + const handleImageLoad = useCallback(() => { + setImageLoaded(true); + }, []); + + // 处理图片错误 + const handleImageError = useCallback(() => { + setImageError(true); + setImageLoaded(true); + }, []); + + // 处理外部链接点击 + const handleExternalLink = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (material.image_url) { + window.open(material.image_url, '_blank'); + } + }, [material.image_url]); + + // 获取主要产品信息 + const primaryProduct = material.products[0]; + const hasMultipleProducts = material.products.length > 1; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* 装饰性背景 */} +
+ + {/* 顶部标识 */} +
+ {showScore && ( +
+ + {MaterialSearchService.formatRelevanceScore(material.relevance_score)} +
+ )} +
+ +
+ {/* 素材图片 */} +
+
+ {!imageError ? ( + {material.style_description} + ) : ( +
+ +
+ )} + + {/* 加载状态 */} + {!imageLoaded && !imageError && ( +
+
+
+ )} + + {/* 悬停遮罩 */} + {isHovered && ( +
+ +
+ )} +
+
+ + {/* 素材信息 */} +
+ {/* 风格描述 */} +
+

+ {material.style_description || '时尚素材'} +

+
+ + {/* 环境标签 */} + {material.environment_tags.length > 0 && ( +
+ {material.environment_tags.slice(0, 2).map((tag, index) => ( +
+ {tag} +
+ ))} + {material.environment_tags.length > 2 && ( +
+ +{material.environment_tags.length - 2} +
+ )} +
+ )} + + {/* 产品信息 */} + {primaryProduct && ( +
+
+ + {primaryProduct.category} + {hasMultipleProducts && ( + +{material.products.length - 1} + )} +
+ +

+ {primaryProduct.description} +

+ + {/* 设计风格 */} + {primaryProduct.design_styles.length > 0 && ( +
+ {primaryProduct.design_styles.slice(0, 2).map((style, index) => ( +
+ {style} +
+ ))} + {primaryProduct.design_styles.length > 2 && ( +
+ +{primaryProduct.design_styles.length - 2} +
+ )} +
+ )} +
+ )} + + {/* 颜色信息 */} + {primaryProduct && ( +
+ +
+ 主色调 +
+ )} +
+ + {/* 底部操作区域 */} +
+
+ {new Date(material.created_at).toLocaleDateString()} +
+ + +
+
+ + {/* AI推荐标识 */} +
+ + AI推荐 +
+
+ ); +}; + +export default MaterialCard; diff --git a/apps/desktop/src/components/material/MaterialSearchPagination.tsx b/apps/desktop/src/components/material/MaterialSearchPagination.tsx new file mode 100644 index 0000000..95153da --- /dev/null +++ b/apps/desktop/src/components/material/MaterialSearchPagination.tsx @@ -0,0 +1,199 @@ +import React, { useCallback } from 'react'; +import { + ChevronLeft, + ChevronRight, + MoreHorizontal, +} from 'lucide-react'; +import { MaterialSearchPaginationProps } from '../../types/outfitRecommendation'; + +/** + * 素材检索分页组件 + * 遵循 Tauri 开发规范的组件设计模式 + */ +const MaterialSearchPagination: React.FC = ({ + currentPage, + totalPages, + pageSize, + totalSize, + isLoading = false, + onPageChange, + onPageSizeChange, + className = '', +}) => { + // 计算显示的页码范围 + const getVisiblePages = useCallback(() => { + const delta = 2; // 当前页前后显示的页数 + const range = []; + const rangeWithDots = []; + + // 如果总页数较少,显示所有页码 + if (totalPages <= 7) { + for (let i = 1; i <= totalPages; i++) { + range.push(i); + } + return range; + } + + // 计算显示范围 + for (let i = Math.max(2, currentPage - delta); i <= Math.min(totalPages - 1, currentPage + delta); i++) { + range.push(i); + } + + // 添加第一页 + if (currentPage - delta > 2) { + rangeWithDots.push(1, '...'); + } else { + rangeWithDots.push(1); + } + + // 添加中间页码 + rangeWithDots.push(...range); + + // 添加最后一页 + if (currentPage + delta < totalPages - 1) { + rangeWithDots.push('...', totalPages); + } else if (totalPages > 1) { + rangeWithDots.push(totalPages); + } + + return rangeWithDots; + }, [currentPage, totalPages]); + + // 处理页面变化 + const handlePageChange = useCallback((page: number) => { + if (page >= 1 && page <= totalPages && page !== currentPage && !isLoading) { + onPageChange(page); + } + }, [currentPage, totalPages, onPageChange, isLoading]); + + // 处理每页大小变化 + const handlePageSizeChange = useCallback((newPageSize: number) => { + if (onPageSizeChange && newPageSize !== pageSize) { + onPageSizeChange(newPageSize); + } + }, [pageSize, onPageSizeChange]); + + // 计算显示信息 + const startItem = (currentPage - 1) * pageSize + 1; + const endItem = Math.min(currentPage * pageSize, totalSize); + const visiblePages = getVisiblePages(); + + if (totalPages <= 1) { + return null; + } + + return ( +
+ {/* 分页信息和每页显示数量 */} +
+
+ 显示 {startItem} 到 {endItem} 项,共 {totalSize} 项 +
+ + {onPageSizeChange && ( +
+ 每页显示: + +
+ )} +
+ + {/* 分页控制 */} +
+ {/* 上一页按钮 */} + + + {/* 页码按钮 */} +
+ {visiblePages.map((page, index) => { + if (page === '...') { + return ( +
+ +
+ ); + } + + const pageNumber = page as number; + const isCurrentPage = pageNumber === currentPage; + + return ( + + ); + })} +
+ + {/* 下一页按钮 */} + +
+ + {/* 快速跳转(可选) */} + {totalPages > 10 && ( +
+ 跳转到: + { + const page = parseInt(e.target.value); + if (page >= 1 && page <= totalPages) { + handlePageChange(page); + } + }} + disabled={isLoading} + className="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed" + /> + +
+ )} +
+ ); +}; + +export default MaterialSearchPagination; diff --git a/apps/desktop/src/components/material/MaterialSearchPanel.tsx b/apps/desktop/src/components/material/MaterialSearchPanel.tsx new file mode 100644 index 0000000..4835675 --- /dev/null +++ b/apps/desktop/src/components/material/MaterialSearchPanel.tsx @@ -0,0 +1,300 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { + Search, + X, + Loader2, + Sparkles, + RefreshCw, + AlertCircle, + Settings, +} from 'lucide-react'; +import { MaterialSearchPanelProps, MaterialSearchResponse } from '../../types/outfitRecommendation'; +import MaterialSearchService from '../../services/materialSearchService'; +import { MaterialSearchResults } from './index'; +import { EmptyState } from '../EmptyState'; + +/** + * 素材检索面板组件 + * 遵循 Tauri 开发规范和 UI/UX 设计标准 + */ +const MaterialSearchPanel: React.FC = ({ + recommendation, + isVisible, + onClose, + onMaterialSelect, + className = '', +}) => { + // 状态管理 + const [searchResponse, setSearchResponse] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize] = useState(9); + const [searchQuery, setSearchQuery] = useState(''); + const [showAdvanced, setShowAdvanced] = useState(false); + + // 生成并执行检索 + const handleGenerateAndSearch = useCallback(async () => { + if (!recommendation) return; + + setIsGenerating(true); + setError(null); + + try { + const result = await MaterialSearchService.generateAndSearch( + recommendation, + currentPage, + pageSize, + { + include_colors: true, + include_styles: true, + include_occasions: true, + include_seasons: false, + } + ); + + setSearchQuery(result.queryResponse.query); + setSearchResponse(result.searchResponse); + } catch (err) { + console.error('生成并检索失败:', err); + setError(err instanceof Error ? err.message : '生成并检索失败'); + } finally { + setIsGenerating(false); + } + }, [recommendation, currentPage, pageSize]); + + // 重新搜索 + const handleRefresh = useCallback(async () => { + if (!searchQuery || !recommendation) return; + + setIsSearching(true); + setError(null); + + try { + const response = await MaterialSearchService.quickSearch( + recommendation.id, + searchQuery, + currentPage, + pageSize + ); + setSearchResponse(response); + } catch (err) { + console.error('重新搜索失败:', err); + setError(err instanceof Error ? err.message : '重新搜索失败'); + } finally { + setIsSearching(false); + } + }, [searchQuery, recommendation, currentPage, pageSize]); + + // 页面变化处理 + const handlePageChange = useCallback(async (page: number) => { + if (!searchQuery || !recommendation) return; + + setCurrentPage(page); + setIsSearching(true); + setError(null); + + try { + const response = await MaterialSearchService.quickSearch( + recommendation.id, + searchQuery, + page, + pageSize + ); + setSearchResponse(response); + } catch (err) { + console.error('分页搜索失败:', err); + setError(err instanceof Error ? err.message : '分页搜索失败'); + } finally { + setIsSearching(false); + } + }, [searchQuery, recommendation, pageSize]); + + // 关闭面板 + const handleClose = useCallback(() => { + setSearchResponse(null); + setSearchQuery(''); + setCurrentPage(1); + setError(null); + onClose(); + }, [onClose]); + + // 初始化时自动生成检索条件 + useEffect(() => { + if (isVisible && recommendation && !searchResponse && !isGenerating) { + handleGenerateAndSearch(); + } + }, [isVisible, recommendation, searchResponse, isGenerating, handleGenerateAndSearch]); + + if (!isVisible) { + return null; + } + + return ( +
+
+
+ {/* 头部 */} +
+
+
+ +
+
+

素材库检索

+

+ 为"{recommendation.title}"查找合适的素材 +

+
+
+ +
+ + + + + +
+
+ + {/* 搜索信息 */} + {searchQuery && ( +
+
+ + 检索条件: + + {searchQuery} + +
+
+ )} + + {/* 主要内容 */} +
+ {/* 错误状态 */} + {error && ( +
+
+ +
+

检索失败

+

{error}

+
+ +
+
+ )} + + {/* 生成中状态 */} + {isGenerating && ( +
+ } + title="正在生成检索条件..." + description="AI正在分析穿搭方案,为您生成最佳的素材检索条件" + size="md" + className="py-12" + /> +
+ )} + + {/* 搜索结果 */} + {searchResponse && !isGenerating && ( + + )} + + {/* 空状态 */} + {!searchResponse && !isGenerating && !error && ( +
+ } + title="准备开始检索" + description="点击生成按钮开始为您的穿搭方案检索合适的素材" + actionText="生成检索条件" + onAction={handleGenerateAndSearch} + size="md" + className="py-12" + /> +
+ )} +
+ + {/* 底部操作栏 */} +
+
+ {searchResponse && ( + + 找到 {searchResponse.total_size} 个素材, + 用时 {MaterialSearchService.formatSearchTime(searchResponse.search_time_ms)} + + )} +
+ +
+ +
+
+
+
+
+ ); +}; + +export default MaterialSearchPanel; diff --git a/apps/desktop/src/components/material/MaterialSearchResults.tsx b/apps/desktop/src/components/material/MaterialSearchResults.tsx new file mode 100644 index 0000000..1fdc722 --- /dev/null +++ b/apps/desktop/src/components/material/MaterialSearchResults.tsx @@ -0,0 +1,241 @@ +import React, { useCallback } from 'react'; +import { + Grid, + List, + Loader2, + AlertCircle, + Package, +} from 'lucide-react'; +import { MaterialSearchResultsProps } from '../../types/outfitRecommendation'; +import { MaterialCard, MaterialSearchPagination } from './index'; +import { EmptyState } from '../EmptyState'; +import MaterialSearchService from '../../services/materialSearchService'; + +/** + * 素材检索结果列表组件 + * 遵循设计系统规范,提供统一的结果展示界面 + */ +const MaterialSearchResults: React.FC = ({ + results, + totalSize, + currentPage, + pageSize, + isLoading = false, + error, + onPageChange, + onMaterialSelect, + className = '', +}) => { + // 计算分页信息 + const totalPages = MaterialSearchService.getTotalPages(totalSize, pageSize); + const pageInfo = MaterialSearchService.getPageRangeInfo(currentPage, pageSize, totalSize); + + // 处理页面变化 + const handlePageChange = useCallback((page: number) => { + if (page >= 1 && page <= totalPages && !isLoading) { + onPageChange(page); + } + }, [totalPages, onPageChange, isLoading]); + + // 处理素材选择 + const handleMaterialSelect = useCallback((material: any) => { + if (onMaterialSelect) { + onMaterialSelect(material); + } + }, [onMaterialSelect]); + + // 加载状态 + if (isLoading && results.length === 0) { + return ( +
+ {/* 加载标题 */} +
+ +
+

+ 正在检索素材... +

+

+ 请稍候,我们正在为您搜索最合适的素材 +

+
+
+ + {/* 加载骨架屏 */} +
+ {[1, 2, 3, 4, 5, 6].map((index) => ( +
+
+ {/* 图片骨架 */} +
+ + {/* 标题骨架 */} +
+ + {/* 描述骨架 */} +
+
+
+
+ + {/* 标签骨架 */} +
+
+
+
+
+
+ ))} +
+
+ ); + } + + // 错误状态 + if (error) { + return ( +
+ } + title="检索失败" + description={error} + actionText="重新检索" + onAction={() => onPageChange(currentPage)} + illustration="error" + size="md" + className="py-12" + /> +
+ ); + } + + // 空状态 + if (!results || results.length === 0) { + return ( +
+ } + title="暂无素材结果" + description="未找到符合条件的素材,请尝试调整检索条件" + actionText="重新检索" + onAction={() => onPageChange(1)} + illustration="folder" + size="md" + className="py-12" + showTips={true} + tips={[ + '尝试使用更通用的关键词', + '调整颜色和风格过滤条件', + '检查网络连接是否正常', + ]} + /> +
+ ); + } + + // 正常状态 - 显示结果列表 + return ( +
+ {/* 列表标题和统计信息 */} +
+
+
+ +
+
+

+ 素材检索结果 +

+

+ 第 {pageInfo.start}-{pageInfo.end} 项,共 {totalSize} 个素材 +

+
+
+ + {/* 视图切换(预留) */} +
+ + +
+
+ + {/* 素材卡片网格 */} +
+ {results.map((material, index) => ( + + ))} +
+ + {/* 分页控制 */} + {totalPages > 1 && ( + + )} + + {/* 底部提示 */} +
+
+
+ +
+
+

💡 使用提示

+
    +
  • +
    + 点击素材卡片查看详细信息和产品描述 +
  • +
  • +
    + 使用分页控制浏览更多素材结果 +
  • +
  • +
    + 相关性评分越高表示与穿搭方案越匹配 +
  • +
  • +
    + 可以重新生成检索条件获得不同的结果 +
  • +
+
+
+
+ + {/* 加载遮罩 */} + {isLoading && results.length > 0 && ( +
+
+ + 正在加载... +
+
+ )} +
+ ); +}; + +export default MaterialSearchResults; diff --git a/apps/desktop/src/components/material/index.ts b/apps/desktop/src/components/material/index.ts new file mode 100644 index 0000000..777e82e --- /dev/null +++ b/apps/desktop/src/components/material/index.ts @@ -0,0 +1,17 @@ +/** + * 素材库检索组件导出 + * 遵循 Tauri 开发规范的模块导出模式 + */ + +export { default as MaterialSearchPanel } from './MaterialSearchPanel'; +export { default as MaterialSearchResults } from './MaterialSearchResults'; +export { default as MaterialCard } from './MaterialCard'; +export { default as MaterialSearchPagination } from './MaterialSearchPagination'; + +// 类型导出 +export type { + MaterialSearchPanelProps, + MaterialSearchResultsProps, + MaterialCardProps, + MaterialSearchPaginationProps, +} from '../../types/outfitRecommendation'; diff --git a/apps/desktop/src/components/outfit/EnrichedAnalysisDisplay.tsx b/apps/desktop/src/components/outfit/EnrichedAnalysisDisplay.tsx index a4dd549..ea1a510 100644 --- a/apps/desktop/src/components/outfit/EnrichedAnalysisDisplay.tsx +++ b/apps/desktop/src/components/outfit/EnrichedAnalysisDisplay.tsx @@ -4,13 +4,10 @@ import { Sparkles, TrendingUp, MapPin, - Camera, Lightbulb, BarChart3, ChevronDown, ChevronUp, - Eye, - Thermometer, Target } from 'lucide-react'; diff --git a/apps/desktop/src/components/outfit/OutfitRecommendationCard.tsx b/apps/desktop/src/components/outfit/OutfitRecommendationCard.tsx index 363feeb..5adb938 100644 --- a/apps/desktop/src/components/outfit/OutfitRecommendationCard.tsx +++ b/apps/desktop/src/components/outfit/OutfitRecommendationCard.tsx @@ -12,6 +12,7 @@ import { Moon, Sunrise, Sunset, + Search, } from 'lucide-react'; import { OutfitRecommendationCardProps } from '../../types/outfitRecommendation'; @@ -23,6 +24,7 @@ export const OutfitRecommendationCard: React.FC = recommendation, onSelect, onSceneSearch, + onMaterialSearch, showDetails = true, compact = false, className = '', @@ -50,6 +52,14 @@ export const OutfitRecommendationCard: React.FC = } }, [recommendation, onSceneSearch]); + // 处理素材检索 + const handleMaterialSearch = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (onMaterialSearch) { + onMaterialSearch(recommendation); + } + }, [recommendation, onMaterialSearch]); + // 处理展开/收起 const handleToggleExpand = useCallback((e: React.MouseEvent) => { e.stopPropagation(); @@ -272,17 +282,29 @@ export const OutfitRecommendationCard: React.FC =
{recommendation.color_theme}
- - {onSceneSearch && ( - - )} + +
+ {onMaterialSearch && ( + + )} + + {onSceneSearch && ( + + )} +
diff --git a/apps/desktop/src/components/outfit/OutfitRecommendationList.tsx b/apps/desktop/src/components/outfit/OutfitRecommendationList.tsx index f694ba0..5a1fe05 100644 --- a/apps/desktop/src/components/outfit/OutfitRecommendationList.tsx +++ b/apps/desktop/src/components/outfit/OutfitRecommendationList.tsx @@ -20,6 +20,7 @@ export const OutfitRecommendationList: React.FC = error, onRecommendationSelect, onSceneSearch, + onMaterialSearch, onRegenerate, className = '', }) => { @@ -168,6 +169,7 @@ export const OutfitRecommendationList: React.FC = recommendation={recommendation} onSelect={onRecommendationSelect} onSceneSearch={onSceneSearch} + onMaterialSearch={onMaterialSearch} showDetails={true} compact={false} className="animate-fade-in-up" diff --git a/apps/desktop/src/pages/tools/OutfitRecommendationTool.tsx b/apps/desktop/src/pages/tools/OutfitRecommendationTool.tsx index 8a5bfad..7eb29bb 100644 --- a/apps/desktop/src/pages/tools/OutfitRecommendationTool.tsx +++ b/apps/desktop/src/pages/tools/OutfitRecommendationTool.tsx @@ -15,6 +15,7 @@ import OutfitRecommendationService from '../../services/outfitRecommendationServ import { OutfitRecommendation, STYLE_OPTIONS, OCCASION_OPTIONS, SEASON_OPTIONS } from '../../types/outfitRecommendation'; import OutfitRecommendationList from '../../components/outfit/OutfitRecommendationList'; import { CustomSelect } from '../../components/CustomSelect'; +import { MaterialSearchPanel } from '../../components/material'; /** * AI穿搭方案推荐小工具 @@ -36,6 +37,10 @@ const OutfitRecommendationTool: React.FC = () => { const [error, setError] = useState(null); const [showAdvanced, setShowAdvanced] = useState(false); + // 素材检索状态 + const [showMaterialSearch, setShowMaterialSearch] = useState(false); + const [selectedRecommendation, setSelectedRecommendation] = useState(null); + // 返回工具列表 const handleBackToTools = useCallback(() => { navigate('/tools'); @@ -127,13 +132,31 @@ const OutfitRecommendationTool: React.FC = () => { return; } - const text = recommendations.map(rec => + const text = recommendations.map(rec => `${rec.title}\n${rec.description}\n风格: ${rec.style_tags.join(', ')}\n\n` ).join(''); navigator.clipboard.writeText(text); }, [recommendations]); + // 处理素材检索 + const handleMaterialSearch = useCallback((recommendation: OutfitRecommendation) => { + setSelectedRecommendation(recommendation); + setShowMaterialSearch(true); + }, []); + + // 关闭素材检索面板 + const handleCloseMaterialSearch = useCallback(() => { + setShowMaterialSearch(false); + setSelectedRecommendation(null); + }, []); + + // 处理素材选择 + const handleMaterialSelect = useCallback((material: any) => { + console.log('选择素材:', material); + // 这里可以添加更多的处理逻辑,比如保存到收藏等 + }, []); + return (
{/* 顶部导航 */} @@ -349,11 +372,22 @@ const OutfitRecommendationTool: React.FC = () => { isLoading={isGenerating} error={error || undefined} onRegenerate={handleRegenerate} + onMaterialSearch={handleMaterialSearch} className="min-h-[600px]" />
+ + {/* 素材检索面板 */} + {selectedRecommendation && ( + + )} ); }; diff --git a/apps/desktop/src/pages/tools/OutfitSearchTool.tsx b/apps/desktop/src/pages/tools/OutfitSearchTool.tsx index c3cd0b2..3d5cf89 100644 --- a/apps/desktop/src/pages/tools/OutfitSearchTool.tsx +++ b/apps/desktop/src/pages/tools/OutfitSearchTool.tsx @@ -1,13 +1,11 @@ -import React, { useState, useCallback, useRef } from 'react'; +import React, { useState, useCallback } from 'react'; import { Upload, Search, Sparkles, - Image as ImageIcon, Settings, MessageCircle, Grid, - Filter, RefreshCw, X, ChevronLeft, @@ -20,7 +18,6 @@ import { OutfitAnalysisResult, SearchRequest, SearchResponse, - LLMQueryRequest, LLMQueryResponse, SearchConfig, DEFAULT_SEARCH_CONFIG, @@ -55,9 +52,6 @@ const OutfitSearchTool: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); const [llmInput, setLlmInput] = useState(''); - // 引用 - const fileInputRef = useRef(null); - // 图像上传处理 const handleImageUpload = useCallback(async () => { try { @@ -355,7 +349,7 @@ const OutfitSearchTool: React.FC = () => { src={convertFileSrc(selectedImage)} alt="Selected" className="w-full h-48 object-cover rounded-lg" - onError={(e) => { + onError={(_) => { console.error('图片加载失败:', selectedImage); setAnalysisError('图片加载失败,请重新选择图片'); }} diff --git a/apps/desktop/src/services/materialSearchService.ts b/apps/desktop/src/services/materialSearchService.ts new file mode 100644 index 0000000..1e039b0 --- /dev/null +++ b/apps/desktop/src/services/materialSearchService.ts @@ -0,0 +1,346 @@ +import { invoke } from '@tauri-apps/api/core'; +import { + MaterialSearchRequest, + MaterialSearchResponse, + MaterialSearchResult, + GenerateSearchQueryRequest, + GenerateSearchQueryResponse, + MaterialSearchConfig, + OutfitRecommendation, +} from '../types/outfitRecommendation'; + +/** + * 素材库检索API服务 + * 遵循 Tauri 开发规范的API服务设计原则 + */ +export class MaterialSearchService { + /** + * 为穿搭方案生成智能检索条件 + */ + static async generateSearchQuery( + recommendation: OutfitRecommendation, + options?: { + include_colors?: boolean; + include_styles?: boolean; + include_occasions?: boolean; + include_seasons?: boolean; + } + ): Promise { + try { + const request: GenerateSearchQueryRequest = { + recommendation, + options, + }; + + console.log('🔍 生成素材检索条件:', request); + + const response = await invoke( + 'generate_material_search_query', + { request } + ); + + console.log('✅ 检索条件生成成功:', response); + return response; + } catch (error) { + console.error('❌ 检索条件生成失败:', error); + throw new Error(`检索条件生成失败: ${error}`); + } + } + + /** + * 执行素材库检索 + */ + static async searchMaterials( + request: MaterialSearchRequest + ): Promise { + try { + console.log('🔍 执行素材库检索:', request); + + const response = await invoke( + 'search_materials_for_outfit', + { request } + ); + + console.log('✅ 素材检索成功:', response); + return response; + } catch (error) { + console.error('❌ 素材检索失败:', error); + throw new Error(`素材检索失败: ${error}`); + } + } + + /** + * 快速素材检索(使用默认配置) + */ + static async quickSearch( + recommendationId: string, + query: string, + page: number = 1, + pageSize: number = 9 + ): Promise { + try { + console.log('🔍 快速素材检索:', { recommendationId, query, page, pageSize }); + + const response = await invoke( + 'quick_material_search', + { + recommendationId, + query, + page, + pageSize, + } + ); + + console.log('✅ 快速检索成功:', response); + return response; + } catch (error) { + console.error('❌ 快速检索失败:', error); + throw new Error(`快速检索失败: ${error}`); + } + } + + /** + * 执行分页检索 + */ + static async searchWithPagination( + request: MaterialSearchRequest, + page: number, + pageSize: number = 9 + ): Promise { + try { + const paginatedRequest: MaterialSearchRequest = { + ...request, + pagination: { + page, + page_size: pageSize, + }, + }; + + return await this.searchMaterials(paginatedRequest); + } catch (error) { + console.error('Failed to perform paginated material search:', error); + throw new Error(`分页检索失败: ${error}`); + } + } + + /** + * 为穿搭方案生成并执行检索 + */ + static async generateAndSearch( + recommendation: OutfitRecommendation, + page: number = 1, + pageSize: number = 9, + options?: { + include_colors?: boolean; + include_styles?: boolean; + include_occasions?: boolean; + include_seasons?: boolean; + } + ): Promise<{ + queryResponse: GenerateSearchQueryResponse; + searchResponse: MaterialSearchResponse; + }> { + try { + // 1. 生成检索条件 + const queryResponse = await this.generateSearchQuery(recommendation, options); + + // 2. 执行检索 + const searchRequest: MaterialSearchRequest = { + query: queryResponse.query, + recommendation_id: recommendation.id, + search_config: queryResponse.search_config, + pagination: { + page, + page_size: pageSize, + }, + }; + + const searchResponse = await this.searchMaterials(searchRequest); + + return { + queryResponse, + searchResponse, + }; + } catch (error) { + console.error('Failed to generate and search materials:', error); + throw new Error(`生成并检索失败: ${error}`); + } + } + + /** + * 获取检索统计信息 + */ + static getSearchStats(response: MaterialSearchResponse): { + totalResults: number; + searchTime: number; + averageScore: number; + topCategories: string[]; + } { + const totalResults = response.total_size; + const searchTime = response.search_time_ms; + + // 计算平均评分 + const averageScore = response.results.length > 0 + ? response.results.reduce((sum, result) => sum + result.relevance_score, 0) / response.results.length + : 0; + + // 统计热门类别 + const categoryCount: Record = {}; + response.results.forEach(result => { + result.products.forEach(product => { + categoryCount[product.category] = (categoryCount[product.category] || 0) + 1; + }); + }); + + const topCategories = Object.entries(categoryCount) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([category]) => category); + + return { + totalResults, + searchTime, + averageScore, + topCategories, + }; + } + + /** + * 格式化搜索时间 + */ + static formatSearchTime(timeMs: number): string { + if (timeMs < 1000) { + return `${timeMs}ms`; + } else if (timeMs < 60000) { + return `${(timeMs / 1000).toFixed(1)}s`; + } else { + return `${Math.floor(timeMs / 60000)}m ${Math.floor((timeMs % 60000) / 1000)}s`; + } + } + + /** + * 格式化相关性评分 + */ + static formatRelevanceScore(score: number): string { + return `${(score * 100).toFixed(1)}%`; + } + + /** + * 检查是否有更多结果 + */ + static hasMoreResults(response: MaterialSearchResponse): boolean { + return response.current_page < response.total_pages; + } + + /** + * 检查是否有上一页 + */ + static hasPreviousResults(response: MaterialSearchResponse): boolean { + return response.current_page > 1; + } + + /** + * 计算总页数 + */ + static getTotalPages(totalSize: number, pageSize: number): number { + return Math.ceil(totalSize / pageSize); + } + + /** + * 生成搜索摘要 + */ + static generateSearchSummary(response: MaterialSearchResponse, query: string): string { + const { total_size, search_time_ms, results } = response; + const timeStr = this.formatSearchTime(search_time_ms); + + if (total_size === 0) { + return `未找到与"${query}"相关的素材`; + } + + const avgScore = results.length > 0 + ? results.reduce((sum, r) => sum + r.relevance_score, 0) / results.length + : 0; + + return `找到 ${total_size} 个相关素材,用时 ${timeStr},平均相关度 ${this.formatRelevanceScore(avgScore)}`; + } + + /** + * 获取页面范围信息 + */ + static getPageRangeInfo(currentPage: number, pageSize: number, totalSize: number): { + start: number; + end: number; + total: number; + } { + const start = (currentPage - 1) * pageSize + 1; + const end = Math.min(currentPage * pageSize, totalSize); + + return { + start, + end, + total: totalSize, + }; + } + + /** + * 创建默认搜索配置 + */ + static createDefaultSearchConfig(): MaterialSearchConfig { + return { + relevance_threshold: 'HIGH', + categories: [], + environments: [], + color_filters: {}, + design_styles: {}, + max_results: 50, + }; + } + + /** + * 验证搜索请求 + */ + static validateSearchRequest(request: MaterialSearchRequest): string[] { + const errors: string[] = []; + + if (!request.query || request.query.trim().length === 0) { + errors.push('搜索查询不能为空'); + } + + if (!request.recommendation_id || request.recommendation_id.trim().length === 0) { + errors.push('穿搭方案ID不能为空'); + } + + if (request.pagination.page < 1) { + errors.push('页码必须大于0'); + } + + if (request.pagination.page_size < 1 || request.pagination.page_size > 100) { + errors.push('每页大小必须在1-100之间'); + } + + return errors; + } + + /** + * 批量处理搜索结果 + */ + static async batchProcessResults( + results: MaterialSearchResult[], + processor: (result: MaterialSearchResult) => Promise + ): Promise { + try { + const processedResults = await Promise.all( + results.map(result => processor(result)) + ); + return processedResults; + } catch (error) { + console.error('Failed to batch process results:', error); + throw new Error(`批量处理失败: ${error}`); + } + } +} + +/** + * 导出默认实例 + */ +export default MaterialSearchService; diff --git a/apps/desktop/src/types/outfitRecommendation.ts b/apps/desktop/src/types/outfitRecommendation.ts index e28c425..cbe1e7c 100644 --- a/apps/desktop/src/types/outfitRecommendation.ts +++ b/apps/desktop/src/types/outfitRecommendation.ts @@ -139,6 +139,8 @@ export interface OutfitRecommendationCardProps { onSelect?: (recommendation: OutfitRecommendation) => void; /** 场景检索事件处理 */ onSceneSearch?: (recommendation: OutfitRecommendation) => void; + /** 素材检索事件处理 */ + onMaterialSearch?: (recommendation: OutfitRecommendation) => void; /** 是否显示详细信息 */ showDetails?: boolean; /** 是否紧凑模式 */ @@ -159,6 +161,8 @@ export interface OutfitRecommendationListProps { onRecommendationSelect?: (recommendation: OutfitRecommendation) => void; /** 场景检索事件 */ onSceneSearch?: (recommendation: OutfitRecommendation) => void; + /** 素材检索事件 */ + onMaterialSearch?: (recommendation: OutfitRecommendation) => void; /** 重新生成事件 */ onRegenerate?: () => void; /** 自定义样式类名 */ @@ -234,6 +238,231 @@ export const COLOR_PREFERENCE_OPTIONS = [ '撞色', ] as const; +// ===== 素材库检索相关类型 ===== + +/** + * 素材检索请求 + */ +export interface MaterialSearchRequest { + /** 基于穿搭方案生成的搜索查询 */ + query: string; + /** 穿搭方案ID */ + recommendation_id: string; + /** 搜索配置 */ + search_config: MaterialSearchConfig; + /** 分页信息 */ + pagination: { + page: number; + page_size: number; + }; +} + +/** + * 素材检索配置 + */ +export interface MaterialSearchConfig { + /** 相关性阈值 */ + relevance_threshold: 'LOWEST' | 'LOW' | 'MEDIUM' | 'HIGH'; + /** 类别过滤 */ + categories: string[]; + /** 环境标签 */ + environments: string[]; + /** 颜色过滤器 */ + color_filters: Record; + /** 设计风格 */ + design_styles: Record; + /** 最大结果数量 */ + max_results: number; +} + +/** + * 颜色过滤器 + */ +export interface ColorFilter { + enabled: boolean; + color: { + hue: number; + saturation: number; + value: number; + }; + hue_threshold: number; + saturation_threshold: number; + value_threshold: number; +} + +/** + * 素材检索结果 + */ +export interface MaterialSearchResult { + /** 结果ID */ + id: string; + /** 图片URL */ + image_url: string; + /** 风格描述 */ + style_description: string; + /** 环境标签 */ + environment_tags: string[]; + /** 产品信息 */ + products: MaterialProduct[]; + /** 相关性评分 */ + relevance_score: number; + /** 创建时间 */ + created_at: string; +} + +/** + * 素材产品信息 + */ +export interface MaterialProduct { + /** 产品类别 */ + category: string; + /** 产品描述 */ + description: string; + /** 主要颜色 */ + color_pattern: { + hue: number; + saturation: number; + value: number; + }; + /** 设计风格 */ + design_styles: 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 GenerateSearchQueryRequest { + /** 穿搭方案 */ + recommendation: OutfitRecommendation; + /** 生成选项 */ + options?: { + /** 是否包含颜色信息 */ + include_colors?: boolean; + /** 是否包含风格信息 */ + include_styles?: boolean; + /** 是否包含场合信息 */ + include_occasions?: boolean; + /** 是否包含季节信息 */ + include_seasons?: boolean; + }; +} + +/** + * 智能检索条件生成响应 + */ +export interface GenerateSearchQueryResponse { + /** 生成的搜索查询 */ + query: string; + /** 生成的搜索配置 */ + search_config: MaterialSearchConfig; + /** 生成时间(毫秒) */ + generation_time_ms: number; + /** 生成时间戳 */ + generated_at: string; +} + +// ===== 素材检索组件Props ===== + +/** + * 素材检索面板组件Props + */ +export interface MaterialSearchPanelProps { + /** 穿搭方案 */ + recommendation: OutfitRecommendation; + /** 是否显示 */ + isVisible: boolean; + /** 关闭回调 */ + onClose: () => void; + /** 素材选择回调 */ + onMaterialSelect?: (material: MaterialSearchResult) => void; + /** 自定义类名 */ + className?: string; +} + +/** + * 素材检索结果列表组件Props + */ +export interface MaterialSearchResultsProps { + /** 搜索结果 */ + results: MaterialSearchResult[]; + /** 总结果数量 */ + totalSize: number; + /** 当前页码 */ + currentPage: number; + /** 每页大小 */ + pageSize: number; + /** 是否加载中 */ + isLoading: boolean; + /** 错误信息 */ + error?: string; + /** 页面变化回调 */ + onPageChange: (page: number) => void; + /** 素材选择回调 */ + onMaterialSelect?: (material: MaterialSearchResult) => void; + /** 自定义类名 */ + className?: string; +} + +/** + * 素材卡片组件Props + */ +export interface MaterialCardProps { + /** 素材结果 */ + material: MaterialSearchResult; + /** 选择回调 */ + onSelect?: (material: MaterialSearchResult) => void; + /** 是否显示评分 */ + showScore?: boolean; + /** 是否紧凑模式 */ + compact?: boolean; + /** 自定义类名 */ + className?: string; +} + +/** + * 素材检索分页组件Props + */ +export interface MaterialSearchPaginationProps { + /** 当前页码 */ + currentPage: number; + /** 总页数 */ + totalPages: number; + /** 每页大小 */ + pageSize: number; + /** 总结果数量 */ + totalSize: number; + /** 是否加载中 */ + isLoading?: boolean; + /** 页面变化回调 */ + onPageChange: (page: number) => void; + /** 每页大小变化回调 */ + onPageSizeChange?: (pageSize: number) => void; + /** 自定义类名 */ + className?: string; +} + // 工具函数 export const OutfitRecommendationUtils = { /**