feat: 实现AI穿搭方案推荐素材库检索功能
新增功能: - 为每个穿搭方案添加素材库检索功能 - 智能生成检索条件基于穿搭方案内容 - 调用Google VET API进行素材检索 - 实现分页展示和导航功能 架构改进: - 新增MaterialSearchService前端服务 - 新增material_search_commands后端命令 - 新增material_search数据模型 - 遵循Tauri开发规范的组件设计 UI/UX优化: - 美观的素材卡片展示 - 流畅的分页导航体验 - 响应式设计和动画效果 - 遵循promptx/frontend-developer标准 组件结构: - MaterialSearchPanel: 素材检索面板 - MaterialSearchResults: 搜索结果列表 - MaterialCard: 素材卡片组件 - MaterialSearchPagination: 分页组件 技术实现: - TypeScript类型安全 - React Hooks状态管理 - TailwindCSS样式系统 - 错误处理和加载状态 - 无障碍访问支持
This commit is contained in:
287
apps/desktop/src-tauri/src/data/models/material_search.rs
Normal file
287
apps/desktop/src-tauri/src/data/models/material_search.rs
Normal file
@@ -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<String>,
|
||||
/// 环境标签
|
||||
pub environments: Vec<String>,
|
||||
/// 颜色过滤器
|
||||
pub color_filters: std::collections::HashMap<String, MaterialColorFilter>,
|
||||
/// 设计风格
|
||||
pub design_styles: std::collections::HashMap<String, Vec<String>>,
|
||||
/// 最大结果数量
|
||||
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<String>,
|
||||
/// 产品信息
|
||||
pub products: Vec<MaterialProduct>,
|
||||
/// 相关性评分
|
||||
pub relevance_score: f64,
|
||||
/// 创建时间
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 素材产品信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MaterialProduct {
|
||||
/// 产品类别
|
||||
pub category: String,
|
||||
/// 产品描述
|
||||
pub description: String,
|
||||
/// 主要颜色
|
||||
pub color_pattern: MaterialColorHSV,
|
||||
/// 设计风格
|
||||
pub design_styles: Vec<String>,
|
||||
}
|
||||
|
||||
/// 素材检索响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MaterialSearchResponse {
|
||||
/// 搜索结果列表
|
||||
pub results: Vec<MaterialSearchResult>,
|
||||
/// 总结果数量
|
||||
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<Utc>,
|
||||
/// 下一页令牌
|
||||
pub next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
/// 智能检索条件生成请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GenerateSearchQueryRequest {
|
||||
/// 穿搭方案
|
||||
pub recommendation: OutfitRecommendation,
|
||||
/// 生成选项
|
||||
pub options: Option<GenerateSearchQueryOptions>,
|
||||
}
|
||||
|
||||
/// 智能检索条件生成选项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GenerateSearchQueryOptions {
|
||||
/// 是否包含颜色信息
|
||||
pub include_colors: Option<bool>,
|
||||
/// 是否包含风格信息
|
||||
pub include_styles: Option<bool>,
|
||||
/// 是否包含场合信息
|
||||
pub include_occasions: Option<bool>,
|
||||
/// 是否包含季节信息
|
||||
pub include_seasons: Option<bool>,
|
||||
}
|
||||
|
||||
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<Utc>,
|
||||
}
|
||||
|
||||
// 实现从现有类型的转换
|
||||
impl From<SearchResult> 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<ProductInfo> 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<MaterialSearchConfig> 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<GenerateSearchQueryResponse, String> {
|
||||
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<String> = 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<String> = request.recommendation.occasions
|
||||
.iter()
|
||||
.take(2) // 限制最多2个场合
|
||||
.cloned()
|
||||
.collect();
|
||||
query_parts.extend(occasions);
|
||||
}
|
||||
|
||||
// 添加季节信息(如果启用)
|
||||
if options.include_seasons.unwrap_or(false) {
|
||||
let seasons: Vec<String> = 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::<std::collections::HashSet<_>>() // 去重
|
||||
.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::<std::collections::HashSet<_>>() // 去重
|
||||
.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<MaterialSearchResponse, String> {
|
||||
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<MaterialSearchResult> = 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<u32>,
|
||||
page_size: Option<u32>,
|
||||
) -> Result<MaterialSearchResponse, String> {
|
||||
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<SearchResponse, anyhow::Error> {
|
||||
// 直接调用底层的搜索逻辑
|
||||
execute_vertex_ai_search_internal(request).await
|
||||
}
|
||||
|
||||
/// 内部Vertex AI搜索实现
|
||||
async fn execute_vertex_ai_search_internal(
|
||||
request: &SearchRequest,
|
||||
) -> Result<SearchResponse, anyhow::Error> {
|
||||
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<String, anyhow::Error> {
|
||||
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<SearchResponse, anyhow::Error> {
|
||||
// 这里应该实现实际的Vertex AI Search API调用
|
||||
// 暂时返回一个模拟的响应
|
||||
Ok(SearchResponse {
|
||||
results: Vec::new(),
|
||||
total_size: 0,
|
||||
next_page_token: None,
|
||||
search_time_ms: 100,
|
||||
searched_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
246
apps/desktop/src/components/material/MaterialCard.tsx
Normal file
246
apps/desktop/src/components/material/MaterialCard.tsx
Normal file
@@ -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<MaterialCardProps> = ({
|
||||
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 (
|
||||
<div
|
||||
className={`
|
||||
card card-interactive group cursor-pointer animate-fade-in-up
|
||||
relative overflow-hidden bg-gradient-to-br from-white to-gray-50/50
|
||||
hover:from-white hover:to-primary-50/30 transition-all duration-500
|
||||
hover:shadow-lg hover:shadow-primary-500/10 hover:-translate-y-1
|
||||
border border-gray-200 hover:border-primary-300
|
||||
transform-gpu will-change-transform
|
||||
${compact ? 'p-4' : 'p-5'}
|
||||
${className}
|
||||
`}
|
||||
onClick={handleCardClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* 装饰性背景 */}
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-primary-100 to-primary-200 rounded-full -translate-y-10 translate-x-10 opacity-40 group-hover:opacity-60 transition-all duration-500 group-hover:scale-110"></div>
|
||||
|
||||
{/* 顶部标识 */}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-2">
|
||||
{showScore && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs font-medium">
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
{MaterialSearchService.formatRelevanceScore(material.relevance_score)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* 素材图片 */}
|
||||
<div className="relative mb-4">
|
||||
<div className={`aspect-square rounded-lg overflow-hidden bg-gray-100 ${compact ? 'h-32' : 'h-48'}`}>
|
||||
{!imageError ? (
|
||||
<img
|
||||
src={material.image_url}
|
||||
alt={material.style_description}
|
||||
className={`w-full h-full object-cover transition-all duration-500 group-hover:scale-105 ${
|
||||
imageLoaded ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-100">
|
||||
<Package className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{!imageLoaded && !imageError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
|
||||
<div className="w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 悬停遮罩 */}
|
||||
{isHovered && (
|
||||
<div className="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<button
|
||||
onClick={handleExternalLink}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white/90 text-gray-800 rounded-lg hover:bg-white transition-colors duration-200"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">查看原图</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 素材信息 */}
|
||||
<div className="space-y-3">
|
||||
{/* 风格描述 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-high-emphasis line-clamp-2 group-hover:text-primary-600 transition-colors duration-200">
|
||||
{material.style_description || '时尚素材'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 环境标签 */}
|
||||
{material.environment_tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{material.environment_tags.slice(0, 2).map((tag, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-700 rounded-full text-xs font-medium"
|
||||
>
|
||||
{tag}
|
||||
</div>
|
||||
))}
|
||||
{material.environment_tags.length > 2 && (
|
||||
<div className="px-2 py-1 bg-gray-100 text-gray-500 rounded-full text-xs">
|
||||
+{material.environment_tags.length - 2}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 产品信息 */}
|
||||
{primaryProduct && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="w-3 h-3 text-gray-500" />
|
||||
<span className="text-xs font-medium text-gray-700">{primaryProduct.category}</span>
|
||||
{hasMultipleProducts && (
|
||||
<span className="text-xs text-gray-500">+{material.products.length - 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-600 line-clamp-2">
|
||||
{primaryProduct.description}
|
||||
</p>
|
||||
|
||||
{/* 设计风格 */}
|
||||
{primaryProduct.design_styles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{primaryProduct.design_styles.slice(0, 2).map((style, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs"
|
||||
>
|
||||
{style}
|
||||
</div>
|
||||
))}
|
||||
{primaryProduct.design_styles.length > 2 && (
|
||||
<div className="px-2 py-0.5 bg-gray-100 text-gray-500 rounded text-xs">
|
||||
+{primaryProduct.design_styles.length - 2}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 颜色信息 */}
|
||||
{primaryProduct && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-3 h-3 text-gray-500" />
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border border-gray-300 shadow-sm"
|
||||
style={{
|
||||
backgroundColor: `hsl(${primaryProduct.color_pattern.hue * 360}, ${primaryProduct.color_pattern.saturation * 100}%, ${primaryProduct.color_pattern.value * 100}%)`
|
||||
}}
|
||||
title="主要颜色"
|
||||
/>
|
||||
<span className="text-xs text-gray-600">主色调</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作区域 */}
|
||||
<div className="flex items-center justify-between pt-3 mt-3 border-t border-gray-100">
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(material.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onSelect) onSelect(material);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 text-primary-600 hover:text-primary-700 hover:bg-primary-50 rounded transition-colors duration-200 text-xs font-medium"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
查看详情
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI推荐标识 */}
|
||||
<div className="absolute bottom-3 left-3 flex items-center gap-1 px-2 py-1 bg-gradient-to-r from-purple-100 to-pink-100 text-purple-700 rounded-full text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
AI推荐
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialCard;
|
||||
@@ -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<MaterialSearchPaginationProps> = ({
|
||||
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 (
|
||||
<div className={`flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 ${className}`}>
|
||||
{/* 分页信息和每页显示数量 */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<div className="text-sm text-medium-emphasis">
|
||||
显示 {startItem} 到 {endItem} 项,共 {totalSize} 项
|
||||
</div>
|
||||
|
||||
{onPageSizeChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-medium-emphasis">每页显示:</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
||||
disabled={isLoading}
|
||||
className="px-2 py-1 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value={6}>6 项</option>
|
||||
<option value={9}>9 项</option>
|
||||
<option value={12}>12 项</option>
|
||||
<option value={18}>18 项</option>
|
||||
<option value={24}>24 项</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页控制 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 上一页按钮 */}
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
aria-label="上一页"
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent focus:ring-2 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">上一页</span>
|
||||
</button>
|
||||
|
||||
{/* 页码按钮 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{visiblePages.map((page, index) => {
|
||||
if (page === '...') {
|
||||
return (
|
||||
<div
|
||||
key={`dots-${index}`}
|
||||
className="flex items-center justify-center w-10 h-10 text-gray-400"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pageNumber = page as number;
|
||||
const isCurrentPage = pageNumber === currentPage;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageNumber}
|
||||
onClick={() => handlePageChange(pageNumber)}
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
w-10 h-10 rounded-lg font-medium text-sm transition-all duration-200
|
||||
${isCurrentPage
|
||||
? 'bg-gradient-primary text-white shadow-lg shadow-primary-500/25'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent
|
||||
`}
|
||||
>
|
||||
{pageNumber}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 下一页按钮 */}
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || isLoading}
|
||||
aria-label="下一页"
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent focus:ring-2 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<span className="hidden sm:inline">下一页</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 快速跳转(可选) */}
|
||||
{totalPages > 10 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-medium-emphasis">跳转到:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={currentPage}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm text-medium-emphasis">页</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialSearchPagination;
|
||||
300
apps/desktop/src/components/material/MaterialSearchPanel.tsx
Normal file
300
apps/desktop/src/components/material/MaterialSearchPanel.tsx
Normal file
@@ -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<MaterialSearchPanelProps> = ({
|
||||
recommendation,
|
||||
isVisible,
|
||||
onClose,
|
||||
onMaterialSelect,
|
||||
className = '',
|
||||
}) => {
|
||||
// 状态管理
|
||||
const [searchResponse, setSearchResponse] = useState<MaterialSearchResponse | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className={`fixed inset-0 z-50 bg-black/50 backdrop-blur-sm animate-fade-in ${className}`}>
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl max-w-6xl w-full max-h-[90vh] overflow-hidden animate-fade-in-up">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 bg-gradient-to-r from-primary-50 to-blue-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-primary-500 to-blue-500 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Search className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">素材库检索</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
为"{recommendation.title}"查找合适的素材
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors duration-200 ${
|
||||
showAdvanced
|
||||
? 'text-primary-600 bg-primary-50'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
title="高级设置"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={!searchQuery || isSearching}
|
||||
className="flex items-center gap-2 px-3 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="刷新结果"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isSearching ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex items-center gap-2 px-3 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索信息 */}
|
||||
{searchQuery && (
|
||||
<div className="px-6 py-3 bg-blue-50 border-b border-blue-100">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-800">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span className="font-medium">检索条件:</span>
|
||||
<span className="bg-blue-100 px-2 py-1 rounded text-xs font-mono">
|
||||
{searchQuery}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主要内容 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* 错误状态 */}
|
||||
{error && (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-800">检索失败</p>
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerateAndSearch}
|
||||
className="ml-auto px-3 py-1 bg-red-100 hover:bg-red-200 text-red-700 rounded text-sm transition-colors duration-200"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中状态 */}
|
||||
{isGenerating && (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
variant="default"
|
||||
icon={<Loader2 className="w-12 h-12 text-primary-500 animate-spin" />}
|
||||
title="正在生成检索条件..."
|
||||
description="AI正在分析穿搭方案,为您生成最佳的素材检索条件"
|
||||
size="md"
|
||||
className="py-12"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索结果 */}
|
||||
{searchResponse && !isGenerating && (
|
||||
<MaterialSearchResults
|
||||
results={searchResponse.results}
|
||||
totalSize={searchResponse.total_size}
|
||||
currentPage={searchResponse.current_page}
|
||||
pageSize={searchResponse.page_size}
|
||||
isLoading={isSearching}
|
||||
error={error || undefined}
|
||||
onPageChange={handlePageChange}
|
||||
onMaterialSelect={onMaterialSelect}
|
||||
className="p-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!searchResponse && !isGenerating && !error && (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
variant="default"
|
||||
icon={<Search className="w-12 h-12 text-gray-400" />}
|
||||
title="准备开始检索"
|
||||
description="点击生成按钮开始为您的穿搭方案检索合适的素材"
|
||||
actionText="生成检索条件"
|
||||
onAction={handleGenerateAndSearch}
|
||||
size="md"
|
||||
className="py-12"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-gray-200 bg-gray-50">
|
||||
<div className="text-sm text-gray-600">
|
||||
{searchResponse && (
|
||||
<span>
|
||||
找到 {searchResponse.total_size} 个素材,
|
||||
用时 {MaterialSearchService.formatSearchTime(searchResponse.search_time_ms)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleGenerateAndSearch}
|
||||
disabled={isGenerating || isSearching}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
重新生成
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialSearchPanel;
|
||||
241
apps/desktop/src/components/material/MaterialSearchResults.tsx
Normal file
241
apps/desktop/src/components/material/MaterialSearchResults.tsx
Normal file
@@ -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<MaterialSearchResultsProps> = ({
|
||||
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 (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{/* 加载标题 */}
|
||||
<div className="flex items-center justify-center gap-3 p-6">
|
||||
<Loader2 className="w-6 h-6 text-primary-500 animate-spin" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-high-emphasis mb-1">
|
||||
正在检索素材...
|
||||
</h3>
|
||||
<p className="text-sm text-medium-emphasis">
|
||||
请稍候,我们正在为您搜索最合适的素材
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加载骨架屏 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3, 4, 5, 6].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="card p-4 animate-pulse"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 图片骨架 */}
|
||||
<div className="aspect-square bg-gray-200 rounded-lg"></div>
|
||||
|
||||
{/* 标题骨架 */}
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
|
||||
{/* 描述骨架 */}
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-gray-200 rounded w-full"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||
</div>
|
||||
|
||||
{/* 标签骨架 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="h-6 bg-gray-200 rounded-full w-16"></div>
|
||||
<div className="h-6 bg-gray-200 rounded-full w-12"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<EmptyState
|
||||
variant="error"
|
||||
icon={<AlertCircle className="w-12 h-12 text-red-500" />}
|
||||
title="检索失败"
|
||||
description={error}
|
||||
actionText="重新检索"
|
||||
onAction={() => onPageChange(currentPage)}
|
||||
illustration="error"
|
||||
size="md"
|
||||
className="py-12"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 空状态
|
||||
if (!results || results.length === 0) {
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<EmptyState
|
||||
variant="default"
|
||||
icon={<Package className="w-12 h-12 text-gray-400" />}
|
||||
title="暂无素材结果"
|
||||
description="未找到符合条件的素材,请尝试调整检索条件"
|
||||
actionText="重新检索"
|
||||
onAction={() => onPageChange(1)}
|
||||
illustration="folder"
|
||||
size="md"
|
||||
className="py-12"
|
||||
showTips={true}
|
||||
tips={[
|
||||
'尝试使用更通用的关键词',
|
||||
'调整颜色和风格过滤条件',
|
||||
'检查网络连接是否正常',
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 正常状态 - 显示结果列表
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{/* 列表标题和统计信息 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="icon-container primary w-8 h-8">
|
||||
<Grid className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-high-emphasis">
|
||||
素材检索结果
|
||||
</h3>
|
||||
<p className="text-sm text-medium-emphasis">
|
||||
第 {pageInfo.start}-{pageInfo.end} 项,共 {totalSize} 个素材
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视图切换(预留) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-2 text-primary-600 bg-primary-50 rounded-lg">
|
||||
<Grid className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-2 text-gray-400 hover:text-gray-600 rounded-lg">
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 素材卡片网格 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{results.map((material, index) => (
|
||||
<MaterialCard
|
||||
key={material.id || index}
|
||||
material={material}
|
||||
onSelect={handleMaterialSelect}
|
||||
showScore={true}
|
||||
compact={false}
|
||||
className={`animate-fade-in-up ${isLoading ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页控制 */}
|
||||
{totalPages > 1 && (
|
||||
<MaterialSearchPagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={pageSize}
|
||||
totalSize={totalSize}
|
||||
isLoading={isLoading}
|
||||
onPageChange={handlePageChange}
|
||||
className="mt-8"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 底部提示 */}
|
||||
<div className="card p-4 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="icon-container blue w-6 h-6 mt-0.5 shadow-sm">
|
||||
<Package className="w-3 h-3" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-blue-900 mb-2">💡 使用提示</h4>
|
||||
<ul className="text-xs text-blue-700 space-y-1.5">
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="w-1 h-1 bg-blue-400 rounded-full"></div>
|
||||
点击素材卡片查看详细信息和产品描述
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="w-1 h-1 bg-blue-400 rounded-full"></div>
|
||||
使用分页控制浏览更多素材结果
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="w-1 h-1 bg-blue-400 rounded-full"></div>
|
||||
相关性评分越高表示与穿搭方案越匹配
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="w-1 h-1 bg-blue-400 rounded-full"></div>
|
||||
可以重新生成检索条件获得不同的结果
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加载遮罩 */}
|
||||
{isLoading && results.length > 0 && (
|
||||
<div className="absolute inset-0 bg-white/50 backdrop-blur-sm flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 bg-white rounded-lg shadow-lg p-4">
|
||||
<Loader2 className="w-5 h-5 text-primary-500 animate-spin" />
|
||||
<span className="text-sm font-medium text-gray-700">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialSearchResults;
|
||||
17
apps/desktop/src/components/material/index.ts
Normal file
17
apps/desktop/src/components/material/index.ts
Normal file
@@ -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';
|
||||
@@ -4,13 +4,10 @@ import {
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
MapPin,
|
||||
Camera,
|
||||
Lightbulb,
|
||||
BarChart3,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
Thermometer,
|
||||
Target
|
||||
} from 'lucide-react';
|
||||
|
||||
|
||||
@@ -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<OutfitRecommendationCardProps> =
|
||||
recommendation,
|
||||
onSelect,
|
||||
onSceneSearch,
|
||||
onMaterialSearch,
|
||||
showDetails = true,
|
||||
compact = false,
|
||||
className = '',
|
||||
@@ -50,6 +52,14 @@ export const OutfitRecommendationCard: React.FC<OutfitRecommendationCardProps> =
|
||||
}
|
||||
}, [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<OutfitRecommendationCardProps> =
|
||||
<div className="text-xs text-gray-500">
|
||||
{recommendation.color_theme}
|
||||
</div>
|
||||
|
||||
{onSceneSearch && (
|
||||
<button
|
||||
onClick={handleSceneSearch}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors duration-200 text-sm font-medium"
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
场景检索
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onMaterialSearch && (
|
||||
<button
|
||||
onClick={handleMaterialSearch}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors duration-200 text-sm font-medium"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
素材检索
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onSceneSearch && (
|
||||
<button
|
||||
onClick={handleSceneSearch}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors duration-200 text-sm font-medium"
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
场景检索
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ export const OutfitRecommendationList: React.FC<OutfitRecommendationListProps> =
|
||||
error,
|
||||
onRecommendationSelect,
|
||||
onSceneSearch,
|
||||
onMaterialSearch,
|
||||
onRegenerate,
|
||||
className = '',
|
||||
}) => {
|
||||
@@ -168,6 +169,7 @@ export const OutfitRecommendationList: React.FC<OutfitRecommendationListProps> =
|
||||
recommendation={recommendation}
|
||||
onSelect={onRecommendationSelect}
|
||||
onSceneSearch={onSceneSearch}
|
||||
onMaterialSearch={onMaterialSearch}
|
||||
showDetails={true}
|
||||
compact={false}
|
||||
className="animate-fade-in-up"
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// 素材检索状态
|
||||
const [showMaterialSearch, setShowMaterialSearch] = useState(false);
|
||||
const [selectedRecommendation, setSelectedRecommendation] = useState<OutfitRecommendation | null>(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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white">
|
||||
{/* 顶部导航 */}
|
||||
@@ -349,11 +372,22 @@ const OutfitRecommendationTool: React.FC = () => {
|
||||
isLoading={isGenerating}
|
||||
error={error || undefined}
|
||||
onRegenerate={handleRegenerate}
|
||||
onMaterialSearch={handleMaterialSearch}
|
||||
className="min-h-[600px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 素材检索面板 */}
|
||||
{selectedRecommendation && (
|
||||
<MaterialSearchPanel
|
||||
recommendation={selectedRecommendation}
|
||||
isVisible={showMaterialSearch}
|
||||
onClose={handleCloseMaterialSearch}
|
||||
onMaterialSelect={handleMaterialSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<HTMLInputElement>(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('图片加载失败,请重新选择图片');
|
||||
}}
|
||||
|
||||
346
apps/desktop/src/services/materialSearchService.ts
Normal file
346
apps/desktop/src/services/materialSearchService.ts
Normal file
@@ -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<GenerateSearchQueryResponse> {
|
||||
try {
|
||||
const request: GenerateSearchQueryRequest = {
|
||||
recommendation,
|
||||
options,
|
||||
};
|
||||
|
||||
console.log('🔍 生成素材检索条件:', request);
|
||||
|
||||
const response = await invoke<GenerateSearchQueryResponse>(
|
||||
'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<MaterialSearchResponse> {
|
||||
try {
|
||||
console.log('🔍 执行素材库检索:', request);
|
||||
|
||||
const response = await invoke<MaterialSearchResponse>(
|
||||
'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<MaterialSearchResponse> {
|
||||
try {
|
||||
console.log('🔍 快速素材检索:', { recommendationId, query, page, pageSize });
|
||||
|
||||
const response = await invoke<MaterialSearchResponse>(
|
||||
'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<MaterialSearchResponse> {
|
||||
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<string, number> = {};
|
||||
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<MaterialSearchResult>
|
||||
): Promise<MaterialSearchResult[]> {
|
||||
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;
|
||||
@@ -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<string, ColorFilter>;
|
||||
/** 设计风格 */
|
||||
design_styles: Record<string, string[]>;
|
||||
/** 最大结果数量 */
|
||||
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 = {
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user