feat: 实现AI穿搭方案推荐功能
- 新增穿搭方案推荐数据模型和类型定义 - 实现基于TikTok视觉趋势的Gemini AI穿搭方案生成 - 创建穿搭方案卡片和列表展示组件 - 集成Sparkles图标点击触发穿搭方案生成 - 实现穿搭方案到场景检索的无缝集成 - 添加完整的前后端API和服务层 - 遵循promptx开发规范和设计系统标准 功能特点: - 基于用户输入关键词生成个性化穿搭推荐 - 包含色彩搭配、风格标签、场景建议等详细信息 - 提供TikTok优化建议和拍摄技巧 - 支持一键场景检索功能 - 美观的卡片式展示界面 - 完整的加载状态和错误处理
This commit is contained in:
@@ -14,6 +14,7 @@ pub mod video_generation;
|
||||
pub mod conversation;
|
||||
pub mod outfit_search;
|
||||
pub mod gemini_analysis;
|
||||
pub mod outfit_recommendation;
|
||||
pub mod custom_tag;
|
||||
pub mod watermark;
|
||||
pub mod thumbnail;
|
||||
|
||||
191
apps/desktop/src-tauri/src/data/models/outfit_recommendation.rs
Normal file
191
apps/desktop/src-tauri/src/data/models/outfit_recommendation.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// 色彩信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ColorInfo {
|
||||
/// 色彩名称
|
||||
pub name: String,
|
||||
/// 十六进制色值
|
||||
pub hex: String,
|
||||
/// HSV值 (0-1范围)
|
||||
pub hsv: (f32, f32, f32),
|
||||
}
|
||||
|
||||
/// 穿搭单品
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitItem {
|
||||
/// 单品类别 (如: "上装", "下装", "鞋子", "配饰")
|
||||
pub category: String,
|
||||
/// 单品描述
|
||||
pub description: String,
|
||||
/// 主要颜色
|
||||
pub primary_color: ColorInfo,
|
||||
/// 次要颜色 (可选)
|
||||
pub secondary_color: Option<ColorInfo>,
|
||||
/// 材质
|
||||
pub material: String,
|
||||
/// 风格标签
|
||||
pub style_tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// 场景建议
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SceneRecommendation {
|
||||
/// 场景名称
|
||||
pub name: String,
|
||||
/// 场景描述
|
||||
pub description: String,
|
||||
/// 场景类型 ("室内", "室外", "特殊")
|
||||
pub scene_type: String,
|
||||
/// 适合的时间段
|
||||
pub time_of_day: Vec<String>,
|
||||
/// 光线条件
|
||||
pub lighting: String,
|
||||
/// 拍摄建议
|
||||
pub photography_tips: Vec<String>,
|
||||
}
|
||||
|
||||
/// 穿搭方案
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitRecommendation {
|
||||
/// 方案ID
|
||||
pub id: String,
|
||||
/// 方案标题
|
||||
pub title: String,
|
||||
/// 方案描述
|
||||
pub description: String,
|
||||
/// 整体风格
|
||||
pub overall_style: String,
|
||||
/// 风格标签
|
||||
pub style_tags: Vec<String>,
|
||||
/// 适合场合
|
||||
pub occasions: Vec<String>,
|
||||
/// 适合季节
|
||||
pub seasons: Vec<String>,
|
||||
/// 穿搭单品列表
|
||||
pub items: Vec<OutfitItem>,
|
||||
/// 色彩搭配主题
|
||||
pub color_theme: String,
|
||||
/// 主要色彩
|
||||
pub primary_colors: Vec<ColorInfo>,
|
||||
/// 场景建议
|
||||
pub scene_recommendations: Vec<SceneRecommendation>,
|
||||
/// TikTok优化建议
|
||||
pub tiktok_tips: Vec<String>,
|
||||
/// 搭配要点
|
||||
pub styling_tips: Vec<String>,
|
||||
/// 创建时间
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 穿搭方案生成请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitRecommendationRequest {
|
||||
/// 用户输入的关键词或描述
|
||||
pub query: String,
|
||||
/// 目标风格 (可选)
|
||||
pub target_style: Option<String>,
|
||||
/// 适合场合 (可选)
|
||||
pub occasions: Option<Vec<String>>,
|
||||
/// 季节偏好 (可选)
|
||||
pub season: Option<String>,
|
||||
/// 色彩偏好 (可选)
|
||||
pub color_preferences: Option<Vec<String>>,
|
||||
/// 生成数量
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
/// 穿搭方案生成响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutfitRecommendationResponse {
|
||||
/// 生成的穿搭方案列表
|
||||
pub recommendations: Vec<OutfitRecommendation>,
|
||||
/// 生成时间 (毫秒)
|
||||
pub generation_time_ms: u64,
|
||||
/// 生成时间戳
|
||||
pub generated_at: DateTime<Utc>,
|
||||
/// 使用的提示词 (调试用)
|
||||
pub prompt_used: Option<String>,
|
||||
}
|
||||
|
||||
/// 场景检索请求 (用于方案详情到场景检索的集成)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SceneSearchRequest {
|
||||
/// 穿搭方案ID
|
||||
pub outfit_id: String,
|
||||
/// 场景描述
|
||||
pub scene_description: String,
|
||||
/// 风格标签
|
||||
pub style_tags: Vec<String>,
|
||||
/// 色彩信息
|
||||
pub colors: Vec<ColorInfo>,
|
||||
/// 搜索参数
|
||||
pub search_params: SceneSearchParams,
|
||||
}
|
||||
|
||||
/// 场景检索参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SceneSearchParams {
|
||||
/// 场景类型过滤
|
||||
pub scene_types: Option<Vec<String>>,
|
||||
/// 时间段过滤
|
||||
pub time_filters: Option<Vec<String>>,
|
||||
/// 光线条件过滤
|
||||
pub lighting_filters: Option<Vec<String>>,
|
||||
/// 结果数量限制
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for OutfitRecommendationRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
query: String::new(),
|
||||
target_style: None,
|
||||
occasions: None,
|
||||
season: None,
|
||||
color_preferences: None,
|
||||
count: 3, // 默认生成3个方案
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutfitRecommendation {
|
||||
/// 生成用于场景检索的描述
|
||||
pub fn generate_scene_search_description(&self) -> String {
|
||||
let mut description = format!("{} - {}", self.title, self.description);
|
||||
|
||||
if !self.style_tags.is_empty() {
|
||||
description.push_str(&format!(" 风格: {}", self.style_tags.join(", ")));
|
||||
}
|
||||
|
||||
if !self.primary_colors.is_empty() {
|
||||
let color_names: Vec<String> = self.primary_colors.iter()
|
||||
.map(|c| c.name.clone())
|
||||
.collect();
|
||||
description.push_str(&format!(" 主要色彩: {}", color_names.join(", ")));
|
||||
}
|
||||
|
||||
description
|
||||
}
|
||||
|
||||
/// 获取所有风格标签 (包括整体风格和单品风格)
|
||||
pub fn get_all_style_tags(&self) -> Vec<String> {
|
||||
let mut all_tags = self.style_tags.clone();
|
||||
|
||||
// 添加整体风格
|
||||
if !self.overall_style.is_empty() {
|
||||
all_tags.push(self.overall_style.clone());
|
||||
}
|
||||
|
||||
// 添加单品风格标签
|
||||
for item in &self.items {
|
||||
all_tags.extend(item.style_tags.clone());
|
||||
}
|
||||
|
||||
// 去重并排序
|
||||
all_tags.sort();
|
||||
all_tags.dedup();
|
||||
all_tags
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,12 @@ use crate::data::models::conversation::{
|
||||
};
|
||||
use crate::data::repositories::conversation_repository::ConversationRepository;
|
||||
|
||||
// 导入穿搭方案推荐相关模块
|
||||
use crate::data::models::outfit_recommendation::{
|
||||
OutfitRecommendation, OutfitRecommendationRequest, OutfitRecommendationResponse,
|
||||
OutfitItem, ColorInfo, SceneRecommendation
|
||||
};
|
||||
|
||||
/// Gemini API配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GeminiConfig {
|
||||
@@ -1690,3 +1696,462 @@ impl GeminiService {
|
||||
}
|
||||
}
|
||||
|
||||
// 穿搭方案推荐扩展
|
||||
impl GeminiService {
|
||||
/// 生成穿搭方案推荐
|
||||
pub async fn generate_outfit_recommendations(&mut self, request: &OutfitRecommendationRequest) -> Result<OutfitRecommendationResponse> {
|
||||
println!("🎨 开始生成穿搭方案推荐...");
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// 构建穿搭方案生成提示词
|
||||
let prompt = self.build_outfit_recommendation_prompt(request);
|
||||
|
||||
// 获取访问令牌
|
||||
let access_token = self.get_access_token().await?;
|
||||
|
||||
// 创建客户端配置
|
||||
let client_config = self.create_gemini_client(&access_token);
|
||||
|
||||
// 准备请求数据
|
||||
let request_data = GenerateContentRequest {
|
||||
contents: vec![ContentPart {
|
||||
role: "user".to_string(),
|
||||
parts: vec![Part::Text { text: prompt.clone() }],
|
||||
}],
|
||||
generation_config: GenerationConfig {
|
||||
temperature: 0.8, // 稍高的温度以增加创意性
|
||||
top_k: 40,
|
||||
top_p: 0.95,
|
||||
max_output_tokens: self.config.max_tokens,
|
||||
},
|
||||
};
|
||||
|
||||
// 发送请求到Cloudflare Gateway
|
||||
let generate_url = format!("{}/{}:generateContent", client_config.gateway_url, self.config.model_name);
|
||||
|
||||
// 重试机制
|
||||
let mut last_error = None;
|
||||
|
||||
for attempt in 0..self.config.max_retries {
|
||||
match self.send_generate_request(&generate_url, &client_config, &request_data).await {
|
||||
Ok(result) => {
|
||||
let raw_response = self.parse_gemini_response_content(&result)?;
|
||||
|
||||
// 解析穿搭方案
|
||||
match self.parse_outfit_recommendations(&raw_response, request) {
|
||||
Ok(recommendations) => {
|
||||
let generation_time = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
println!("✅ 穿搭方案生成完成,共生成 {} 个方案", recommendations.len());
|
||||
|
||||
return Ok(OutfitRecommendationResponse {
|
||||
recommendations,
|
||||
generation_time_ms: generation_time,
|
||||
generated_at: chrono::Utc::now(),
|
||||
prompt_used: Some(prompt),
|
||||
});
|
||||
}
|
||||
Err(parse_error) => {
|
||||
println!("⚠️ 解析穿搭方案失败: {}", parse_error);
|
||||
last_error = Some(anyhow!("解析穿搭方案失败: {}", parse_error));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(e);
|
||||
|
||||
if attempt < self.config.max_retries - 1 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(self.config.retry_delay)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("穿搭方案生成失败,已重试{}次: {}", self.config.max_retries, last_error.unwrap()))
|
||||
}
|
||||
|
||||
/// 构建穿搭方案生成提示词
|
||||
fn build_outfit_recommendation_prompt(&self, request: &OutfitRecommendationRequest) -> String {
|
||||
let mut prompt = String::new();
|
||||
|
||||
// 基于TikTok模特拍摄选景专家的核心理念
|
||||
prompt.push_str(r#"你是一位专业的TikTok时尚穿搭顾问,深度理解短视频平台的视觉传播规律和用户审美趋势。
|
||||
请根据用户需求生成专业的穿搭方案推荐,确保每个方案都能最大化吸引TikTok用户注意力。
|
||||
|
||||
## 核心设计原则
|
||||
- **视觉冲击力优先**: 确保穿搭在3秒内抓住用户注意力
|
||||
- **平台特性导向**: 所有搭配都要考虑TikTok平台的传播特性和算法偏好
|
||||
- **色彩搭配和谐**: 优先选择能产生强烈视觉对比和吸引力的色彩组合
|
||||
- **场景适配性**: 确保穿搭与拍摄场景完美匹配
|
||||
- **趋势敏感性**: 紧跟TikTok视觉趋势,体现当下流行元素
|
||||
|
||||
## 当前热门趋势
|
||||
- **色彩趋势**: 奶油色系、多巴胺色彩、莫兰迪色系、渐变色彩、对比色搭配
|
||||
- **风格趋势**: 休闲简约、街头潮流、复古回潮、甜美可爱、酷帅中性
|
||||
- **场景偏好**: 咖啡厅、城市街道、公园绿地、家居环境、商场购物中心
|
||||
|
||||
"#);
|
||||
|
||||
// 添加用户具体需求
|
||||
prompt.push_str(&format!("\n## 用户需求\n"));
|
||||
prompt.push_str(&format!("关键词: {}\n", request.query));
|
||||
|
||||
if let Some(style) = &request.target_style {
|
||||
prompt.push_str(&format!("目标风格: {}\n", style));
|
||||
}
|
||||
|
||||
if let Some(occasions) = &request.occasions {
|
||||
prompt.push_str(&format!("适合场合: {}\n", occasions.join(", ")));
|
||||
}
|
||||
|
||||
if let Some(season) = &request.season {
|
||||
prompt.push_str(&format!("季节偏好: {}\n", season));
|
||||
}
|
||||
|
||||
if let Some(colors) = &request.color_preferences {
|
||||
prompt.push_str(&format!("色彩偏好: {}\n", colors.join(", ")));
|
||||
}
|
||||
|
||||
// 添加输出格式要求
|
||||
prompt.push_str(&format!("
|
||||
|
||||
## 输出要求
|
||||
请生成 {} 个不同风格的穿搭方案,以JSON格式返回,结构如下:
|
||||
|
||||
```json
|
||||
{{
|
||||
\"recommendations\": [
|
||||
{{
|
||||
\"id\": \"outfit_001\",
|
||||
\"title\": \"方案标题\",
|
||||
\"description\": \"详细的穿搭描述,突出亮点和特色\",
|
||||
\"overall_style\": \"整体风格\",
|
||||
\"style_tags\": [\"风格标签1\", \"风格标签2\"],
|
||||
\"occasions\": [\"适合场合1\", \"适合场合2\"],
|
||||
\"seasons\": [\"适合季节\"],
|
||||
\"items\": [
|
||||
{{
|
||||
\"category\": \"上装\",
|
||||
\"description\": \"具体单品描述\",
|
||||
\"primary_color\": {{
|
||||
\"name\": \"颜色名称\",
|
||||
\"hex\": \"#FFFFFF\",
|
||||
\"hsv\": [0.0, 0.0, 1.0]
|
||||
}},
|
||||
\"secondary_color\": {{
|
||||
\"name\": \"次要颜色\",
|
||||
\"hex\": \"#000000\",
|
||||
\"hsv\": [0.0, 0.0, 0.0]
|
||||
}},
|
||||
\"material\": \"材质\",
|
||||
\"style_tags\": [\"单品风格标签\"]
|
||||
}}
|
||||
],
|
||||
\"color_theme\": \"色彩搭配主题\",
|
||||
\"primary_colors\": [
|
||||
{{
|
||||
\"name\": \"主色调名称\",
|
||||
\"hex\": \"#FFFFFF\",
|
||||
\"hsv\": [0.0, 0.0, 1.0]
|
||||
}}
|
||||
],
|
||||
\"scene_recommendations\": [
|
||||
{{
|
||||
\"name\": \"推荐场景名称\",
|
||||
\"description\": \"场景详细描述\",
|
||||
\"scene_type\": \"室内或室外或特殊\",
|
||||
\"time_of_day\": [\"时间段\"],
|
||||
\"lighting\": \"光线条件描述\",
|
||||
\"photography_tips\": [\"拍摄建议1\", \"拍摄建议2\"]
|
||||
}}
|
||||
],
|
||||
\"tiktok_tips\": [\"TikTok优化建议1\", \"TikTok优化建议2\"],
|
||||
\"styling_tips\": [\"搭配要点1\", \"搭配要点2\"]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
请确保:
|
||||
1. 每个方案都有独特的风格定位
|
||||
2. 色彩搭配符合当前流行趋势
|
||||
3. 场景推荐具有强烈的视觉冲击力
|
||||
4. TikTok优化建议实用且具体
|
||||
5. 返回的是有效的JSON格式
|
||||
", request.count));
|
||||
|
||||
prompt
|
||||
}
|
||||
|
||||
/// 解析穿搭方案推荐响应
|
||||
fn parse_outfit_recommendations(&self, raw_response: &str, request: &OutfitRecommendationRequest) -> Result<Vec<OutfitRecommendation>> {
|
||||
println!("🔍 开始解析穿搭方案响应...");
|
||||
println!("📄 原始响应: {}", raw_response);
|
||||
|
||||
// 尝试提取JSON部分
|
||||
let json_str = self.extract_json_from_response(raw_response)?;
|
||||
|
||||
// 使用容错JSON解析器
|
||||
let config = ParserConfig {
|
||||
max_text_length: 1024 * 1024,
|
||||
enable_comments: true,
|
||||
enable_unquoted_keys: true,
|
||||
enable_trailing_commas: true,
|
||||
timeout_ms: 30000,
|
||||
recovery_strategies: vec![
|
||||
RecoveryStrategy::StandardJson,
|
||||
RecoveryStrategy::ManualFix,
|
||||
RecoveryStrategy::RegexExtract,
|
||||
RecoveryStrategy::PartialParse,
|
||||
],
|
||||
};
|
||||
|
||||
let mut parser = TolerantJsonParser::new(Some(config))?;
|
||||
|
||||
match parser.parse(&json_str) {
|
||||
Ok((parsed, _stats)) => {
|
||||
if let Some(recommendations_array) = parsed.get("recommendations").and_then(|v| v.as_array()) {
|
||||
let mut recommendations = Vec::new();
|
||||
|
||||
for (index, rec_value) in recommendations_array.iter().enumerate() {
|
||||
match self.parse_single_outfit_recommendation(rec_value, index) {
|
||||
Ok(recommendation) => recommendations.push(recommendation),
|
||||
Err(e) => {
|
||||
println!("⚠️ 解析第{}个穿搭方案失败: {}", index + 1, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if recommendations.is_empty() {
|
||||
return Err(anyhow!("未能解析出任何有效的穿搭方案"));
|
||||
}
|
||||
|
||||
println!("✅ 成功解析 {} 个穿搭方案", recommendations.len());
|
||||
Ok(recommendations)
|
||||
} else {
|
||||
Err(anyhow!("响应中未找到recommendations数组"))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ JSON解析失败: {}", e);
|
||||
Err(anyhow!("JSON解析失败: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析单个穿搭方案
|
||||
fn parse_single_outfit_recommendation(&self, value: &serde_json::Value, index: usize) -> Result<OutfitRecommendation> {
|
||||
let id = value.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&format!("outfit_{:03}", index + 1))
|
||||
.to_string();
|
||||
|
||||
let title = value.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("时尚穿搭方案")
|
||||
.to_string();
|
||||
|
||||
let description = value.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("精心设计的穿搭方案")
|
||||
.to_string();
|
||||
|
||||
let overall_style = value.get("overall_style")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("时尚")
|
||||
.to_string();
|
||||
|
||||
let style_tags = value.get("style_tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_else(|| vec!["时尚".to_string()]);
|
||||
|
||||
let occasions = value.get("occasions")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_else(|| vec!["日常".to_string()]);
|
||||
|
||||
let seasons = value.get("seasons")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_else(|| vec!["四季".to_string()]);
|
||||
|
||||
let color_theme = value.get("color_theme")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("和谐搭配")
|
||||
.to_string();
|
||||
|
||||
// 解析穿搭单品
|
||||
let items = value.get("items")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item_value| self.parse_outfit_item(item_value).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// 解析主要色彩
|
||||
let primary_colors = value.get("primary_colors")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|color_value| self.parse_color_info(color_value).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// 解析场景推荐
|
||||
let scene_recommendations = value.get("scene_recommendations")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|scene_value| self.parse_scene_recommendation(scene_value).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let tiktok_tips = value.get("tiktok_tips")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let styling_tips = value.get("styling_tips")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(OutfitRecommendation {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
overall_style,
|
||||
style_tags,
|
||||
occasions,
|
||||
seasons,
|
||||
items,
|
||||
color_theme,
|
||||
primary_colors,
|
||||
scene_recommendations,
|
||||
tiktok_tips,
|
||||
styling_tips,
|
||||
created_at: chrono::Utc::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析穿搭单品
|
||||
fn parse_outfit_item(&self, value: &serde_json::Value) -> Result<OutfitItem> {
|
||||
let category = value.get("category")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("单品")
|
||||
.to_string();
|
||||
|
||||
let description = value.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("时尚单品")
|
||||
.to_string();
|
||||
|
||||
let material = value.get("material")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("优质面料")
|
||||
.to_string();
|
||||
|
||||
let style_tags = value.get("style_tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let primary_color = value.get("primary_color")
|
||||
.and_then(|v| self.parse_color_info(v).ok())
|
||||
.unwrap_or_else(|| ColorInfo {
|
||||
name: "经典色".to_string(),
|
||||
hex: "#000000".to_string(),
|
||||
hsv: (0.0, 0.0, 0.0),
|
||||
});
|
||||
|
||||
let secondary_color = value.get("secondary_color")
|
||||
.and_then(|v| self.parse_color_info(v).ok());
|
||||
|
||||
Ok(OutfitItem {
|
||||
category,
|
||||
description,
|
||||
primary_color,
|
||||
secondary_color,
|
||||
material,
|
||||
style_tags,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析色彩信息
|
||||
fn parse_color_info(&self, value: &serde_json::Value) -> Result<ColorInfo> {
|
||||
let name = value.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("颜色")
|
||||
.to_string();
|
||||
|
||||
let hex = value.get("hex")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("#000000")
|
||||
.to_string();
|
||||
|
||||
let hsv = value.get("hsv")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| {
|
||||
if arr.len() >= 3 {
|
||||
Some((
|
||||
arr[0].as_f64().unwrap_or(0.0) as f32,
|
||||
arr[1].as_f64().unwrap_or(0.0) as f32,
|
||||
arr[2].as_f64().unwrap_or(0.0) as f32,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or((0.0, 0.0, 0.0));
|
||||
|
||||
Ok(ColorInfo { name, hex, hsv })
|
||||
}
|
||||
|
||||
/// 解析场景推荐
|
||||
fn parse_scene_recommendation(&self, value: &serde_json::Value) -> Result<SceneRecommendation> {
|
||||
let name = value.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("推荐场景")
|
||||
.to_string();
|
||||
|
||||
let description = value.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("适合拍摄的场景")
|
||||
.to_string();
|
||||
|
||||
let scene_type = value.get("scene_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("室内")
|
||||
.to_string();
|
||||
|
||||
let lighting = value.get("lighting")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("自然光")
|
||||
.to_string();
|
||||
|
||||
let time_of_day = value.get("time_of_day")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_else(|| vec!["全天".to_string()]);
|
||||
|
||||
let photography_tips = value.get("photography_tips")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(SceneRecommendation {
|
||||
name,
|
||||
description,
|
||||
scene_type,
|
||||
time_of_day,
|
||||
lighting,
|
||||
photography_tips,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,7 @@ pub fn run() {
|
||||
commands::outfit_search_commands::get_supported_image_formats,
|
||||
commands::outfit_search_commands::get_default_search_config,
|
||||
commands::outfit_search_commands::get_outfit_search_config,
|
||||
commands::outfit_search_commands::generate_outfit_recommendations,
|
||||
// 相似度检索工具命令
|
||||
commands::similarity_search_commands::quick_similarity_search,
|
||||
commands::similarity_search_commands::get_similarity_search_suggestions,
|
||||
|
||||
@@ -7,6 +7,9 @@ use crate::data::models::outfit_search::{
|
||||
LLMQueryRequest, LLMQueryResponse, OutfitSearchGlobalConfig, ProductInfo, SearchFilterBuilder,
|
||||
SearchRequest, SearchResponse, SearchResult,
|
||||
};
|
||||
use crate::data::models::outfit_recommendation::{
|
||||
OutfitRecommendationRequest, OutfitRecommendationResponse,
|
||||
};
|
||||
use crate::infrastructure::gemini_service::{GeminiConfig, GeminiService};
|
||||
|
||||
/// 分析服装图像
|
||||
@@ -172,6 +175,32 @@ pub async fn get_supported_image_formats(
|
||||
])
|
||||
}
|
||||
|
||||
/// 生成穿搭方案推荐
|
||||
#[tauri::command]
|
||||
pub async fn generate_outfit_recommendations(
|
||||
_state: State<'_, AppState>,
|
||||
request: OutfitRecommendationRequest,
|
||||
) -> Result<OutfitRecommendationResponse, String> {
|
||||
println!("🎨 收到穿搭方案生成请求: {:?}", request);
|
||||
|
||||
// 创建Gemini服务
|
||||
let config = GeminiConfig::default();
|
||||
let mut gemini_service = GeminiService::new(Some(config))
|
||||
.map_err(|e| format!("Failed to create GeminiService: {}", e))?;
|
||||
|
||||
// 生成穿搭方案
|
||||
let response = gemini_service
|
||||
.generate_outfit_recommendations(&request)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("Failed to generate outfit recommendations: {}", e);
|
||||
format!("穿搭方案生成失败: {}", e)
|
||||
})?;
|
||||
|
||||
println!("✅ 穿搭方案生成成功,共生成 {} 个方案", response.recommendations.len());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// 获取默认搜索配置
|
||||
#[tauri::command]
|
||||
pub async fn get_default_search_config(
|
||||
|
||||
292
apps/desktop/src/components/outfit/OutfitRecommendationCard.tsx
Normal file
292
apps/desktop/src/components/outfit/OutfitRecommendationCard.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
MapPin,
|
||||
Clock,
|
||||
Palette,
|
||||
Tag,
|
||||
Camera,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
Sun,
|
||||
Moon,
|
||||
Sunrise,
|
||||
Sunset,
|
||||
} from 'lucide-react';
|
||||
import { OutfitRecommendationCardProps } from '../../types/outfitRecommendation';
|
||||
|
||||
/**
|
||||
* 穿搭方案推荐卡片组件
|
||||
* 遵循设计系统规范,提供统一的方案展示界面
|
||||
*/
|
||||
export const OutfitRecommendationCard: React.FC<OutfitRecommendationCardProps> = ({
|
||||
recommendation,
|
||||
onSelect,
|
||||
onSceneSearch,
|
||||
showDetails = true,
|
||||
compact = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// 处理卡片点击
|
||||
const handleCardClick = useCallback((e: React.MouseEvent) => {
|
||||
// 如果点击的是按钮或其子元素,不触发卡片选择
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onSelect) {
|
||||
onSelect(recommendation);
|
||||
}
|
||||
}, [recommendation, onSelect]);
|
||||
|
||||
// 处理场景检索
|
||||
const handleSceneSearch = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onSceneSearch) {
|
||||
onSceneSearch(recommendation);
|
||||
}
|
||||
}, [recommendation, onSceneSearch]);
|
||||
|
||||
// 处理展开/收起
|
||||
const handleToggleExpand = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}, [isExpanded]);
|
||||
|
||||
// 获取时间段图标
|
||||
const getTimeIcon = (timeOfDay: string) => {
|
||||
switch (timeOfDay.toLowerCase()) {
|
||||
case '早晨':
|
||||
case '上午':
|
||||
return <Sunrise className="w-3 h-3" />;
|
||||
case '中午':
|
||||
case '下午':
|
||||
return <Sun className="w-3 h-3" />;
|
||||
case '傍晚':
|
||||
case '黄昏':
|
||||
return <Sunset className="w-3 h-3" />;
|
||||
case '晚上':
|
||||
case '夜晚':
|
||||
return <Moon className="w-3 h-3" />;
|
||||
default:
|
||||
return <Clock className="w-3 h-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
${compact ? 'p-4' : 'p-6'}
|
||||
${className}
|
||||
`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* 装饰性背景 */}
|
||||
<div className="absolute top-0 right-0 w-24 h-24 bg-gradient-to-br from-primary-100 to-primary-200 rounded-full -translate-y-12 translate-x-12 opacity-40 group-hover:opacity-60 transition-all duration-500 group-hover:scale-110"></div>
|
||||
|
||||
{/* 顶部标识 */}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-primary-100 text-primary-700 rounded-full text-xs font-medium">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
AI推荐
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* 标题和描述 */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-high-emphasis mb-2 group-hover:text-primary-600 transition-colors duration-200">
|
||||
{recommendation.title}
|
||||
</h3>
|
||||
<p className="text-sm text-medium-emphasis line-clamp-2">
|
||||
{recommendation.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 风格标签 */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-700 rounded-full text-xs font-medium">
|
||||
<Tag className="w-3 h-3" />
|
||||
{recommendation.overall_style}
|
||||
</div>
|
||||
{recommendation.style_tags.slice(0, 2).map((tag, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-xs"
|
||||
>
|
||||
{tag}
|
||||
</div>
|
||||
))}
|
||||
{recommendation.style_tags.length > 2 && (
|
||||
<div className="px-2 py-1 bg-gray-100 text-gray-500 rounded-full text-xs">
|
||||
+{recommendation.style_tags.length - 2}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 主要色彩 */}
|
||||
{recommendation.primary_colors.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Palette className="w-4 h-4 text-gray-500" />
|
||||
<div className="flex items-center gap-2">
|
||||
{recommendation.primary_colors.slice(0, 4).map((color, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-6 h-6 rounded-full border-2 border-white shadow-sm"
|
||||
style={{ backgroundColor: color.hex }}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
{recommendation.primary_colors.length > 4 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
+{recommendation.primary_colors.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 适合场合和季节 */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4 text-xs text-medium-emphasis">
|
||||
<div>
|
||||
<span className="font-medium">适合场合:</span>
|
||||
<div className="mt-1">
|
||||
{recommendation.occasions.slice(0, 2).join('、')}
|
||||
{recommendation.occasions.length > 2 && '...'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">适合季节:</span>
|
||||
<div className="mt-1">
|
||||
{recommendation.seasons.join('、')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详细信息 (可展开) */}
|
||||
{showDetails && (
|
||||
<>
|
||||
{/* 展开/收起按钮 */}
|
||||
<button
|
||||
onClick={handleToggleExpand}
|
||||
className="flex items-center gap-2 w-full text-left text-sm text-primary-600 hover:text-primary-700 mb-3 transition-colors duration-200"
|
||||
>
|
||||
<span>{isExpanded ? '收起详情' : '查看详情'}</span>
|
||||
<ChevronRight
|
||||
className={`w-4 h-4 transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 展开的详细内容 */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* 穿搭单品 */}
|
||||
{recommendation.items.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-high-emphasis mb-2">穿搭单品</h4>
|
||||
<div className="space-y-2">
|
||||
{recommendation.items.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-2 bg-gray-50 rounded-lg">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border border-gray-300"
|
||||
style={{ backgroundColor: item.primary_color.hex }}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{item.category}</div>
|
||||
<div className="text-xs text-gray-600">{item.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 场景推荐 */}
|
||||
{recommendation.scene_recommendations.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-high-emphasis mb-2">推荐场景</h4>
|
||||
<div className="space-y-2">
|
||||
{recommendation.scene_recommendations.slice(0, 2).map((scene, index) => (
|
||||
<div key={index} className="p-2 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<MapPin className="w-3 h-3 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">{scene.name}</span>
|
||||
<span className="text-xs text-blue-600 bg-blue-100 px-2 py-0.5 rounded">
|
||||
{scene.scene_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 mb-2">{scene.description}</p>
|
||||
{scene.time_of_day.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{scene.time_of_day.slice(0, 3).map((time, timeIndex) => (
|
||||
<div
|
||||
key={timeIndex}
|
||||
className="flex items-center gap-1 text-xs text-blue-600"
|
||||
>
|
||||
{getTimeIcon(time)}
|
||||
<span>{time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TikTok优化建议 */}
|
||||
{recommendation.tiktok_tips.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-high-emphasis mb-2 flex items-center gap-2">
|
||||
<Camera className="w-4 h-4" />
|
||||
TikTok优化建议
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{recommendation.tiktok_tips.slice(0, 3).map((tip, index) => (
|
||||
<div key={index} className="text-xs text-gray-600 flex items-start gap-2">
|
||||
<div className="w-1 h-1 bg-primary-500 rounded-full mt-2 flex-shrink-0" />
|
||||
<span>{tip}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 底部操作按钮 */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutfitRecommendationCard;
|
||||
211
apps/desktop/src/components/outfit/OutfitRecommendationList.tsx
Normal file
211
apps/desktop/src/components/outfit/OutfitRecommendationList.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
ShoppingBag,
|
||||
} from 'lucide-react';
|
||||
import { OutfitRecommendationListProps } from '../../types/outfitRecommendation';
|
||||
import OutfitRecommendationCard from './OutfitRecommendationCard';
|
||||
import { EmptyState } from '../EmptyState';
|
||||
|
||||
/**
|
||||
* 穿搭方案推荐列表组件
|
||||
* 遵循设计系统规范,提供统一的列表展示界面
|
||||
*/
|
||||
export const OutfitRecommendationList: React.FC<OutfitRecommendationListProps> = ({
|
||||
recommendations,
|
||||
isLoading = false,
|
||||
error,
|
||||
onRecommendationSelect,
|
||||
onSceneSearch,
|
||||
onRegenerate,
|
||||
className = '',
|
||||
}) => {
|
||||
// 加载状态
|
||||
if (isLoading) {
|
||||
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">
|
||||
AI正在生成穿搭方案...
|
||||
</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].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="card p-6 animate-pulse"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 标题骨架 */}
|
||||
<div className="h-6 bg-gray-200 rounded-lg w-3/4"></div>
|
||||
|
||||
{/* 描述骨架 */}
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded w-full"></div>
|
||||
<div className="h-4 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 className="flex gap-2">
|
||||
{[1, 2, 3, 4].map((colorIndex) => (
|
||||
<div
|
||||
key={colorIndex}
|
||||
className="w-6 h-6 bg-gray-200 rounded-full"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 按钮骨架 */}
|
||||
<div className="flex justify-between items-center pt-4 border-t border-gray-100">
|
||||
<div className="h-4 bg-gray-200 rounded w-20"></div>
|
||||
<div className="h-8 bg-gray-200 rounded-lg w-24"></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={onRegenerate}
|
||||
illustration="error"
|
||||
size="md"
|
||||
className="py-12"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 空状态
|
||||
if (!recommendations || recommendations.length === 0) {
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<EmptyState
|
||||
variant="default"
|
||||
icon={<ShoppingBag className="w-12 h-12 text-gray-400" />}
|
||||
title="暂无穿搭方案"
|
||||
description="点击上方的 ✨ 图标开始生成个性化穿搭推荐"
|
||||
actionText="开始生成"
|
||||
onAction={onRegenerate}
|
||||
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">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-high-emphasis">
|
||||
AI穿搭推荐
|
||||
</h3>
|
||||
<p className="text-sm text-medium-emphasis">
|
||||
共为您生成 {recommendations.length} 个个性化穿搭方案
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onRegenerate && (
|
||||
<button
|
||||
onClick={onRegenerate}
|
||||
className="flex items-center gap-2 px-4 py-2 text-primary-600 hover:text-primary-700 hover:bg-primary-50 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">重新生成</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 推荐卡片网格 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{recommendations.map((recommendation, index) => (
|
||||
<OutfitRecommendationCard
|
||||
key={recommendation.id || index}
|
||||
recommendation={recommendation}
|
||||
onSelect={onRecommendationSelect}
|
||||
onSceneSearch={onSceneSearch}
|
||||
showDetails={true}
|
||||
compact={false}
|
||||
className="animate-fade-in-up"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<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">
|
||||
<Sparkles 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>
|
||||
每个方案都包含TikTok优化建议,助力内容创作
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutfitRecommendationList;
|
||||
142
apps/desktop/src/components/outfit/OutfitRecommendationModal.tsx
Normal file
142
apps/desktop/src/components/outfit/OutfitRecommendationModal.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { X, Sparkles } from 'lucide-react';
|
||||
import { OutfitRecommendation } from '../../types/outfitRecommendation';
|
||||
import OutfitRecommendationList from './OutfitRecommendationList';
|
||||
|
||||
interface OutfitRecommendationModalProps {
|
||||
/** 是否显示模态框 */
|
||||
isOpen: boolean;
|
||||
/** 关闭模态框回调 */
|
||||
onClose: () => void;
|
||||
/** 穿搭方案列表 */
|
||||
recommendations: OutfitRecommendation[];
|
||||
/** 是否正在加载 */
|
||||
isLoading?: boolean;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
/** 方案选择回调 */
|
||||
onRecommendationSelect?: (recommendation: OutfitRecommendation) => void;
|
||||
/** 场景检索回调 */
|
||||
onSceneSearch?: (recommendation: OutfitRecommendation) => void;
|
||||
/** 重新生成回调 */
|
||||
onRegenerate?: () => void;
|
||||
/** 生成查询 */
|
||||
query?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 穿搭方案推荐模态框组件
|
||||
* 遵循设计系统规范,提供统一的模态框展示界面
|
||||
*/
|
||||
export const OutfitRecommendationModal: React.FC<OutfitRecommendationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
recommendations,
|
||||
isLoading = false,
|
||||
error,
|
||||
onRecommendationSelect,
|
||||
onSceneSearch,
|
||||
onRegenerate,
|
||||
query = '',
|
||||
}) => {
|
||||
// 处理背景点击关闭
|
||||
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
// 处理ESC键关闭
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm animate-fade-in"
|
||||
onClick={handleBackdropClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="relative w-full max-w-7xl max-h-[90vh] mx-4 bg-white rounded-2xl shadow-2xl animate-scale-in overflow-hidden">
|
||||
{/* 模态框头部 */}
|
||||
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="icon-container primary w-10 h-10">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-high-emphasis">
|
||||
AI穿搭方案推荐
|
||||
</h2>
|
||||
<p className="text-sm text-medium-emphasis">
|
||||
{query ? `基于"${query}"的个性化穿搭推荐` : '个性化穿搭推荐'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors duration-200"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模态框内容 */}
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-80px)]">
|
||||
<div className="p-6">
|
||||
<OutfitRecommendationList
|
||||
recommendations={recommendations}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onRecommendationSelect={onRecommendationSelect}
|
||||
onSceneSearch={onSceneSearch}
|
||||
onRegenerate={onRegenerate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模态框底部 (可选) */}
|
||||
{!isLoading && !error && recommendations.length > 0 && (
|
||||
<div className="sticky bottom-0 bg-gradient-to-t from-white to-transparent px-6 py-4 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-medium-emphasis">
|
||||
共 {recommendations.length} 个推荐方案
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{onRegenerate && (
|
||||
<button
|
||||
onClick={onRegenerate}
|
||||
className="btn btn-outline btn-sm"
|
||||
>
|
||||
重新生成
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutfitRecommendationModal;
|
||||
@@ -24,6 +24,7 @@ export const SimilaritySearchPanel: React.FC<SimilaritySearchPanelProps> = ({
|
||||
onSearch,
|
||||
onSuggestionSelect,
|
||||
onSuggestionsToggle,
|
||||
onOutfitRecommendation,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -41,6 +42,16 @@ export const SimilaritySearchPanel: React.FC<SimilaritySearchPanelProps> = ({
|
||||
onSearch(request);
|
||||
}, [query, selectedThreshold, onSearch]);
|
||||
|
||||
// 处理穿搭方案生成
|
||||
const handleOutfitRecommendation = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (onOutfitRecommendation) {
|
||||
onOutfitRecommendation();
|
||||
}
|
||||
}, [onOutfitRecommendation]);
|
||||
|
||||
// 处理回车键搜索
|
||||
const handleKeyPress = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -105,9 +116,15 @@ export const SimilaritySearchPanel: React.FC<SimilaritySearchPanelProps> = ({
|
||||
className="form-input pr-12 group-hover:border-primary-300 focus:border-primary-500 transition-colors duration-200"
|
||||
disabled={isSearching}
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 group-hover:text-primary-500 transition-colors duration-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOutfitRecommendation}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-primary-500 transition-colors duration-200 p-1 rounded-full hover:bg-primary-50"
|
||||
title="生成AI穿搭方案推荐"
|
||||
disabled={isSearching}
|
||||
>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
import { SimilaritySearchPanel } from '../../components/similarity/SimilaritySearchPanel';
|
||||
import SimilaritySearchResults from '../../components/similarity/SimilaritySearchResults';
|
||||
import { SimilaritySearchRequest } from '../../types/similaritySearch';
|
||||
import OutfitRecommendationModal from '../../components/outfit/OutfitRecommendationModal';
|
||||
import OutfitRecommendationService from '../../services/outfitRecommendationService';
|
||||
import { OutfitRecommendation } from '../../types/outfitRecommendation';
|
||||
|
||||
/**
|
||||
* 相似度检索工具页面
|
||||
@@ -31,6 +34,12 @@ const SimilaritySearchTool: React.FC = () => {
|
||||
const [selectedResult, setSelectedResult] = useState<any>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
|
||||
// 穿搭方案推荐状态
|
||||
const [showOutfitModal, setShowOutfitModal] = useState(false);
|
||||
const [outfitRecommendations, setOutfitRecommendations] = useState<OutfitRecommendation[]>([]);
|
||||
const [isGeneratingOutfits, setIsGeneratingOutfits] = useState(false);
|
||||
const [outfitError, setOutfitError] = useState<string | null>(null);
|
||||
|
||||
// 状态管理
|
||||
const {
|
||||
query,
|
||||
@@ -142,6 +151,83 @@ const SimilaritySearchTool: React.FC = () => {
|
||||
navigate('/tools');
|
||||
}, [navigate]);
|
||||
|
||||
// 处理穿搭方案生成
|
||||
const handleOutfitRecommendation = useCallback(async () => {
|
||||
if (!query.trim()) {
|
||||
setOutfitError('请先输入搜索关键词');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingOutfits(true);
|
||||
setOutfitError(null);
|
||||
setShowOutfitModal(true);
|
||||
|
||||
try {
|
||||
const response = await OutfitRecommendationService.quickGenerate(query.trim(), 3);
|
||||
setOutfitRecommendations(response.recommendations);
|
||||
} catch (error) {
|
||||
console.error('穿搭方案生成失败:', error);
|
||||
setOutfitError(error instanceof Error ? error.message : '穿搭方案生成失败');
|
||||
} finally {
|
||||
setIsGeneratingOutfits(false);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
// 处理穿搭方案重新生成
|
||||
const handleOutfitRegenerate = useCallback(async () => {
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingOutfits(true);
|
||||
setOutfitError(null);
|
||||
|
||||
try {
|
||||
const response = await OutfitRecommendationService.quickGenerate(query.trim(), 3);
|
||||
setOutfitRecommendations(response.recommendations);
|
||||
} catch (error) {
|
||||
console.error('穿搭方案重新生成失败:', error);
|
||||
setOutfitError(error instanceof Error ? error.message : '穿搭方案生成失败');
|
||||
} finally {
|
||||
setIsGeneratingOutfits(false);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
// 处理穿搭方案选择
|
||||
const handleOutfitSelect = useCallback((recommendation: OutfitRecommendation) => {
|
||||
console.log('选择穿搭方案:', recommendation);
|
||||
// 这里可以添加更多的处理逻辑,比如显示详情等
|
||||
}, []);
|
||||
|
||||
// 处理场景检索
|
||||
const handleSceneSearch = useCallback((recommendation: OutfitRecommendation) => {
|
||||
console.log('场景检索:', recommendation);
|
||||
|
||||
// 生成场景检索请求
|
||||
const sceneSearchRequest = OutfitRecommendationService.generateSceneSearchRequest(recommendation);
|
||||
|
||||
// 将场景描述设置为搜索查询
|
||||
setQuery(sceneSearchRequest.scene_description);
|
||||
|
||||
// 关闭穿搭方案模态框
|
||||
setShowOutfitModal(false);
|
||||
|
||||
// 执行搜索
|
||||
const searchRequest: SimilaritySearchRequest = {
|
||||
query: sceneSearchRequest.scene_description,
|
||||
relevance_threshold: selectedThreshold,
|
||||
page_size: configState.config?.max_results_per_page || 12,
|
||||
page_offset: 0,
|
||||
};
|
||||
|
||||
executeSearch(searchRequest);
|
||||
}, [selectedThreshold, configState.config?.max_results_per_page, executeSearch, setQuery]);
|
||||
|
||||
// 关闭穿搭方案模态框
|
||||
const handleCloseOutfitModal = useCallback(() => {
|
||||
setShowOutfitModal(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50">
|
||||
{/* 页面头部 */}
|
||||
@@ -212,6 +298,7 @@ const SimilaritySearchTool: React.FC = () => {
|
||||
onSearch={handleSearch}
|
||||
onSuggestionSelect={handleSuggestionSelect}
|
||||
onSuggestionsToggle={setShowSuggestions}
|
||||
onOutfitRecommendation={handleOutfitRecommendation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -400,6 +487,19 @@ const SimilaritySearchTool: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 穿搭方案推荐模态框 */}
|
||||
<OutfitRecommendationModal
|
||||
isOpen={showOutfitModal}
|
||||
onClose={handleCloseOutfitModal}
|
||||
recommendations={outfitRecommendations}
|
||||
isLoading={isGeneratingOutfits}
|
||||
error={outfitError || undefined}
|
||||
onRecommendationSelect={handleOutfitSelect}
|
||||
onSceneSearch={handleSceneSearch}
|
||||
onRegenerate={handleOutfitRegenerate}
|
||||
query={query}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
278
apps/desktop/src/services/outfitRecommendationService.ts
Normal file
278
apps/desktop/src/services/outfitRecommendationService.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 穿搭方案推荐服务
|
||||
* 遵循 Tauri 开发规范的服务层设计
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
OutfitRecommendationRequest,
|
||||
OutfitRecommendationResponse,
|
||||
OutfitRecommendation,
|
||||
SceneSearchRequest,
|
||||
DEFAULT_OUTFIT_RECOMMENDATION_REQUEST,
|
||||
} from '../types/outfitRecommendation';
|
||||
|
||||
export class OutfitRecommendationService {
|
||||
/**
|
||||
* 生成穿搭方案推荐
|
||||
*/
|
||||
static async generateRecommendations(
|
||||
request: Partial<OutfitRecommendationRequest>
|
||||
): Promise<OutfitRecommendationResponse> {
|
||||
try {
|
||||
const fullRequest: OutfitRecommendationRequest = {
|
||||
...DEFAULT_OUTFIT_RECOMMENDATION_REQUEST,
|
||||
...request,
|
||||
} as OutfitRecommendationRequest;
|
||||
|
||||
console.log('🎨 发送穿搭方案生成请求:', fullRequest);
|
||||
|
||||
const response = await invoke<OutfitRecommendationResponse>(
|
||||
'generate_outfit_recommendations',
|
||||
{ request: fullRequest }
|
||||
);
|
||||
|
||||
console.log('✅ 穿搭方案生成成功:', response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('❌ 穿搭方案生成失败:', error);
|
||||
throw new Error(`穿搭方案生成失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于查询快速生成推荐
|
||||
*/
|
||||
static async quickGenerate(
|
||||
query: string,
|
||||
count: number = 3
|
||||
): Promise<OutfitRecommendationResponse> {
|
||||
return this.generateRecommendations({
|
||||
query,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于风格生成推荐
|
||||
*/
|
||||
static async generateByStyle(
|
||||
query: string,
|
||||
targetStyle: string,
|
||||
count: number = 3
|
||||
): Promise<OutfitRecommendationResponse> {
|
||||
return this.generateRecommendations({
|
||||
query,
|
||||
target_style: targetStyle,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于场合生成推荐
|
||||
*/
|
||||
static async generateByOccasion(
|
||||
query: string,
|
||||
occasions: string[],
|
||||
count: number = 3
|
||||
): Promise<OutfitRecommendationResponse> {
|
||||
return this.generateRecommendations({
|
||||
query,
|
||||
occasions,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于季节生成推荐
|
||||
*/
|
||||
static async generateBySeason(
|
||||
query: string,
|
||||
season: string,
|
||||
count: number = 3
|
||||
): Promise<OutfitRecommendationResponse> {
|
||||
return this.generateRecommendations({
|
||||
query,
|
||||
season,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于色彩偏好生成推荐
|
||||
*/
|
||||
static async generateByColors(
|
||||
query: string,
|
||||
colorPreferences: string[],
|
||||
count: number = 3
|
||||
): Promise<OutfitRecommendationResponse> {
|
||||
return this.generateRecommendations({
|
||||
query,
|
||||
color_preferences: colorPreferences,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成场景检索请求
|
||||
* 用于将穿搭方案转换为场景检索参数
|
||||
*/
|
||||
static generateSceneSearchRequest(
|
||||
recommendation: OutfitRecommendation,
|
||||
searchParams?: Partial<SceneSearchRequest['search_params']>
|
||||
): SceneSearchRequest {
|
||||
// 生成场景描述
|
||||
const sceneDescription = this.generateSceneDescription(recommendation);
|
||||
|
||||
// 获取所有风格标签
|
||||
const styleTags = this.getAllStyleTags(recommendation);
|
||||
|
||||
return {
|
||||
outfit_id: recommendation.id,
|
||||
scene_description: sceneDescription,
|
||||
style_tags: styleTags,
|
||||
colors: recommendation.primary_colors,
|
||||
search_params: {
|
||||
scene_types: recommendation.scene_recommendations.map(s => s.scene_type),
|
||||
time_filters: recommendation.scene_recommendations.flatMap(s => s.time_of_day),
|
||||
lighting_filters: recommendation.scene_recommendations.map(s => s.lighting),
|
||||
limit: 20,
|
||||
...searchParams,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成场景描述
|
||||
*/
|
||||
private static generateSceneDescription(recommendation: OutfitRecommendation): string {
|
||||
let description = `${recommendation.title} - ${recommendation.description}`;
|
||||
|
||||
if (recommendation.style_tags.length > 0) {
|
||||
description += ` 风格: ${recommendation.style_tags.join(', ')}`;
|
||||
}
|
||||
|
||||
if (recommendation.primary_colors.length > 0) {
|
||||
const colorNames = recommendation.primary_colors.map(c => c.name);
|
||||
description += ` 主要色彩: ${colorNames.join(', ')}`;
|
||||
}
|
||||
|
||||
if (recommendation.occasions.length > 0) {
|
||||
description += ` 适合场合: ${recommendation.occasions.join(', ')}`;
|
||||
}
|
||||
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有风格标签
|
||||
*/
|
||||
private static getAllStyleTags(recommendation: OutfitRecommendation): string[] {
|
||||
const allTags = [...recommendation.style_tags];
|
||||
|
||||
// 添加整体风格
|
||||
if (recommendation.overall_style) {
|
||||
allTags.push(recommendation.overall_style);
|
||||
}
|
||||
|
||||
// 添加单品风格标签
|
||||
recommendation.items.forEach(item => {
|
||||
allTags.push(...item.style_tags);
|
||||
});
|
||||
|
||||
// 去重并排序
|
||||
return [...new Set(allTags)].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化色彩信息
|
||||
*/
|
||||
static formatColorInfo(color: { name: string; hex: string }): string {
|
||||
return `${color.name} (${color.hex})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查方案是否适合指定季节
|
||||
*/
|
||||
static isSeasonAppropriate(recommendation: OutfitRecommendation, season: string): boolean {
|
||||
return recommendation.seasons.includes(season);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查方案是否适合指定场合
|
||||
*/
|
||||
static isOccasionAppropriate(recommendation: OutfitRecommendation, occasion: string): boolean {
|
||||
return recommendation.occasions.includes(occasion);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推荐的主要色彩名称
|
||||
*/
|
||||
static getPrimaryColorNames(recommendation: OutfitRecommendation): string[] {
|
||||
return recommendation.primary_colors.map(color => color.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推荐的TikTok优化建议摘要
|
||||
*/
|
||||
static getTikTokTipsSummary(recommendation: OutfitRecommendation): string {
|
||||
if (recommendation.tiktok_tips.length === 0) {
|
||||
return '暂无TikTok优化建议';
|
||||
}
|
||||
|
||||
if (recommendation.tiktok_tips.length === 1) {
|
||||
return recommendation.tiktok_tips[0];
|
||||
}
|
||||
|
||||
return `${recommendation.tiktok_tips[0]}等${recommendation.tiktok_tips.length}条建议`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推荐的场景类型摘要
|
||||
*/
|
||||
static getSceneTypesSummary(recommendation: OutfitRecommendation): string {
|
||||
if (recommendation.scene_recommendations.length === 0) {
|
||||
return '暂无场景推荐';
|
||||
}
|
||||
|
||||
const sceneTypes = [...new Set(recommendation.scene_recommendations.map(s => s.scene_type))];
|
||||
return sceneTypes.join('、');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证推荐数据的完整性
|
||||
*/
|
||||
static validateRecommendation(recommendation: OutfitRecommendation): {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!recommendation.id) {
|
||||
errors.push('缺少方案ID');
|
||||
}
|
||||
|
||||
if (!recommendation.title) {
|
||||
errors.push('缺少方案标题');
|
||||
}
|
||||
|
||||
if (!recommendation.description) {
|
||||
errors.push('缺少方案描述');
|
||||
}
|
||||
|
||||
if (recommendation.items.length === 0) {
|
||||
errors.push('缺少穿搭单品');
|
||||
}
|
||||
|
||||
if (recommendation.primary_colors.length === 0) {
|
||||
errors.push('缺少主要色彩信息');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default OutfitRecommendationService;
|
||||
297
apps/desktop/src/types/outfitRecommendation.ts
Normal file
297
apps/desktop/src/types/outfitRecommendation.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 穿搭方案推荐相关类型定义
|
||||
* 遵循 Tauri 开发规范的类型安全设计
|
||||
*/
|
||||
|
||||
// 色彩信息
|
||||
export interface ColorInfo {
|
||||
/** 色彩名称 */
|
||||
name: string;
|
||||
/** 十六进制色值 */
|
||||
hex: string;
|
||||
/** HSV值 (0-1范围) */
|
||||
hsv: [number, number, number];
|
||||
}
|
||||
|
||||
// 穿搭单品
|
||||
export interface OutfitItem {
|
||||
/** 单品类别 (如: "上装", "下装", "鞋子", "配饰") */
|
||||
category: string;
|
||||
/** 单品描述 */
|
||||
description: string;
|
||||
/** 主要颜色 */
|
||||
primary_color: ColorInfo;
|
||||
/** 次要颜色 (可选) */
|
||||
secondary_color?: ColorInfo;
|
||||
/** 材质 */
|
||||
material: string;
|
||||
/** 风格标签 */
|
||||
style_tags: string[];
|
||||
}
|
||||
|
||||
// 场景建议
|
||||
export interface SceneRecommendation {
|
||||
/** 场景名称 */
|
||||
name: string;
|
||||
/** 场景描述 */
|
||||
description: string;
|
||||
/** 场景类型 ("室内", "室外", "特殊") */
|
||||
scene_type: string;
|
||||
/** 适合的时间段 */
|
||||
time_of_day: string[];
|
||||
/** 光线条件 */
|
||||
lighting: string;
|
||||
/** 拍摄建议 */
|
||||
photography_tips: string[];
|
||||
}
|
||||
|
||||
// 穿搭方案
|
||||
export interface OutfitRecommendation {
|
||||
/** 方案ID */
|
||||
id: string;
|
||||
/** 方案标题 */
|
||||
title: string;
|
||||
/** 方案描述 */
|
||||
description: string;
|
||||
/** 整体风格 */
|
||||
overall_style: string;
|
||||
/** 风格标签 */
|
||||
style_tags: string[];
|
||||
/** 适合场合 */
|
||||
occasions: string[];
|
||||
/** 适合季节 */
|
||||
seasons: string[];
|
||||
/** 穿搭单品列表 */
|
||||
items: OutfitItem[];
|
||||
/** 色彩搭配主题 */
|
||||
color_theme: string;
|
||||
/** 主要色彩 */
|
||||
primary_colors: ColorInfo[];
|
||||
/** 场景建议 */
|
||||
scene_recommendations: SceneRecommendation[];
|
||||
/** TikTok优化建议 */
|
||||
tiktok_tips: string[];
|
||||
/** 搭配要点 */
|
||||
styling_tips: string[];
|
||||
/** 创建时间 */
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 穿搭方案生成请求
|
||||
export interface OutfitRecommendationRequest {
|
||||
/** 用户输入的关键词或描述 */
|
||||
query: string;
|
||||
/** 目标风格 (可选) */
|
||||
target_style?: string;
|
||||
/** 适合场合 (可选) */
|
||||
occasions?: string[];
|
||||
/** 季节偏好 (可选) */
|
||||
season?: string;
|
||||
/** 色彩偏好 (可选) */
|
||||
color_preferences?: string[];
|
||||
/** 生成数量 */
|
||||
count: number;
|
||||
}
|
||||
|
||||
// 穿搭方案生成响应
|
||||
export interface OutfitRecommendationResponse {
|
||||
/** 生成的穿搭方案列表 */
|
||||
recommendations: OutfitRecommendation[];
|
||||
/** 生成时间 (毫秒) */
|
||||
generation_time_ms: number;
|
||||
/** 生成时间戳 */
|
||||
generated_at: string;
|
||||
/** 使用的提示词 (调试用) */
|
||||
prompt_used?: string;
|
||||
}
|
||||
|
||||
// 场景检索请求 (用于方案详情到场景检索的集成)
|
||||
export interface SceneSearchRequest {
|
||||
/** 穿搭方案ID */
|
||||
outfit_id: string;
|
||||
/** 场景描述 */
|
||||
scene_description: string;
|
||||
/** 风格标签 */
|
||||
style_tags: string[];
|
||||
/** 色彩信息 */
|
||||
colors: ColorInfo[];
|
||||
/** 搜索参数 */
|
||||
search_params: SceneSearchParams;
|
||||
}
|
||||
|
||||
// 场景检索参数
|
||||
export interface SceneSearchParams {
|
||||
/** 场景类型过滤 */
|
||||
scene_types?: string[];
|
||||
/** 时间段过滤 */
|
||||
time_filters?: string[];
|
||||
/** 光线条件过滤 */
|
||||
lighting_filters?: string[];
|
||||
/** 结果数量限制 */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// 穿搭方案卡片组件属性
|
||||
export interface OutfitRecommendationCardProps {
|
||||
/** 穿搭方案数据 */
|
||||
recommendation: OutfitRecommendation;
|
||||
/** 点击事件处理 */
|
||||
onSelect?: (recommendation: OutfitRecommendation) => void;
|
||||
/** 场景检索事件处理 */
|
||||
onSceneSearch?: (recommendation: OutfitRecommendation) => void;
|
||||
/** 是否显示详细信息 */
|
||||
showDetails?: boolean;
|
||||
/** 是否紧凑模式 */
|
||||
compact?: boolean;
|
||||
/** 自定义样式类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 穿搭方案列表组件属性
|
||||
export interface OutfitRecommendationListProps {
|
||||
/** 穿搭方案列表 */
|
||||
recommendations: OutfitRecommendation[];
|
||||
/** 是否正在加载 */
|
||||
isLoading?: boolean;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
/** 卡片选择事件 */
|
||||
onRecommendationSelect?: (recommendation: OutfitRecommendation) => void;
|
||||
/** 场景检索事件 */
|
||||
onSceneSearch?: (recommendation: OutfitRecommendation) => void;
|
||||
/** 重新生成事件 */
|
||||
onRegenerate?: () => void;
|
||||
/** 自定义样式类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 穿搭方案生成状态
|
||||
export interface OutfitRecommendationState {
|
||||
/** 当前查询 */
|
||||
query: string;
|
||||
/** 生成的方案列表 */
|
||||
recommendations: OutfitRecommendation[];
|
||||
/** 是否正在生成 */
|
||||
isGenerating: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 生成历史 */
|
||||
history: OutfitRecommendationResponse[];
|
||||
}
|
||||
|
||||
// 默认请求参数
|
||||
export const DEFAULT_OUTFIT_RECOMMENDATION_REQUEST: Partial<OutfitRecommendationRequest> = {
|
||||
count: 3,
|
||||
target_style: undefined,
|
||||
occasions: undefined,
|
||||
season: undefined,
|
||||
color_preferences: undefined,
|
||||
};
|
||||
|
||||
// 常用风格选项
|
||||
export const STYLE_OPTIONS = [
|
||||
'休闲',
|
||||
'正式',
|
||||
'运动',
|
||||
'街头',
|
||||
'简约',
|
||||
'复古',
|
||||
'甜美',
|
||||
'酷帅',
|
||||
'文艺',
|
||||
'优雅',
|
||||
] as const;
|
||||
|
||||
// 常用场合选项
|
||||
export const OCCASION_OPTIONS = [
|
||||
'日常',
|
||||
'工作',
|
||||
'约会',
|
||||
'聚会',
|
||||
'旅行',
|
||||
'运动',
|
||||
'正式场合',
|
||||
'休闲娱乐',
|
||||
] as const;
|
||||
|
||||
// 季节选项
|
||||
export const SEASON_OPTIONS = [
|
||||
'春季',
|
||||
'夏季',
|
||||
'秋季',
|
||||
'冬季',
|
||||
] as const;
|
||||
|
||||
// 色彩偏好选项
|
||||
export const COLOR_PREFERENCE_OPTIONS = [
|
||||
'暖色调',
|
||||
'冷色调',
|
||||
'中性色',
|
||||
'亮色',
|
||||
'深色',
|
||||
'浅色',
|
||||
'单色',
|
||||
'撞色',
|
||||
] as const;
|
||||
|
||||
// 工具函数
|
||||
export const OutfitRecommendationUtils = {
|
||||
/**
|
||||
* 生成用于场景检索的描述
|
||||
*/
|
||||
generateSceneSearchDescription(recommendation: OutfitRecommendation): string {
|
||||
let description = `${recommendation.title} - ${recommendation.description}`;
|
||||
|
||||
if (recommendation.style_tags.length > 0) {
|
||||
description += ` 风格: ${recommendation.style_tags.join(', ')}`;
|
||||
}
|
||||
|
||||
if (recommendation.primary_colors.length > 0) {
|
||||
const colorNames = recommendation.primary_colors.map(c => c.name);
|
||||
description += ` 主要色彩: ${colorNames.join(', ')}`;
|
||||
}
|
||||
|
||||
return description;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取所有风格标签
|
||||
*/
|
||||
getAllStyleTags(recommendation: OutfitRecommendation): string[] {
|
||||
const allTags = [...recommendation.style_tags];
|
||||
|
||||
// 添加整体风格
|
||||
if (recommendation.overall_style) {
|
||||
allTags.push(recommendation.overall_style);
|
||||
}
|
||||
|
||||
// 添加单品风格标签
|
||||
recommendation.items.forEach(item => {
|
||||
allTags.push(...item.style_tags);
|
||||
});
|
||||
|
||||
// 去重并排序
|
||||
return [...new Set(allTags)].sort();
|
||||
},
|
||||
|
||||
/**
|
||||
* 格式化色彩信息为显示文本
|
||||
*/
|
||||
formatColorInfo(color: ColorInfo): string {
|
||||
return `${color.name} (${color.hex})`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查方案是否适合指定季节
|
||||
*/
|
||||
isSeasonAppropriate(recommendation: OutfitRecommendation, season: string): boolean {
|
||||
return recommendation.seasons.includes(season);
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查方案是否适合指定场合
|
||||
*/
|
||||
isOccasionAppropriate(recommendation: OutfitRecommendation, occasion: string): boolean {
|
||||
return recommendation.occasions.includes(occasion);
|
||||
},
|
||||
};
|
||||
@@ -77,6 +77,7 @@ export interface SimilaritySearchPanelProps {
|
||||
onSearch: (request: SimilaritySearchRequest) => void;
|
||||
onSuggestionSelect: (suggestion: string) => void;
|
||||
onSuggestionsToggle: (show: boolean) => void;
|
||||
onOutfitRecommendation?: () => void; // 穿搭方案生成回调
|
||||
}
|
||||
|
||||
export interface SimilaritySearchResultsProps {
|
||||
|
||||
Reference in New Issue
Block a user