From fcc0b64488e2a26eb2eb2cef7d2bea6de1770826 Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 31 Jul 2025 10:54:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E9=9F=B3=E8=89=B2=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=E5=92=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=A9=BF=E6=90=AD=E7=94=9F=E6=88=90=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增系统音色管理模块 - 创建 SystemVoice 数据模型,支持多种音色类型和性别分类 - 添加系统音色数据库表和迁移文件,内置46个预设音色 - 实现 SystemVoiceRepository 仓库层,支持分页、搜索、分类查询 - 添加系统音色 Tauri 命令,提供完整的 API 接口 - 修复穿搭图片生成问题 - 修复 prompt 替换逻辑,避免错误覆盖模特描述节点 - 实现商品编号调试文件命名,支持 debug_replaced_1.json 等格式 - 添加 product_index 字段支持,便于多商品生成调试 - 修复编译错误,确保所有结构体字段完整 - 完善穿搭生成功能 - 添加分页加载功能,支持下拉加载更多记录 - 实现失败重试机制,提供用户友好的错误处理 - 支持多商品并发生成,每个商品独立任务处理 - 添加详细的测试验证文档 内置音色包括: - 基础系统音色:青涩青年、精英青年、霸道青年等 - 精品音色:所有基础音色的 beta 版本 - 童声音色:聪明男童、可爱男童、萌萌女童等 - 角色音色:病娇弟弟、俊朗男友、甜心小玲等 - 节日音色:Santa Claus、Grinch、Rudolph - 英文音色:Arnold、Charming Lady、Sweet Girl等 --- OUTFIT_GENERATION_VERIFICATION.md | 122 +++++ .../src/business/services/comfyui_service.rs | 162 +++++- apps/desktop/src-tauri/src/data/models/mod.rs | 1 + .../src-tauri/src/data/models/outfit_image.rs | 25 +- .../src-tauri/src/data/models/system_voice.rs | 319 +++++++++++ .../src-tauri/src/data/repositories/mod.rs | 1 + .../repositories/outfit_image_repository.rs | 78 ++- .../repositories/system_voice_repository.rs | 181 +++++++ .../src/infrastructure/database/migrations.rs | 8 + .../025_create_system_voices_table.sql | 85 +++ .../025_create_system_voices_table_down.sql | 6 + apps/desktop/src-tauri/src/lib.rs | 12 + .../src/presentation/commands/mod.rs | 1 + .../commands/outfit_image_commands.rs | 501 ++++++++++++------ .../commands/system_voice_commands.rs | 302 +++++++++++ .../src/components/OutfitImageGallery.tsx | 116 +++- .../components/OutfitImageGenerationModal.tsx | 4 + .../src/components/OutfitImageGenerator.tsx | 64 ++- apps/desktop/src/pages/ModelDetail.tsx | 78 ++- .../src/services/outfitImageService.ts | 115 +++- .../src/tests/outfitGeneration.test.ts | 166 ++++++ apps/desktop/src/types/outfitImage.ts | 9 + 22 files changed, 2141 insertions(+), 215 deletions(-) create mode 100644 OUTFIT_GENERATION_VERIFICATION.md create mode 100644 apps/desktop/src-tauri/src/data/models/system_voice.rs create mode 100644 apps/desktop/src-tauri/src/data/repositories/system_voice_repository.rs create mode 100644 apps/desktop/src-tauri/src/infrastructure/database/migrations/025_create_system_voices_table.sql create mode 100644 apps/desktop/src-tauri/src/infrastructure/database/migrations/025_create_system_voices_table_down.sql create mode 100644 apps/desktop/src-tauri/src/presentation/commands/system_voice_commands.rs create mode 100644 apps/desktop/src/tests/outfitGeneration.test.ts diff --git a/OUTFIT_GENERATION_VERIFICATION.md b/OUTFIT_GENERATION_VERIFICATION.md new file mode 100644 index 0000000..f967f96 --- /dev/null +++ b/OUTFIT_GENERATION_VERIFICATION.md @@ -0,0 +1,122 @@ +# 穿搭生成功能改进验证清单 + +## 功能概述 +本次改进实现了三个主要功能: +1. **失败重试机制** - 为失败的穿搭图片生成记录添加重试功能 +2. **多商品同时生成** - 支持选择多个商品时创建多个独立任务记录 +3. **多任务并发执行** - 多个任务记录同时运行,提高生成效率 + +## 验证清单 + +### 1. 失败重试机制 ✅ + +#### 后端服务层 +- [x] `OutfitImageService.retryOutfitImageGeneration()` 方法已实现 +- [x] 调用正确的 Tauri 命令 `retry_outfit_image_generation` +- [x] 错误处理和日志记录完整 + +#### 前端UI组件 +- [x] `OutfitImageGallery` 组件添加了重试状态管理 +- [x] 网格视图中失败记录显示重试按钮 +- [x] 列表视图中失败记录显示重试按钮 +- [x] 失败状态区域显示重试按钮 +- [x] 重试过程中显示加载状态 +- [x] 重试按钮在重试过程中禁用 + +#### 集成 +- [x] `ModelDetail.tsx` 添加了 `handleRetryOutfitRecord` 处理函数 +- [x] `OutfitImageGallery` 组件接收 `onRetry` 回调 +- [x] 重试成功后自动刷新记录列表 + +### 2. 多商品同时生成 ✅ + +#### 生成逻辑改进 +- [x] `OutfitImageGenerator.handleGenerate()` 修改为为每个商品创建独立任务 +- [x] 每个商品图片对应一个 `OutfitImageGenerationRequest` +- [x] 支持批量模式和单个模式的自动切换 + +#### UI改进 +- [x] 使用说明更新,反映多商品生成功能 +- [x] 生成按钮文本根据商品数量动态显示 +- [x] 商品上传区域显示批量模式标识 + +### 3. 多任务并发执行 ✅ + +#### 批量处理服务 +- [x] `OutfitImageService.createBatchOutfitImageTasks()` 并发创建任务 +- [x] `OutfitImageService.executeBatchOutfitImageTasks()` 并发执行任务 +- [x] `OutfitImageService.batchGenerateOutfitImages()` 一键批量生成 + +#### 组件集成 +- [x] `OutfitImageGenerator` 支持批量生成模式 +- [x] `OutfitImageGenerationModal` 传递批量生成回调 +- [x] `ModelDetail.tsx` 实现 `handleBatchGenerateOutfitImages` + +#### 性能优化 +- [x] 使用 `Promise.all()` 实现真正的并发执行 +- [x] 避免串行等待,提高生成效率 + +### 4. UI组件和交互改进 ✅ + +#### 状态显示 +- [x] 生成按钮显示任务数量 +- [x] 批量模式标识 +- [x] 进度显示包含商品图片数量信息 + +#### 用户体验 +- [x] 重试按钮仅在失败状态显示 +- [x] 重试过程中的视觉反馈 +- [x] 批量生成的状态提示 + +### 5. 测试和验证 ✅ + +#### 单元测试 +- [x] 创建了 `outfitGeneration.test.ts` 测试文件 +- [x] 测试重试机制的正常和异常情况 +- [x] 测试批量创建和执行任务 +- [x] 测试并发执行性能 + +#### 代码质量 +- [x] 无 TypeScript 编译错误 +- [x] 清理未使用的导入 +- [x] 代码结构清晰,注释完整 + +## 手动测试建议 + +### 测试场景 1: 失败重试 +1. 创建一个穿搭生成任务并让其失败 +2. 在穿搭记录列表中找到失败的记录 +3. 点击重试按钮 +4. 验证重试状态显示和任务重新执行 + +### 测试场景 2: 多商品生成 +1. 在穿搭生成弹框中上传多个商品图片(如3张) +2. 选择模特形象图片 +3. 点击生成按钮 +4. 验证创建了3个独立的任务记录 +5. 验证每个任务记录只包含一个商品图片 + +### 测试场景 3: 并发执行 +1. 同时生成多个穿搭任务 +2. 观察任务列表中的进度更新 +3. 验证多个任务同时进行,而不是串行等待 +4. 检查生成完成的时间是否比串行执行更快 + +## 技术实现亮点 + +1. **服务层分离**: 将批量处理逻辑封装在服务层,保持组件的简洁 +2. **并发优化**: 使用 `Promise.all()` 实现真正的并发执行 +3. **向后兼容**: 保持原有单个生成功能的同时,添加批量功能 +4. **用户体验**: 提供清晰的状态反馈和操作提示 +5. **错误处理**: 完整的错误处理和重试机制 +6. **类型安全**: 完整的 TypeScript 类型定义 + +## 结论 + +所有三个主要功能都已成功实现并集成到现有系统中: + +✅ **失败重试机制** - 用户可以轻松重试失败的生成任务 +✅ **多商品同时生成** - 支持为多个商品创建独立的生成任务 +✅ **多任务并发执行** - 显著提高了多任务生成的效率 + +改进后的系统提供了更好的用户体验、更高的生成效率和更强的错误恢复能力。 diff --git a/apps/desktop/src-tauri/src/business/services/comfyui_service.rs b/apps/desktop/src-tauri/src/business/services/comfyui_service.rs index 9f321bb..bfd5d7b 100644 --- a/apps/desktop/src-tauri/src/business/services/comfyui_service.rs +++ b/apps/desktop/src-tauri/src/business/services/comfyui_service.rs @@ -366,26 +366,33 @@ impl ComfyUIService { }); } "prompt" => { - // 根据节点类型决定使用哪个字段 - let field_name = if let Value::Object(node_obj) = node_value { - if let Some(Value::String(class_type)) = node_obj.get("class_type") { - if class_type == "String" { - "value" + // 只有当 prompt 不为空时才进行替换,保持工作流原始设置 + if !prompt.trim().is_empty() { + // 根据节点类型决定使用哪个字段 + let field_name = if let Value::Object(node_obj) = node_value { + if let Some(Value::String(class_type)) = node_obj.get("class_type") { + if class_type == "String" { + "value" + } else { + "text" + } } else { "text" } } else { "text" - } - } else { - "text" - }; + }; - replacements.push(WorkflowNodeReplacement { - node_id: node_id.clone(), - input_field: field_name.to_string(), - value: Value::String(prompt.to_string()), - }); + replacements.push(WorkflowNodeReplacement { + node_id: node_id.clone(), + input_field: field_name.to_string(), + value: Value::String(prompt.to_string()), + }); + + info!("🔄 替换 prompt 节点 {}: {}", node_id, prompt); + } else { + info!("⏭️ 跳过 prompt 节点 {} 替换,保持工作流原始设置", node_id); + } } "negative" => { let neg_prompt = negative_prompt.unwrap_or(""); @@ -811,7 +818,88 @@ impl ComfyUIService { ).map_err(|e| anyhow!("替换工作流节点失败: {}", e))?; // 保存替换后的工作流到调试文件 - if let Err(e) = self.save_debug_workflow(&updated_workflow, workflow_path).await { + if let Err(e) = self.save_debug_workflow(&updated_workflow, workflow_path, None).await { + warn!("保存调试工作流文件失败: {}", e); + } + + // 4. 提交工作流 + let prompt_id = self.submit_workflow(updated_workflow).await + .map_err(|e| anyhow!("提交工作流失败: {}", e))?; + + info!("工作流提交成功,提示 ID: {}", prompt_id); + + // 5. 跟踪进度 + self.track_workflow_progress(&prompt_id, progress_callback).await + .map_err(|e| anyhow!("跟踪工作流进度失败: {}", e))?; + + // 6. 验证工作流完成状态 + let mut attempts = 0; + let max_attempts = 10; + + while attempts < max_attempts { + if self.is_workflow_completed(&prompt_id).await? { + break; + } + + attempts += 1; + sleep(self.execution_config.result_polling_interval).await; + } + + if attempts >= max_attempts { + return Err(anyhow!("工作流完成状态验证超时")); + } + + // 7. 获取结果 + let image_filenames = self.get_history(&prompt_id).await + .map_err(|e| anyhow!("获取工作流结果失败: {}", e))?; + + if image_filenames.is_empty() { + return Err(anyhow!("工作流执行完成但未生成任何图片")); + } + + let execution_time = start_time.elapsed(); + info!("工作流执行完成,耗时: {:?},生成图片数量: {}", execution_time, image_filenames.len()); + + Ok(image_filenames) + } + + /// 执行完整的工作流生成流程(带商品编号) + pub async fn execute_workflow_indexed( + &self, + workflow_path: &str, + model_image_url: &str, + product_image_url: &str, + prompt: &str, + negative_prompt: Option<&str>, + progress_callback: F, + product_index: Option, + ) -> Result> + where + F: FnMut(WorkflowProgress) + Send + 'static, + { + info!("开始执行工作流: {} (商品编号: {:?})", workflow_path, product_index.map(|i| i + 1)); + let start_time = Instant::now(); + + // 1. 验证服务器连接 + if !self.check_connection().await? { + return Err(anyhow!("ComfyUI 服务器不可用")); + } + + // 2. 加载工作流 + let workflow = self.load_workflow(workflow_path).await + .map_err(|e| anyhow!("加载工作流失败: {}", e))?; + + // 3. 替换节点值 + let updated_workflow = self.auto_replace_input_nodes( + workflow, + model_image_url, + product_image_url, + prompt, + negative_prompt, + ).map_err(|e| anyhow!("替换工作流节点失败: {}", e))?; + + // 保存替换后的工作流到调试文件(带商品编号) + if let Err(e) = self.save_debug_workflow(&updated_workflow, workflow_path, product_index).await { warn!("保存调试工作流文件失败: {}", e); } @@ -1052,16 +1140,46 @@ impl ComfyUIService { where F: FnMut(WorkflowProgress) + Send + 'static, { - info!("开始执行工作流并上传结果: {}", workflow_path); + self.execute_workflow_with_upload_indexed( + cloud_service, + workflow_path, + model_image_url, + product_image_url, + prompt, + negative_prompt, + remote_key_prefix, + progress_callback, + None, // 不指定商品编号 + ).await + } + + /// 执行工作流并自动上传结果到云端(带商品编号) + pub async fn execute_workflow_with_upload_indexed( + &self, + cloud_service: &CloudUploadService, + workflow_path: &str, + model_image_url: &str, + product_image_url: &str, + prompt: &str, + negative_prompt: Option<&str>, + remote_key_prefix: Option<&str>, + progress_callback: F, + product_index: Option, + ) -> Result> + where + F: FnMut(WorkflowProgress) + Send + 'static, + { + info!("开始执行工作流并上传结果: {} (商品编号: {:?})", workflow_path, product_index.map(|i| i + 1)); // 执行工作流 - let image_filenames = self.execute_workflow( + let image_filenames = self.execute_workflow_indexed( workflow_path, model_image_url, product_image_url, prompt, negative_prompt, progress_callback, + product_index, ).await?; if image_filenames.is_empty() { @@ -1084,7 +1202,7 @@ impl ComfyUIService { } /// 保存调试用的工作流文件 - async fn save_debug_workflow(&self, workflow: &Value, original_path: &str) -> Result<()> { + async fn save_debug_workflow(&self, workflow: &Value, original_path: &str, product_index: Option) -> Result<()> { use std::path::Path; use tokio::fs; @@ -1095,8 +1213,12 @@ impl ComfyUIService { .and_then(|s| s.to_str()) .unwrap_or("workflow"); - // 生成调试文件名 - let debug_filename = format!("{}_debug_replaced.json", file_stem); + // 生成调试文件名,根据商品编号区分 + let debug_filename = if let Some(index) = product_index { + format!("{}_debug_replaced_{}.json", file_stem, index + 1) + } else { + format!("{}_debug_replaced.json", file_stem) + }; let debug_path = parent_dir.join(debug_filename); // 格式化JSON并保存 diff --git a/apps/desktop/src-tauri/src/data/models/mod.rs b/apps/desktop/src-tauri/src/data/models/mod.rs index d30b92e..7424439 100644 --- a/apps/desktop/src-tauri/src/data/models/mod.rs +++ b/apps/desktop/src-tauri/src/data/models/mod.rs @@ -23,5 +23,6 @@ pub mod image_generation_record; pub mod thumbnail; pub mod voice_clone_record; pub mod speech_generation_record; +pub mod system_voice; pub mod outfit_image; pub mod outfit_photo_generation; diff --git a/apps/desktop/src-tauri/src/data/models/outfit_image.rs b/apps/desktop/src-tauri/src/data/models/outfit_image.rs index 10c2ae7..e6f681c 100644 --- a/apps/desktop/src-tauri/src/data/models/outfit_image.rs +++ b/apps/desktop/src-tauri/src/data/models/outfit_image.rs @@ -106,11 +106,23 @@ impl OutfitImageRecord { self.status = OutfitImageStatus::Failed; self.error_message = Some(error_message); self.completed_at = Some(Utc::now()); - + if let Some(started_at) = self.started_at { self.duration_ms = Some((Utc::now() - started_at).num_milliseconds() as u64); } } + + /// 重置记录状态以便重试 + pub fn reset_for_retry(&mut self) { + self.status = OutfitImageStatus::Pending; + self.progress = 0.0; + self.error_message = None; + self.started_at = None; + self.completed_at = None; + self.duration_ms = None; + // 保留 result_urls,如果之前有部分成功的结果 + // self.result_urls.clear(); // 可选:是否清除之前的结果 + } } /// 商品图片 @@ -190,6 +202,7 @@ pub struct OutfitImageGenerationRequest { pub product_image_paths: Vec, // 商品图片路径列表 pub generation_prompt: Option, // 可选的生成提示词 pub style_preferences: Option>, // 风格偏好 + pub product_index: Option, // 商品编号(用于调试文件命名) } /// 穿搭图片生成响应 @@ -213,3 +226,13 @@ pub struct OutfitImageStats { pub completed_records: u32, pub failed_records: u32, } + +/// 穿搭图片记录分页响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitImageRecordsResponse { + pub records: Vec, + pub total_count: u32, + pub page: u32, + pub page_size: u32, + pub has_more: bool, +} diff --git a/apps/desktop/src-tauri/src/data/models/system_voice.rs b/apps/desktop/src-tauri/src/data/models/system_voice.rs new file mode 100644 index 0000000..6a8c7b0 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/system_voice.rs @@ -0,0 +1,319 @@ +use rusqlite::{Connection, Result as SqliteResult, Row}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 系统音色类型 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum SystemVoiceType { + /// 系统预设音色 + System, + /// 精品音色 + Premium, + /// 童声音色 + Child, + /// 角色音色 + Character, + /// 节日音色 + Holiday, + /// 英文音色 + English, +} + +impl SystemVoiceType { + pub fn from_str(s: &str) -> Self { + match s { + "system" => SystemVoiceType::System, + "premium" => SystemVoiceType::Premium, + "child" => SystemVoiceType::Child, + "character" => SystemVoiceType::Character, + "holiday" => SystemVoiceType::Holiday, + "english" => SystemVoiceType::English, + _ => SystemVoiceType::System, + } + } + + pub fn to_string(&self) -> String { + match self { + SystemVoiceType::System => "system".to_string(), + SystemVoiceType::Premium => "premium".to_string(), + SystemVoiceType::Child => "child".to_string(), + SystemVoiceType::Character => "character".to_string(), + SystemVoiceType::Holiday => "holiday".to_string(), + SystemVoiceType::English => "english".to_string(), + } + } +} + +/// 音色性别 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum VoiceGender { + Male, + Female, + Child, + Other, +} + +impl VoiceGender { + pub fn from_str(s: &str) -> Self { + match s { + "male" => VoiceGender::Male, + "female" => VoiceGender::Female, + "child" => VoiceGender::Child, + "other" => VoiceGender::Other, + _ => VoiceGender::Other, + } + } + + pub fn to_string(&self) -> String { + match self { + VoiceGender::Male => "male".to_string(), + VoiceGender::Female => "female".to_string(), + VoiceGender::Child => "child".to_string(), + VoiceGender::Other => "other".to_string(), + } + } +} + +/// 系统音色实体模型 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemVoice { + pub id: String, + pub voice_id: String, + pub voice_name: String, + pub voice_name_en: Option, + pub description: Option, + pub voice_type: SystemVoiceType, + pub gender: VoiceGender, + pub language: String, + pub is_active: bool, + pub sort_order: i32, + pub created_at: String, + pub updated_at: String, +} + +/// 创建系统音色请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateSystemVoiceRequest { + pub voice_id: String, + pub voice_name: String, + pub voice_name_en: Option, + pub description: Option, + pub voice_type: SystemVoiceType, + pub gender: VoiceGender, + pub language: String, + pub sort_order: i32, +} + +impl SystemVoice { + /// 从数据库行创建实例 + pub fn from_row(row: &Row) -> SqliteResult { + Ok(SystemVoice { + id: row.get("id")?, + voice_id: row.get("voice_id")?, + voice_name: row.get("voice_name")?, + voice_name_en: row.get("voice_name_en")?, + description: row.get("description")?, + voice_type: SystemVoiceType::from_str(&row.get::<_, String>("voice_type")?), + gender: VoiceGender::from_str(&row.get::<_, String>("gender")?), + language: row.get("language")?, + is_active: row.get("is_active")?, + sort_order: row.get("sort_order")?, + created_at: row.get("created_at")?, + updated_at: row.get("updated_at")?, + }) + } + + /// 创建新的系统音色 + pub fn create(conn: &Connection, request: CreateSystemVoiceRequest) -> SqliteResult { + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + conn.execute( + r#" + INSERT INTO system_voices ( + id, voice_id, voice_name, voice_name_en, description, + voice_type, gender, language, is_active, sort_order, + created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + "#, + ( + &id, + &request.voice_id, + &request.voice_name, + &request.voice_name_en, + &request.description, + &request.voice_type.to_string(), + &request.gender.to_string(), + &request.language, + true, + &request.sort_order, + &now, + &now, + ), + )?; + + Ok(SystemVoice { + id, + voice_id: request.voice_id, + voice_name: request.voice_name, + voice_name_en: request.voice_name_en, + description: request.description, + voice_type: request.voice_type, + gender: request.gender, + language: request.language, + is_active: true, + sort_order: request.sort_order, + created_at: now.clone(), + updated_at: now, + }) + } + + /// 获取所有活跃的系统音色 + pub fn get_all_active(conn: &Connection) -> SqliteResult> { + let mut stmt = conn.prepare( + r#" + SELECT id, voice_id, voice_name, voice_name_en, description, + voice_type, gender, language, is_active, sort_order, + created_at, updated_at + FROM system_voices + WHERE is_active = 1 + ORDER BY sort_order ASC, voice_name ASC + "#, + )?; + + let voice_iter = stmt.query_map([], |row| SystemVoice::from_row(row))?; + let mut voices = Vec::new(); + + for voice in voice_iter { + voices.push(voice?); + } + + Ok(voices) + } + + /// 根据类型获取系统音色 + pub fn get_by_type(conn: &Connection, voice_type: SystemVoiceType) -> SqliteResult> { + let mut stmt = conn.prepare( + r#" + SELECT id, voice_id, voice_name, voice_name_en, description, + voice_type, gender, language, is_active, sort_order, + created_at, updated_at + FROM system_voices + WHERE voice_type = ?1 AND is_active = 1 + ORDER BY sort_order ASC, voice_name ASC + "#, + )?; + + let voice_iter = stmt.query_map([voice_type.to_string()], |row| SystemVoice::from_row(row))?; + let mut voices = Vec::new(); + + for voice in voice_iter { + voices.push(voice?); + } + + Ok(voices) + } + + /// 根据voice_id获取系统音色 + pub fn get_by_voice_id(conn: &Connection, voice_id: &str) -> SqliteResult> { + let mut stmt = conn.prepare( + r#" + SELECT id, voice_id, voice_name, voice_name_en, description, + voice_type, gender, language, is_active, sort_order, + created_at, updated_at + FROM system_voices + WHERE voice_id = ?1 AND is_active = 1 + "#, + )?; + + let mut rows = stmt.query_map([voice_id], |row| SystemVoice::from_row(row))?; + + match rows.next() { + Some(row) => Ok(Some(row?)), + None => Ok(None), + } + } + + /// 批量插入系统音色 + pub fn batch_insert(conn: &Connection, voices: Vec) -> SqliteResult<()> { + let tx = conn.unchecked_transaction()?; + + for voice_request in voices { + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + tx.execute( + r#" + INSERT OR IGNORE INTO system_voices ( + id, voice_id, voice_name, voice_name_en, description, + voice_type, gender, language, is_active, sort_order, + created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + "#, + ( + &id, + &voice_request.voice_id, + &voice_request.voice_name, + &voice_request.voice_name_en, + &voice_request.description, + &voice_request.voice_type.to_string(), + &voice_request.gender.to_string(), + &voice_request.language, + true, + &voice_request.sort_order, + &now, + &now, + ), + )?; + } + + tx.commit()?; + Ok(()) + } +} + +/// 创建系统音色表 +pub fn create_table(conn: &Connection) -> SqliteResult<()> { + conn.execute( + r#" + CREATE TABLE IF NOT EXISTS system_voices ( + id TEXT PRIMARY KEY, + voice_id TEXT NOT NULL UNIQUE, + voice_name TEXT NOT NULL, + voice_name_en TEXT, + description TEXT, + voice_type TEXT NOT NULL, + gender TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'zh-CN', + is_active BOOLEAN NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + "#, + [], + )?; + + // 创建索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_system_voices_voice_id ON system_voices(voice_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_system_voices_type ON system_voices(voice_type)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_system_voices_active ON system_voices(is_active)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_system_voices_sort ON system_voices(sort_order)", + [], + )?; + + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/data/repositories/mod.rs b/apps/desktop/src-tauri/src/data/repositories/mod.rs index 4bfa640..c4d24d7 100644 --- a/apps/desktop/src-tauri/src/data/repositories/mod.rs +++ b/apps/desktop/src-tauri/src/data/repositories/mod.rs @@ -15,5 +15,6 @@ pub mod outfit_favorite_repository; pub mod watermark_template_repository; pub mod template_segment_weight_repository; pub mod image_generation_repository; +pub mod system_voice_repository; pub mod outfit_image_repository; pub mod outfit_photo_generation_repository; diff --git a/apps/desktop/src-tauri/src/data/repositories/outfit_image_repository.rs b/apps/desktop/src-tauri/src/data/repositories/outfit_image_repository.rs index 43a81ca..0dde403 100644 --- a/apps/desktop/src-tauri/src/data/repositories/outfit_image_repository.rs +++ b/apps/desktop/src-tauri/src/data/repositories/outfit_image_repository.rs @@ -420,21 +420,14 @@ impl OutfitImageRepository { }).map_err(|e| anyhow!("查询穿搭图片记录失败: {}", e))?; let mut records = Vec::new(); - for record in record_iter { - let mut record = record.map_err(|e| anyhow!("解析记录失败: {}", e))?; + for record_result in record_iter { + let mut record = record_result.map_err(|e| anyhow!("解析记录失败: {}", e))?; - // 加载关联的商品图片和穿搭图片 - record.product_images = self.get_product_images_by_record_id(&record.id) - .unwrap_or_else(|e| { - warn!("获取商品图片失败: {}", e); - Vec::new() - }); + // 加载关联的商品图片 + record.product_images = self.get_product_images_by_record_id(&record.id)?; - record.outfit_images = self.get_outfit_images_by_record_id(&record.id) - .unwrap_or_else(|e| { - warn!("获取穿搭图片失败: {}", e); - Vec::new() - }); + // 加载关联的穿搭图片 + record.outfit_images = self.get_outfit_images_by_record_id(&record.id)?; records.push(record); } @@ -442,6 +435,65 @@ impl OutfitImageRepository { Ok(records) } + /// 根据模特ID获取分页的穿搭图片生成记录列表 + pub fn get_records_by_model_id_paginated(&self, model_id: &str, limit: u32, offset: u32) -> Result> { + // 🚨 强制使用连接池,避免死锁 + if !self.database.has_pool() { + return Err(anyhow!("连接池未启用,无法安全执行数据库操作")); + } + + let pooled_conn = self.database.acquire_from_pool() + .map_err(|e| anyhow!("获取连接池连接失败: {}", e))?; + + let mut stmt = pooled_conn.prepare( + r#" + SELECT id, model_id, model_image_id, generation_prompt, status, progress, + result_urls, error_message, created_at, started_at, completed_at, duration_ms + FROM outfit_image_records + WHERE model_id = ?1 + ORDER BY created_at DESC + LIMIT ?2 OFFSET ?3 + "#, + ).map_err(|e| anyhow!("准备查询语句失败: {}", e))?; + + let record_iter = stmt.query_map(params![model_id, limit, offset], |row| { + self.row_to_record(row) + }).map_err(|e| anyhow!("查询穿搭图片记录失败: {}", e))?; + + let mut records = Vec::new(); + for record_result in record_iter { + let mut record = record_result.map_err(|e| anyhow!("解析记录失败: {}", e))?; + + // 加载关联的商品图片 + record.product_images = self.get_product_images_by_record_id(&record.id)?; + + // 加载关联的穿搭图片 + record.outfit_images = self.get_outfit_images_by_record_id(&record.id)?; + + records.push(record); + } + + Ok(records) + } + + /// 获取模特的穿搭图片记录总数 + pub fn get_records_count_by_model_id(&self, model_id: &str) -> Result { + if !self.database.has_pool() { + return Err(anyhow!("连接池未启用,无法安全执行数据库操作")); + } + + let pooled_conn = self.database.acquire_from_pool() + .map_err(|e| anyhow!("获取连接池连接失败: {}", e))?; + + let count: u32 = pooled_conn.query_row( + "SELECT COUNT(*) FROM outfit_image_records WHERE model_id = ?1", + params![model_id], + |row| row.get(0) + ).map_err(|e| anyhow!("查询记录总数失败: {}", e))?; + + Ok(count) + } + /// 删除穿搭图片生成记录(强制使用连接池) pub fn delete_record(&self, id: &str) -> Result<()> { diff --git a/apps/desktop/src-tauri/src/data/repositories/system_voice_repository.rs b/apps/desktop/src-tauri/src/data/repositories/system_voice_repository.rs new file mode 100644 index 0000000..0eb33ae --- /dev/null +++ b/apps/desktop/src-tauri/src/data/repositories/system_voice_repository.rs @@ -0,0 +1,181 @@ +use anyhow::Result; +use std::sync::Arc; +use crate::data::models::system_voice::{SystemVoice, SystemVoiceType, CreateSystemVoiceRequest}; +use crate::infrastructure::database::Database; + +/// 系统音色仓库 +pub struct SystemVoiceRepository { + database: Arc, +} + +impl SystemVoiceRepository { + /// 创建新的系统音色仓库实例 + pub fn new(database: Arc) -> Self { + Self { database } + } + + /// 获取所有活跃的系统音色 + pub fn get_all_active(&self) -> Result> { + self.database.with_connection(|conn| { + SystemVoice::get_all_active(conn) + }) + .map_err(|e| anyhow::anyhow!("获取系统音色列表失败: {}", e)) + } + + /// 根据类型获取系统音色 + pub fn get_by_type(&self, voice_type: SystemVoiceType) -> Result> { + self.database.with_connection(|conn| { + SystemVoice::get_by_type(conn, voice_type) + }) + .map_err(|e| anyhow::anyhow!("根据类型获取系统音色失败: {}", e)) + } + + /// 根据voice_id获取系统音色 + pub fn get_by_voice_id(&self, voice_id: &str) -> Result> { + self.database.with_connection(|conn| { + SystemVoice::get_by_voice_id(conn, voice_id) + }) + .map_err(|e| anyhow::anyhow!("根据voice_id获取系统音色失败: {}", e)) + } + + /// 创建系统音色 + pub fn create(&self, request: CreateSystemVoiceRequest) -> Result { + self.database.with_connection(|conn| { + SystemVoice::create(conn, request) + }) + .map_err(|e| anyhow::anyhow!("创建系统音色失败: {}", e)) + } + + /// 批量插入系统音色 + pub fn batch_insert(&self, voices: Vec) -> Result<()> { + self.database.with_connection(|conn| { + SystemVoice::batch_insert(conn, voices) + }) + .map_err(|e| anyhow::anyhow!("批量插入系统音色失败: {}", e)) + } + + /// 检查系统音色是否存在 + pub fn exists_by_voice_id(&self, voice_id: &str) -> Result { + match self.get_by_voice_id(voice_id)? { + Some(_) => Ok(true), + None => Ok(false), + } + } + + /// 获取系统音色总数 + pub fn count_active(&self) -> Result { + self.database.with_connection(|conn| { + let mut stmt = conn.prepare("SELECT COUNT(*) FROM system_voices WHERE is_active = 1")?; + let count: i64 = stmt.query_row([], |row| row.get(0))?; + Ok(count) + }) + .map_err(|e| anyhow::anyhow!("获取系统音色总数失败: {}", e)) + } + + /// 根据性别获取系统音色 + pub fn get_by_gender(&self, gender: &str) -> Result> { + self.database.with_connection(|conn| { + let mut stmt = conn.prepare( + r#" + SELECT id, voice_id, voice_name, voice_name_en, description, + voice_type, gender, language, is_active, sort_order, + created_at, updated_at + FROM system_voices + WHERE gender = ?1 AND is_active = 1 + ORDER BY sort_order ASC, voice_name ASC + "#, + )?; + + let voice_iter = stmt.query_map([gender], |row| SystemVoice::from_row(row))?; + let mut voices = Vec::new(); + + for voice in voice_iter { + voices.push(voice?); + } + + Ok(voices) + }) + .map_err(|e| anyhow::anyhow!("根据性别获取系统音色失败: {}", e)) + } + + /// 根据语言获取系统音色 + pub fn get_by_language(&self, language: &str) -> Result> { + self.database.with_connection(|conn| { + let mut stmt = conn.prepare( + r#" + SELECT id, voice_id, voice_name, voice_name_en, description, + voice_type, gender, language, is_active, sort_order, + created_at, updated_at + FROM system_voices + WHERE language = ?1 AND is_active = 1 + ORDER BY sort_order ASC, voice_name ASC + "#, + )?; + + let voice_iter = stmt.query_map([language], |row| SystemVoice::from_row(row))?; + let mut voices = Vec::new(); + + for voice in voice_iter { + voices.push(voice?); + } + + Ok(voices) + }) + .map_err(|e| anyhow::anyhow!("根据语言获取系统音色失败: {}", e)) + } + + /// 搜索系统音色 + pub fn search(&self, keyword: &str) -> Result> { + self.database.with_connection(|conn| { + let search_pattern = format!("%{}%", keyword); + let mut stmt = conn.prepare( + r#" + SELECT id, voice_id, voice_name, voice_name_en, description, + voice_type, gender, language, is_active, sort_order, + created_at, updated_at + FROM system_voices + WHERE (voice_name LIKE ?1 OR voice_name_en LIKE ?1 OR description LIKE ?1) + AND is_active = 1 + ORDER BY sort_order ASC, voice_name ASC + "#, + )?; + + let voice_iter = stmt.query_map([&search_pattern], |row| SystemVoice::from_row(row))?; + let mut voices = Vec::new(); + + for voice in voice_iter { + voices.push(voice?); + } + + Ok(voices) + }) + .map_err(|e| anyhow::anyhow!("搜索系统音色失败: {}", e)) + } + + /// 获取分页的系统音色列表 + pub fn get_paginated(&self, offset: i64, limit: i64) -> Result> { + self.database.with_connection(|conn| { + let mut stmt = conn.prepare( + r#" + SELECT id, voice_id, voice_name, voice_name_en, description, + voice_type, gender, language, is_active, sort_order, + created_at, updated_at + FROM system_voices + WHERE is_active = 1 + ORDER BY sort_order ASC, voice_name ASC + LIMIT ?1 OFFSET ?2 + "#, + )?; + + let voice_iter = stmt.query_map([limit, offset], |row| SystemVoice::from_row(row))?; + let mut voices = Vec::new(); + + for voice in voice_iter { + voices.push(voice?); + } + + Ok(voices) + }) + .map_err(|e| anyhow::anyhow!("获取分页系统音色列表失败: {}", e)) + } +} diff --git a/apps/desktop/src-tauri/src/infrastructure/database/migrations.rs b/apps/desktop/src-tauri/src/infrastructure/database/migrations.rs index dc1f3c5..0e339dd 100644 --- a/apps/desktop/src-tauri/src/infrastructure/database/migrations.rs +++ b/apps/desktop/src-tauri/src/infrastructure/database/migrations.rs @@ -229,6 +229,14 @@ impl MigrationManager { down_sql: Some(include_str!("migrations/023_create_voice_clone_table_down.sql").to_string()), }); + // 迁移 26: 创建系统音色表 + self.add_migration(Migration { + version: 26, + description: "创建系统音色表".to_string(), + up_sql: include_str!("migrations/025_create_system_voices_table.sql").to_string(), + down_sql: Some(include_str!("migrations/025_create_system_voices_table_down.sql").to_string()), + }); + // 迁移 24: 创建语音生成记录表 self.add_migration(Migration { version: 24, diff --git a/apps/desktop/src-tauri/src/infrastructure/database/migrations/025_create_system_voices_table.sql b/apps/desktop/src-tauri/src/infrastructure/database/migrations/025_create_system_voices_table.sql new file mode 100644 index 0000000..f8030ad --- /dev/null +++ b/apps/desktop/src-tauri/src/infrastructure/database/migrations/025_create_system_voices_table.sql @@ -0,0 +1,85 @@ +-- 创建系统音色表 +CREATE TABLE IF NOT EXISTS system_voices ( + id TEXT PRIMARY KEY, + voice_id TEXT NOT NULL UNIQUE, + voice_name TEXT NOT NULL, + voice_name_en TEXT, + description TEXT, + voice_type TEXT NOT NULL, + gender TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'zh-CN', + is_active BOOLEAN NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- 创建索引以提高查询性能 +CREATE INDEX IF NOT EXISTS idx_system_voices_voice_id ON system_voices(voice_id); +CREATE INDEX IF NOT EXISTS idx_system_voices_type ON system_voices(voice_type); +CREATE INDEX IF NOT EXISTS idx_system_voices_active ON system_voices(is_active); +CREATE INDEX IF NOT EXISTS idx_system_voices_sort ON system_voices(sort_order); + +-- 插入系统预设音色数据 +INSERT OR IGNORE INTO system_voices (id, voice_id, voice_name, voice_name_en, description, voice_type, gender, language, is_active, sort_order, created_at, updated_at) VALUES +-- 基础系统音色 +('sys_voice_001', 'male-qn-qingse', '青涩青年音色', 'Young Male Voice', '清新自然的青年男声', 'system', 'male', 'zh-CN', 1, 1, datetime('now'), datetime('now')), +('sys_voice_002', 'male-qn-jingying', '精英青年音色', 'Elite Male Voice', '成熟稳重的精英男声', 'system', 'male', 'zh-CN', 1, 2, datetime('now'), datetime('now')), +('sys_voice_003', 'male-qn-badao', '霸道青年音色', 'Dominant Male Voice', '霸气十足的青年男声', 'system', 'male', 'zh-CN', 1, 3, datetime('now'), datetime('now')), +('sys_voice_004', 'male-qn-daxuesheng', '青年大学生音色', 'College Student Voice', '阳光活力的大学生男声', 'system', 'male', 'zh-CN', 1, 4, datetime('now'), datetime('now')), +('sys_voice_005', 'female-shaonv', '少女音色', 'Young Girl Voice', '清纯甜美的少女声音', 'system', 'female', 'zh-CN', 1, 5, datetime('now'), datetime('now')), +('sys_voice_006', 'female-yujie', '御姐音色', 'Mature Lady Voice', '成熟魅力的御姐声音', 'system', 'female', 'zh-CN', 1, 6, datetime('now'), datetime('now')), +('sys_voice_007', 'female-chengshu', '成熟女性音色', 'Mature Woman Voice', '温柔知性的成熟女声', 'system', 'female', 'zh-CN', 1, 7, datetime('now'), datetime('now')), +('sys_voice_008', 'female-tianmei', '甜美女性音色', 'Sweet Female Voice', '甜美可爱的女性声音', 'system', 'female', 'zh-CN', 1, 8, datetime('now'), datetime('now')), + +-- 主持人音色 +('sys_voice_009', 'presenter_male', '男性主持人', 'Male Presenter', '专业的男性主持人声音', 'system', 'male', 'zh-CN', 1, 9, datetime('now'), datetime('now')), +('sys_voice_010', 'presenter_female', '女性主持人', 'Female Presenter', '专业的女性主持人声音', 'system', 'female', 'zh-CN', 1, 10, datetime('now'), datetime('now')), + +-- 有声书音色 +('sys_voice_011', 'audiobook_male_1', '男性有声书1', 'Male Audiobook 1', '适合有声书朗读的男声1', 'system', 'male', 'zh-CN', 1, 11, datetime('now'), datetime('now')), +('sys_voice_012', 'audiobook_male_2', '男性有声书2', 'Male Audiobook 2', '适合有声书朗读的男声2', 'system', 'male', 'zh-CN', 1, 12, datetime('now'), datetime('now')), +('sys_voice_013', 'audiobook_female_1', '女性有声书1', 'Female Audiobook 1', '适合有声书朗读的女声1', 'system', 'female', 'zh-CN', 1, 13, datetime('now'), datetime('now')), +('sys_voice_014', 'audiobook_female_2', '女性有声书2', 'Female Audiobook 2', '适合有声书朗读的女声2', 'system', 'female', 'zh-CN', 1, 14, datetime('now'), datetime('now')), + +-- 精品音色 (Beta版本) +('sys_voice_015', 'male-qn-qingse-jingpin', '青涩青年音色-beta', 'Young Male Voice Beta', '精品版青涩青年男声', 'premium', 'male', 'zh-CN', 1, 15, datetime('now'), datetime('now')), +('sys_voice_016', 'male-qn-jingying-jingpin', '精英青年音色-beta', 'Elite Male Voice Beta', '精品版精英青年男声', 'premium', 'male', 'zh-CN', 1, 16, datetime('now'), datetime('now')), +('sys_voice_017', 'male-qn-badao-jingpin', '霸道青年音色-beta', 'Dominant Male Voice Beta', '精品版霸道青年男声', 'premium', 'male', 'zh-CN', 1, 17, datetime('now'), datetime('now')), +('sys_voice_018', 'male-qn-daxuesheng-jingpin', '青年大学生音色-beta', 'College Student Voice Beta', '精品版大学生男声', 'premium', 'male', 'zh-CN', 1, 18, datetime('now'), datetime('now')), +('sys_voice_019', 'female-shaonv-jingpin', '少女音色-beta', 'Young Girl Voice Beta', '精品版少女声音', 'premium', 'female', 'zh-CN', 1, 19, datetime('now'), datetime('now')), +('sys_voice_020', 'female-yujie-jingpin', '御姐音色-beta', 'Mature Lady Voice Beta', '精品版御姐声音', 'premium', 'female', 'zh-CN', 1, 20, datetime('now'), datetime('now')), +('sys_voice_021', 'female-chengshu-jingpin', '成熟女性音色-beta', 'Mature Woman Voice Beta', '精品版成熟女声', 'premium', 'female', 'zh-CN', 1, 21, datetime('now'), datetime('now')), +('sys_voice_022', 'female-tianmei-jingpin', '甜美女性音色-beta', 'Sweet Female Voice Beta', '精品版甜美女声', 'premium', 'female', 'zh-CN', 1, 22, datetime('now'), datetime('now')), + +-- 童声音色 +('sys_voice_023', 'clever_boy', '聪明男童', 'Clever Boy', '聪明活泼的男童声音', 'child', 'child', 'zh-CN', 1, 23, datetime('now'), datetime('now')), +('sys_voice_024', 'cute_boy', '可爱男童', 'Cute Boy', '可爱天真的男童声音', 'child', 'child', 'zh-CN', 1, 24, datetime('now'), datetime('now')), +('sys_voice_025', 'lovely_girl', '萌萌女童', 'Lovely Girl', '萌萌可爱的女童声音', 'child', 'child', 'zh-CN', 1, 25, datetime('now'), datetime('now')), +('sys_voice_026', 'cartoon_pig', '卡通猪小琪', 'Cartoon Pig', '卡通角色猪小琪的声音', 'child', 'other', 'zh-CN', 1, 26, datetime('now'), datetime('now')), + +-- 角色音色 +('sys_voice_027', 'bingjiao_didi', '病娇弟弟', 'Yandere Brother', '病娇风格的弟弟角色声音', 'character', 'male', 'zh-CN', 1, 27, datetime('now'), datetime('now')), +('sys_voice_028', 'junlang_nanyou', '俊朗男友', 'Handsome Boyfriend', '俊朗帅气的男友角色声音', 'character', 'male', 'zh-CN', 1, 28, datetime('now'), datetime('now')), +('sys_voice_029', 'chunzhen_xuedi', '纯真学弟', 'Innocent Junior', '纯真可爱的学弟角色声音', 'character', 'male', 'zh-CN', 1, 29, datetime('now'), datetime('now')), +('sys_voice_030', 'lengdan_xiongzhang', '冷淡学长', 'Cold Senior', '冷淡高冷的学长角色声音', 'character', 'male', 'zh-CN', 1, 30, datetime('now'), datetime('now')), +('sys_voice_031', 'badao_shaoye', '霸道少爷', 'Domineering Young Master', '霸道总裁风格的少爷声音', 'character', 'male', 'zh-CN', 1, 31, datetime('now'), datetime('now')), +('sys_voice_032', 'tianxin_xiaoling', '甜心小玲', 'Sweet Xiaoling', '甜心可爱的小玲角色声音', 'character', 'female', 'zh-CN', 1, 32, datetime('now'), datetime('now')), +('sys_voice_033', 'qiaopi_mengmei', '俏皮萌妹', 'Playful Cute Girl', '俏皮可爱的萌妹角色声音', 'character', 'female', 'zh-CN', 1, 33, datetime('now'), datetime('now')), +('sys_voice_034', 'wumei_yujie', '妩媚御姐', 'Charming Lady', '妩媚性感的御姐角色声音', 'character', 'female', 'zh-CN', 1, 34, datetime('now'), datetime('now')), +('sys_voice_035', 'diadia_xuemei', '嗲嗲学妹', 'Sweet Junior Girl', '嗲嗲可爱的学妹角色声音', 'character', 'female', 'zh-CN', 1, 35, datetime('now'), datetime('now')), +('sys_voice_036', 'danya_xuejie', '淡雅学姐', 'Elegant Senior Girl', '淡雅知性的学姐角色声音', 'character', 'female', 'zh-CN', 1, 36, datetime('now'), datetime('now')), + +-- 节日音色 +('sys_voice_037', 'Santa_Claus', 'Santa Claus', 'Santa Claus', '圣诞老人的经典声音', 'holiday', 'male', 'en-US', 1, 37, datetime('now'), datetime('now')), +('sys_voice_038', 'Grinch', 'Grinch', 'Grinch', '圣诞怪杰格林奇的声音', 'holiday', 'male', 'en-US', 1, 38, datetime('now'), datetime('now')), +('sys_voice_039', 'Rudolph', 'Rudolph', 'Rudolph', '红鼻子驯鹿鲁道夫的声音', 'holiday', 'other', 'en-US', 1, 39, datetime('now'), datetime('now')), + +-- 英文音色 +('sys_voice_040', 'Arnold', 'Arnold', 'Arnold', '阿诺德风格的英文男声', 'english', 'male', 'en-US', 1, 40, datetime('now'), datetime('now')), +('sys_voice_041', 'Charming_Santa', 'Charming Santa', 'Charming Santa', '魅力圣诞老人英文声音', 'english', 'male', 'en-US', 1, 41, datetime('now'), datetime('now')), +('sys_voice_042', 'Charming_Lady', 'Charming Lady', 'Charming Lady', '魅力女士英文声音', 'english', 'female', 'en-US', 1, 42, datetime('now'), datetime('now')), +('sys_voice_043', 'Sweet_Girl', 'Sweet Girl', 'Sweet Girl', '甜美女孩英文声音', 'english', 'female', 'en-US', 1, 43, datetime('now'), datetime('now')), +('sys_voice_044', 'Cute_Elf', 'Cute Elf', 'Cute Elf', '可爱精灵英文声音', 'english', 'other', 'en-US', 1, 44, datetime('now'), datetime('now')), +('sys_voice_045', 'Attractive_Girl', 'Attractive Girl', 'Attractive Girl', '迷人女孩英文声音', 'english', 'female', 'en-US', 1, 45, datetime('now'), datetime('now')), +('sys_voice_046', 'Serene_Woman', 'Serene Woman', 'Serene Woman', '宁静女性英文声音', 'english', 'female', 'en-US', 1, 46, datetime('now'), datetime('now')); diff --git a/apps/desktop/src-tauri/src/infrastructure/database/migrations/025_create_system_voices_table_down.sql b/apps/desktop/src-tauri/src/infrastructure/database/migrations/025_create_system_voices_table_down.sql new file mode 100644 index 0000000..30ee875 --- /dev/null +++ b/apps/desktop/src-tauri/src/infrastructure/database/migrations/025_create_system_voices_table_down.sql @@ -0,0 +1,6 @@ +-- 删除系统音色表 +DROP INDEX IF EXISTS idx_system_voices_sort; +DROP INDEX IF EXISTS idx_system_voices_active; +DROP INDEX IF EXISTS idx_system_voices_type; +DROP INDEX IF EXISTS idx_system_voices_voice_id; +DROP TABLE IF EXISTS system_voices; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 77b85ee..31ebb65 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -437,15 +437,27 @@ pub fn run() { commands::voice_clone_commands::download_audio, commands::voice_clone_commands::get_speech_generation_records, commands::voice_clone_commands::get_speech_generation_records_by_voice_id, + // 系统音色命令 + commands::system_voice_commands::get_system_voices, + commands::system_voice_commands::get_system_voices_by_type, + commands::system_voice_commands::get_system_voices_by_gender, + commands::system_voice_commands::get_system_voices_by_language, + commands::system_voice_commands::search_system_voices, + commands::system_voice_commands::get_system_voices_paginated, + commands::system_voice_commands::get_system_voice_by_id, + commands::system_voice_commands::check_system_voice_exists, + commands::system_voice_commands::get_system_voice_stats, commands::voice_clone_commands::delete_speech_generation_record, // 穿搭图片相关命令 commands::outfit_image_commands::get_model_dashboard_stats, commands::outfit_image_commands::get_outfit_image_records, + commands::outfit_image_commands::get_outfit_image_records_paginated, commands::outfit_image_commands::create_outfit_image_record, commands::outfit_image_commands::delete_outfit_image_record, commands::outfit_image_commands::get_outfit_image_record_detail, commands::outfit_image_commands::execute_outfit_image_generation, commands::outfit_image_commands::execute_outfit_image_task, + commands::outfit_image_commands::retry_outfit_image_generation, // 穿搭照片生成相关命令 commands::outfit_photo_generation_commands::create_outfit_photo_generation_task, commands::outfit_photo_generation_commands::execute_outfit_photo_generation, diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index 2ca7821..8f4c37f 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -34,6 +34,7 @@ pub mod image_generation_commands; pub mod template_segment_weight_commands; pub mod directory_settings_commands; pub mod voice_clone_commands; +pub mod system_voice_commands; pub mod outfit_image_commands; pub mod outfit_photo_generation_commands; pub mod workflow_management_commands; diff --git a/apps/desktop/src-tauri/src/presentation/commands/outfit_image_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/outfit_image_commands.rs index 9e59cf7..353c21a 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/outfit_image_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/outfit_image_commands.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use crate::app_state::AppState; use crate::data::models::outfit_image::{ OutfitImageRecord, OutfitImageGenerationRequest, OutfitImageGenerationResponse, - OutfitImageStats, ProductImage, OutfitImage + OutfitImageStats, ProductImage, OutfitImage, OutfitImageStatus, OutfitImageRecordsResponse }; use crate::data::models::outfit_photo_generation::{ WorkflowProgress @@ -122,6 +122,46 @@ pub async fn get_outfit_image_records( Ok(records) } +/// 分页获取模特的穿搭图片生成记录列表 +#[tauri::command] +pub async fn get_outfit_image_records_paginated( + state: State<'_, AppState>, + model_id: String, + page: u32, + page_size: u32, +) -> Result { + println!("📋 分页获取模特穿搭图片生成记录: {} (页码: {}, 每页: {})", model_id, page, page_size); + + let database = state.get_database(); + let outfit_repo = OutfitImageRepository::new(database); + + // 计算偏移量 + let offset = (page - 1) * page_size; + + // 获取记录 + let records = outfit_repo.get_records_by_model_id_paginated(&model_id, page_size, offset) + .map_err(|e| format!("获取穿搭图片记录失败: {}", e))?; + + // 获取总数 + let total_count = outfit_repo.get_records_count_by_model_id(&model_id) + .map_err(|e| format!("获取记录总数失败: {}", e))?; + + // 计算是否还有更多数据 + let has_more = (offset + page_size) < total_count; + + let response = OutfitImageRecordsResponse { + records, + total_count, + page, + page_size, + has_more, + }; + + println!("✅ 获取到 {} 条穿搭图片记录 (总数: {}, 还有更多: {})", + response.records.len(), total_count, has_more); + Ok(response) +} + /// 创建穿搭图片生成记录 #[tauri::command] pub async fn create_outfit_image_record( @@ -397,85 +437,129 @@ pub async fn execute_outfit_image_generation( } }; - for (index, product_image_path) in request.product_image_paths.iter().enumerate() { - info!("处理商品图片 {}/{}: {}", index + 1, request.product_image_paths.len(), product_image_path); + // 验证请求只包含一个商品图片(工作流模板设计为单商品) + if request.product_image_paths.len() != 1 { + let error_msg = format!( + "当前工作流模板只支持单个商品图片,但请求包含 {} 个商品图片。请为每个商品图片创建独立的生成任务。", + request.product_image_paths.len() + ); + error!("{}", error_msg); - // 上传商品图片到云端获取外网链接 - let product_image_url = if product_image_path.starts_with("https://") { - // 如果已经是HTTPS URL,直接使用 - info!("📷 使用已有的云端商品图片URL: {}", product_image_path); - product_image_path.clone() - } else { - // 如果是本地路径,上传到云存储 - info!("📤 本地商品图片需要上传到云存储: {}", product_image_path); - match upload_image_to_cloud(product_image_path, &cloud_upload_service).await { - Ok(url) => url, - Err(e) => { - has_errors = true; - let error_msg = format!("商品图片 {} 上传失败: {}", index + 1, e); - error_messages.push(error_msg.clone()); - error!("{}", error_msg); - continue; - } - } - }; + // 更新记录状态为失败 + if let Ok(Some(mut record)) = outfit_repo.get_record_by_id(&record_id) { + record.fail(error_msg.clone()); + let _ = outfit_repo.update_record(&record); + } - // 创建进度回调 - let app_handle_clone = app_handle.clone(); - let record_id_clone = record_id.clone(); - let progress_callback = move |progress: WorkflowProgress| { - // 发送进度事件到前端 - if let Err(e) = app_handle_clone.emit("outfit_generation_progress", &progress) { - warn!("发送进度事件失败: {}", e); - } - info!("生成进度 - 记录ID: {}, 进度: {}%", record_id_clone, progress.progress_percentage); - }; + return Ok(OutfitImageGenerationResponse { + record_id, + generated_images: Vec::new(), + generation_time_ms: 0, + success: false, + error_message: Some(error_msg), + }); + } - let prompt = request.generation_prompt.clone().unwrap_or_else(|| "时尚穿搭生成".to_string()); + let product_image_path = &request.product_image_paths[0]; + info!("处理单个商品图片: {}", product_image_path); - // 执行 ComfyUI 工作流并自动上传结果 - let remote_key_prefix = format!("outfit-images/{}", record_id); - - match comfyui_service.execute_workflow_with_upload( - &cloud_upload_service, - &workflow_path_str, - &model_image_url, - &product_image_url, - &prompt, - None, // 负面提示词 - Some(&remote_key_prefix), - progress_callback, - ).await { - Ok(upload_results) => { - // 处理上传结果 - let mut successful_uploads = 0; - - for upload_result in upload_results { - if upload_result.success { - if let Some(remote_url) = upload_result.remote_url { - all_generated_images.push(remote_url.clone()); - successful_uploads += 1; - info!("图片上传成功: {}", remote_url); - } - } else { - warn!("图片上传失败: {}", upload_result.error_message.unwrap_or_default()); - } - } - - if successful_uploads == 0 { - has_errors = true; - error_messages.push(format!("商品图片 {}: 所有图片上传失败", index + 1)); - } else { - info!("商品图片 {} 生成成功,上传 {} 张图片", index + 1, successful_uploads); - } - } + // 上传商品图片到云端获取外网链接 + let product_image_url = if product_image_path.starts_with("https://") { + // 如果已经是HTTPS URL,直接使用 + info!("📷 使用已有的云端商品图片URL: {}", product_image_path); + product_image_path.clone() + } else { + // 如果是本地路径,上传到云存储 + info!("📤 本地商品图片需要上传到云存储: {}", product_image_path); + match upload_image_to_cloud(product_image_path, &cloud_upload_service).await { + Ok(url) => url, Err(e) => { has_errors = true; - let error_msg = format!("商品图片 {} 生成异常: {}", index + 1, e); + let error_msg = format!("商品图片上传失败: {}", e); error_messages.push(error_msg.clone()); error!("{}", error_msg); + + // 更新记录状态为失败 + if let Ok(Some(mut record)) = outfit_repo.get_record_by_id(&record_id) { + record.fail(error_msg.clone()); + let _ = outfit_repo.update_record(&record); + } + + return Ok(OutfitImageGenerationResponse { + record_id, + generated_images: Vec::new(), + generation_time_ms: 0, + success: false, + error_message: Some(error_msg), + }); } } + }; + + // 创建进度回调 + let app_handle_clone = app_handle.clone(); + let record_id_clone = record_id.clone(); + let progress_callback = move |progress: WorkflowProgress| { + // 发送进度事件到前端 + if let Err(e) = app_handle_clone.emit("outfit_generation_progress", &progress) { + warn!("发送进度事件失败: {}", e); + } + info!("生成进度 - 记录ID: {}, 进度: {}%", record_id_clone, progress.progress_percentage); + }; + + // 只有当用户明确提供了 prompt 时才进行替换,否则保持工作流原始设置 + let prompt = match request.generation_prompt.as_ref() { + Some(p) if !p.trim().is_empty() => p.clone(), + _ => { + // 如果没有提供 prompt 或为空,使用空字符串,让工作流保持原始设置 + info!("未提供生成提示词,将保持工作流模板的原始设置"); + String::new() + } + }; + + // 执行 ComfyUI 工作流并自动上传结果 + let remote_key_prefix = format!("outfit-images/{}", record_id); + + match comfyui_service.execute_workflow_with_upload_indexed( + &cloud_upload_service, + &workflow_path_str, + &model_image_url, + &product_image_url, + &prompt, + None, // 负面提示词 + Some(&remote_key_prefix), + progress_callback, + request.product_index, // 使用请求中的商品编号 + ).await { + Ok(upload_results) => { + // 处理上传结果 + let mut successful_uploads = 0; + + for upload_result in upload_results { + if upload_result.success { + if let Some(remote_url) = upload_result.remote_url { + all_generated_images.push(remote_url.clone()); + successful_uploads += 1; + info!("图片上传成功: {}", remote_url); + } + } else { + warn!("图片上传失败: {}", upload_result.error_message.unwrap_or_default()); + } + } + + if successful_uploads == 0 { + has_errors = true; + error_messages.push("所有图片上传失败".to_string()); + } else { + info!("商品图片生成成功,上传 {} 张图片", successful_uploads); + } + } + Err(e) => { + has_errors = true; + let error_msg = format!("商品图片生成异常: {}", e); + error_messages.push(error_msg.clone()); + error!("{}", error_msg); + } } let end_time = Utc::now(); @@ -590,6 +674,7 @@ pub async fn execute_outfit_image_task( product_image_paths: record.product_images.iter().map(|img| img.file_path.clone()).collect(), generation_prompt: record.generation_prompt.clone(), style_preferences: None, + product_index: Some(0), // 默认商品编号为0 }; // 立即更新记录状态为"生成中" @@ -649,6 +734,56 @@ pub async fn execute_outfit_image_task( Ok(()) } +/// 重试失败的穿搭图片生成任务 +#[tauri::command] +pub async fn retry_outfit_image_generation( + state: State<'_, AppState>, + app_handle: AppHandle, + record_id: String, +) -> Result<(), String> { + info!("🔄 重试穿搭图片生成任务: {}", record_id); + + // 获取数据库和仓库 + let database = state.get_database(); + let outfit_repo = OutfitImageRepository::new(database.clone()); + + // 获取任务记录 + let record = match outfit_repo.get_record_by_id(&record_id) { + Ok(Some(record)) => record, + Ok(None) => { + error!("重试任务记录不存在: {}", record_id); + return Err(format!("任务记录不存在: {}", record_id)); + } + Err(e) => { + error!("获取重试任务记录失败: {} - {}", record_id, e); + return Err(format!("获取任务记录失败: {}", e)); + } + }; + + // 检查记录状态是否为失败状态 + if record.status != OutfitImageStatus::Failed { + warn!("任务记录状态不是失败状态,无法重试: {} - 当前状态: {:?}", record_id, record.status); + return Err(format!("任务状态不是失败状态,无法重试。当前状态: {:?}", record.status)); + } + + // 重置记录状态为待处理,清除错误信息 + if let Ok(Some(mut record)) = outfit_repo.get_record_by_id(&record_id) { + record.reset_for_retry(); + if let Err(e) = outfit_repo.update_record(&record) { + error!("重置记录状态失败: {}", e); + return Err(format!("重置记录状态失败: {}", e)); + } else { + info!("📝 重置记录状态为待处理: {}", record_id); + } + } + + // 调用执行任务命令重新执行 + execute_outfit_image_task(state, app_handle, record_id.clone()).await?; + + info!("✅ 穿搭图片生成任务重试成功: {}", record_id); + Ok(()) +} + /// 上传图片到云存储并返回可访问的URL async fn upload_image_to_cloud(file_path: &str, cloud_service: &CloudUploadService) -> Result { info!("📤 正在上传图片到云存储: {}", file_path); @@ -873,105 +1008,155 @@ async fn perform_outfit_image_generation( } }; - for (index, product_image_path) in request.product_image_paths.iter().enumerate() { - info!("处理商品图片 {}/{}: {}", index + 1, request.product_image_paths.len(), product_image_path); + // 验证请求只包含一个商品图片(工作流模板设计为单商品) + if request.product_image_paths.len() != 1 { + let error_msg = format!( + "当前工作流模板只支持单个商品图片,但重试请求包含 {} 个商品图片。", + request.product_image_paths.len() + ); + error!("{}", error_msg); - // 上传商品图片到云端获取外网链接 - let product_image_url = if product_image_path.starts_with("https://") { - // 如果已经是HTTPS URL,直接使用 - info!("📷 使用已有的云端商品图片URL: {}", product_image_path); - product_image_path.clone() - } else { - // 如果是本地路径,上传到云存储 - info!("📤 本地商品图片需要上传到云存储: {}", product_image_path); - match upload_image_to_cloud(product_image_path, &cloud_upload_service).await { - Ok(url) => url, - Err(e) => { - has_errors = true; - let error_msg = format!("商品图片 {} 上传失败: {}", index + 1, e); - error_messages.push(error_msg.clone()); - error!("{}", error_msg); - continue; - } - } - }; + let simple_error = SimpleError(error_msg.clone()); + let user_error = error_service.handle_error(&simple_error, None); - // 创建进度回调 - let app_handle_clone = app_handle.clone(); - let record_id_clone = record_id.clone(); - let outfit_repo_clone = OutfitImageRepository::new(database.clone()); + // 更新记录状态为失败 + if let Ok(Some(mut record)) = outfit_repo.get_record_by_id(&record_id) { + record.fail(user_error.message.clone()); + let _ = outfit_repo.update_record(&record); + } - let progress_callback = move |progress: WorkflowProgress| { - // 更新数据库中的进度 - if let Ok(Some(mut record)) = outfit_repo_clone.get_record_by_id(&record_id_clone) { - record.progress = progress.progress_percentage / 100.0; // 转换为0-1范围 - if let Err(e) = outfit_repo_clone.update_record(&record) { - warn!("更新记录进度失败: {}", e); - } - } + return Ok(OutfitImageGenerationResponse { + record_id, + generated_images: Vec::new(), + generation_time_ms: 0, + success: false, + error_message: Some(user_error.message), + }); + } - // 发送进度事件到前端 - let progress_event = serde_json::json!({ - "type": "outfit_generation_progress", - "record_id": record_id_clone, - "progress_percentage": progress.progress_percentage, - "current_step": progress.current_step, - "total_steps": progress.total_steps, - "status_message": progress.status_message, - "current_node_id": progress.current_node_id - }); + let product_image_path = &request.product_image_paths[0]; + info!("重试处理单个商品图片: {}", product_image_path); - if let Err(e) = app_handle_clone.emit("outfit_generation_progress", &progress_event) { - warn!("发送进度事件失败: {}", e); - } - info!("生成进度 - 记录ID: {}, 进度: {}%", record_id_clone, progress.progress_percentage); - }; - - let prompt = request.generation_prompt.clone().unwrap_or_else(|| "时尚穿搭生成".to_string()); - - // 执行 ComfyUI 工作流并自动上传结果 - let remote_key_prefix = format!("outfit-images/{}", record_id); - - match comfyui_service.execute_workflow_with_upload( - &cloud_upload_service, - &workflow_path_str, - &model_image_url, - &product_image_url, - &prompt, - None, // 负面提示词 - Some(&remote_key_prefix), - progress_callback, - ).await { - Ok(upload_results) => { - // 处理上传结果 - let mut successful_uploads = 0; - - for upload_result in upload_results { - if upload_result.success { - if let Some(remote_url) = upload_result.remote_url { - all_generated_images.push(remote_url.clone()); - successful_uploads += 1; - info!("图片上传成功: {}", remote_url); - } - } else { - warn!("图片上传失败: {}", upload_result.error_message.unwrap_or_default()); - } - } - - if successful_uploads == 0 { - has_errors = true; - error_messages.push(format!("商品图片 {}: 所有图片上传失败", index + 1)); - } else { - info!("商品图片 {} 生成成功,上传 {} 张图片", index + 1, successful_uploads); - } - } + // 上传商品图片到云端获取外网链接 + let product_image_url = if product_image_path.starts_with("https://") { + // 如果已经是HTTPS URL,直接使用 + info!("📷 使用已有的云端商品图片URL: {}", product_image_path); + product_image_path.clone() + } else { + // 如果是本地路径,上传到云存储 + info!("📤 本地商品图片需要上传到云存储: {}", product_image_path); + match upload_image_to_cloud(product_image_path, &cloud_upload_service).await { + Ok(url) => url, Err(e) => { has_errors = true; - let error_msg = format!("商品图片 {} 生成异常: {}", index + 1, e); + let error_msg = format!("商品图片上传失败: {}", e); error_messages.push(error_msg.clone()); error!("{}", error_msg); + + let simple_error = SimpleError(error_msg.clone()); + let user_error = error_service.handle_error(&simple_error, None); + + // 更新记录状态为失败 + if let Ok(Some(mut record)) = outfit_repo.get_record_by_id(&record_id) { + record.fail(user_error.message.clone()); + let _ = outfit_repo.update_record(&record); + } + + return Ok(OutfitImageGenerationResponse { + record_id, + generated_images: Vec::new(), + generation_time_ms: 0, + success: false, + error_message: Some(user_error.message), + }); } } + }; + + // 创建进度回调 + let app_handle_clone = app_handle.clone(); + let record_id_clone = record_id.clone(); + let outfit_repo_clone = OutfitImageRepository::new(database.clone()); + + let progress_callback = move |progress: WorkflowProgress| { + // 更新数据库中的进度 + if let Ok(Some(mut record)) = outfit_repo_clone.get_record_by_id(&record_id_clone) { + record.progress = progress.progress_percentage / 100.0; // 转换为0-1范围 + if let Err(e) = outfit_repo_clone.update_record(&record) { + warn!("更新记录进度失败: {}", e); + } + } + + // 发送进度事件到前端 + let progress_event = serde_json::json!({ + "type": "outfit_generation_progress", + "record_id": record_id_clone, + "progress_percentage": progress.progress_percentage, + "current_step": progress.current_step, + "total_steps": progress.total_steps, + "status_message": progress.status_message, + "current_node_id": progress.current_node_id + }); + + if let Err(e) = app_handle_clone.emit("outfit_generation_progress", &progress_event) { + warn!("发送进度事件失败: {}", e); + } + info!("生成进度 - 记录ID: {}, 进度: {}%", record_id_clone, progress.progress_percentage); + }; + + // 只有当用户明确提供了 prompt 时才进行替换,否则保持工作流原始设置 + let prompt = match request.generation_prompt.as_ref() { + Some(p) if !p.trim().is_empty() => p.clone(), + _ => { + // 如果没有提供 prompt 或为空,使用空字符串,让工作流保持原始设置 + info!("重试时未提供生成提示词,将保持工作流模板的原始设置"); + String::new() + } + }; + + // 执行 ComfyUI 工作流并自动上传结果 + let remote_key_prefix = format!("outfit-images/{}", record_id); + + match comfyui_service.execute_workflow_with_upload_indexed( + &cloud_upload_service, + &workflow_path_str, + &model_image_url, + &product_image_url, + &prompt, + None, // 负面提示词 + Some(&remote_key_prefix), + progress_callback, + request.product_index, // 使用请求中的商品编号 + ).await { + Ok(upload_results) => { + // 处理上传结果 + let mut successful_uploads = 0; + + for upload_result in upload_results { + if upload_result.success { + if let Some(remote_url) = upload_result.remote_url { + all_generated_images.push(remote_url.clone()); + successful_uploads += 1; + info!("图片上传成功: {}", remote_url); + } + } else { + warn!("图片上传失败: {}", upload_result.error_message.unwrap_or_default()); + } + } + + if successful_uploads == 0 { + has_errors = true; + error_messages.push("所有图片上传失败".to_string()); + } else { + info!("商品图片重试生成成功,上传 {} 张图片", successful_uploads); + } + } + Err(e) => { + has_errors = true; + let error_msg = format!("商品图片重试生成异常: {}", e); + error_messages.push(error_msg.clone()); + error!("{}", error_msg); + } } let end_time = Utc::now(); diff --git a/apps/desktop/src-tauri/src/presentation/commands/system_voice_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/system_voice_commands.rs new file mode 100644 index 0000000..1a5086a --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/system_voice_commands.rs @@ -0,0 +1,302 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tauri::{command, State}; +use tracing::{error, info}; + +use crate::data::models::system_voice::{SystemVoice, SystemVoiceType}; +use crate::data::repositories::system_voice_repository::SystemVoiceRepository; +use crate::infrastructure::database::Database; + +/// 系统音色查询请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemVoiceQuery { + pub voice_type: Option, + pub gender: Option, + pub language: Option, + pub keyword: Option, + pub page: Option, + pub page_size: Option, +} + +/// 系统音色响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemVoiceResponse { + pub voices: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, + pub total_pages: i64, +} + +/// 获取所有活跃的系统音色 +#[command] +pub async fn get_system_voices( + database: State<'_, Arc>, +) -> Result, String> { + info!("获取系统音色列表"); + + let repository = SystemVoiceRepository::new(database.inner().clone()); + + match repository.get_all_active() { + Ok(voices) => { + info!("成功获取 {} 个系统音色", voices.len()); + Ok(voices) + } + Err(e) => { + error!("获取系统音色列表失败: {}", e); + Err(format!("获取系统音色列表失败: {}", e)) + } + } +} + +/// 根据类型获取系统音色 +#[command] +pub async fn get_system_voices_by_type( + voice_type: String, + database: State<'_, Arc>, +) -> Result, String> { + info!("根据类型获取系统音色: {}", voice_type); + + let repository = SystemVoiceRepository::new(database.inner().clone()); + let voice_type_enum = SystemVoiceType::from_str(&voice_type); + + match repository.get_by_type(voice_type_enum) { + Ok(voices) => { + info!("成功获取 {} 个 {} 类型的系统音色", voices.len(), voice_type); + Ok(voices) + } + Err(e) => { + error!("根据类型获取系统音色失败: {}", e); + Err(format!("根据类型获取系统音色失败: {}", e)) + } + } +} + +/// 根据性别获取系统音色 +#[command] +pub async fn get_system_voices_by_gender( + gender: String, + database: State<'_, Arc>, +) -> Result, String> { + info!("根据性别获取系统音色: {}", gender); + + let repository = SystemVoiceRepository::new(database.inner().clone()); + + match repository.get_by_gender(&gender) { + Ok(voices) => { + info!("成功获取 {} 个 {} 性别的系统音色", voices.len(), gender); + Ok(voices) + } + Err(e) => { + error!("根据性别获取系统音色失败: {}", e); + Err(format!("根据性别获取系统音色失败: {}", e)) + } + } +} + +/// 根据语言获取系统音色 +#[command] +pub async fn get_system_voices_by_language( + language: String, + database: State<'_, Arc>, +) -> Result, String> { + info!("根据语言获取系统音色: {}", language); + + let repository = SystemVoiceRepository::new(database.inner().clone()); + + match repository.get_by_language(&language) { + Ok(voices) => { + info!("成功获取 {} 个 {} 语言的系统音色", voices.len(), language); + Ok(voices) + } + Err(e) => { + error!("根据语言获取系统音色失败: {}", e); + Err(format!("根据语言获取系统音色失败: {}", e)) + } + } +} + +/// 搜索系统音色 +#[command] +pub async fn search_system_voices( + keyword: String, + database: State<'_, Arc>, +) -> Result, String> { + info!("搜索系统音色: {}", keyword); + + let repository = SystemVoiceRepository::new(database.inner().clone()); + + match repository.search(&keyword) { + Ok(voices) => { + info!("搜索到 {} 个匹配的系统音色", voices.len()); + Ok(voices) + } + Err(e) => { + error!("搜索系统音色失败: {}", e); + Err(format!("搜索系统音色失败: {}", e)) + } + } +} + +/// 分页获取系统音色 +#[command] +pub async fn get_system_voices_paginated( + query: SystemVoiceQuery, + database: State<'_, Arc>, +) -> Result { + info!("分页获取系统音色: {:?}", query); + + let repository = SystemVoiceRepository::new(database.inner().clone()); + + let page = query.page.unwrap_or(1); + let page_size = query.page_size.unwrap_or(20); + let offset = (page - 1) * page_size; + + // 根据查询条件获取音色列表 + let voices = if let Some(keyword) = &query.keyword { + repository.search(keyword) + } else if let Some(voice_type) = &query.voice_type { + let voice_type_enum = SystemVoiceType::from_str(voice_type); + repository.get_by_type(voice_type_enum) + } else if let Some(gender) = &query.gender { + repository.get_by_gender(gender) + } else if let Some(language) = &query.language { + repository.get_by_language(language) + } else { + repository.get_paginated(offset, page_size) + }; + + match voices { + Ok(voices) => { + // 获取总数 + let total = match repository.count_active() { + Ok(count) => count, + Err(e) => { + error!("获取系统音色总数失败: {}", e); + return Err(format!("获取系统音色总数失败: {}", e)); + } + }; + + let total_pages = (total + page_size - 1) / page_size; + + let response = SystemVoiceResponse { + voices, + total, + page, + page_size, + total_pages, + }; + + info!("成功获取分页系统音色,页码: {}, 每页: {}, 总数: {}", page, page_size, total); + Ok(response) + } + Err(e) => { + error!("分页获取系统音色失败: {}", e); + Err(format!("分页获取系统音色失败: {}", e)) + } + } +} + +/// 根据voice_id获取系统音色详情 +#[command] +pub async fn get_system_voice_by_id( + voice_id: String, + database: State<'_, Arc>, +) -> Result, String> { + info!("根据voice_id获取系统音色详情: {}", voice_id); + + let repository = SystemVoiceRepository::new(database.inner().clone()); + + match repository.get_by_voice_id(&voice_id) { + Ok(voice) => { + if voice.is_some() { + info!("成功获取系统音色详情: {}", voice_id); + } else { + info!("未找到系统音色: {}", voice_id); + } + Ok(voice) + } + Err(e) => { + error!("根据voice_id获取系统音色详情失败: {}", e); + Err(format!("根据voice_id获取系统音色详情失败: {}", e)) + } + } +} + +/// 检查系统音色是否存在 +#[command] +pub async fn check_system_voice_exists( + voice_id: String, + database: State<'_, Arc>, +) -> Result { + info!("检查系统音色是否存在: {}", voice_id); + + let repository = SystemVoiceRepository::new(database.inner().clone()); + + match repository.exists_by_voice_id(&voice_id) { + Ok(exists) => { + info!("系统音色 {} 存在状态: {}", voice_id, exists); + Ok(exists) + } + Err(e) => { + error!("检查系统音色是否存在失败: {}", e); + Err(format!("检查系统音色是否存在失败: {}", e)) + } + } +} + +/// 获取系统音色统计信息 +#[command] +pub async fn get_system_voice_stats( + database: State<'_, Arc>, +) -> Result { + info!("获取系统音色统计信息"); + + let repository = SystemVoiceRepository::new(database.inner().clone()); + + // 获取总数 + let total = match repository.count_active() { + Ok(count) => count, + Err(e) => { + error!("获取系统音色总数失败: {}", e); + return Err(format!("获取系统音色总数失败: {}", e)); + } + }; + + // 按类型统计 + let mut type_stats = serde_json::Map::new(); + for voice_type in ["system", "premium", "child", "character", "holiday", "english"] { + let voice_type_enum = SystemVoiceType::from_str(voice_type); + match repository.get_by_type(voice_type_enum) { + Ok(voices) => { + type_stats.insert(voice_type.to_string(), serde_json::Value::Number(voices.len().into())); + } + Err(e) => { + error!("获取 {} 类型音色统计失败: {}", voice_type, e); + } + } + } + + // 按性别统计 + let mut gender_stats = serde_json::Map::new(); + for gender in ["male", "female", "child", "other"] { + match repository.get_by_gender(gender) { + Ok(voices) => { + gender_stats.insert(gender.to_string(), serde_json::Value::Number(voices.len().into())); + } + Err(e) => { + error!("获取 {} 性别音色统计失败: {}", gender, e); + } + } + } + + let stats = serde_json::json!({ + "total": total, + "by_type": type_stats, + "by_gender": gender_stats + }); + + info!("成功获取系统音色统计信息"); + Ok(stats) +} diff --git a/apps/desktop/src/components/OutfitImageGallery.tsx b/apps/desktop/src/components/OutfitImageGallery.tsx index 50a3ad1..59da5bf 100644 --- a/apps/desktop/src/components/OutfitImageGallery.tsx +++ b/apps/desktop/src/components/OutfitImageGallery.tsx @@ -10,19 +10,25 @@ import { Search, Calendar, Sparkles, - Eye + Eye, + RotateCcw } from 'lucide-react'; import { OutfitImageRecord, OutfitImageStatus } from '../types/outfitImage'; import { DeleteConfirmDialog } from './DeleteConfirmDialog'; import { getImageSrc } from '../utils/imagePathUtils'; import { ImagePreviewModal } from './ImagePreviewModal'; import { GroundingSource } from '../types/ragGrounding'; +import { OutfitImageService } from '../services/outfitImageService'; interface OutfitImageGalleryProps { records: OutfitImageRecord[]; onDelete: (record: OutfitImageRecord) => Promise; onRefresh: () => Promise; + onRetry?: (record: OutfitImageRecord) => Promise; + onLoadMore?: () => Promise; loading?: boolean; + loadingMore?: boolean; + hasMore?: boolean; className?: string; } @@ -37,7 +43,11 @@ export const OutfitImageGallery: React.FC = ({ records, onDelete, onRefresh, + onRetry, + onLoadMore, loading = false, + loadingMore = false, + hasMore = false, className = '' }) => { const [viewMode, setViewMode] = useState('grid'); @@ -68,6 +78,9 @@ export const OutfitImageGallery: React.FC = ({ deleting: false }); + // 重试状态管理 + const [retryingRecords, setRetryingRecords] = useState>(new Set()); + // 过滤记录 const filteredRecords = records.filter(record => { const matchesFilter = filter === 'all' || record.status === filter; @@ -105,6 +118,32 @@ export const OutfitImageGallery: React.FC = ({ setDeleteConfirm({ show: false, record: null, deleting: false }); }, []); + // 处理重试 + const handleRetry = useCallback(async (record: OutfitImageRecord) => { + if (retryingRecords.has(record.id)) return; + + try { + setRetryingRecords(prev => new Set(prev).add(record.id)); + + if (onRetry) { + // 使用父组件提供的重试方法 + await onRetry(record); + } else { + // 使用默认的重试方法 + await OutfitImageService.retryOutfitImageGeneration(record.id); + await onRefresh(); + } + } catch (error) { + console.error('重试失败:', error); + } finally { + setRetryingRecords(prev => { + const newSet = new Set(prev); + newSet.delete(record.id); + return newSet; + }); + } + }, [retryingRecords, onRetry, onRefresh]); + // 打开图片预览 const openImagePreview = useCallback((imageUrl: string, title: string, allImages: string[] = [], currentIndex: number = 0) => { console.log('🖼️ 打开图片预览:', { imageUrl, title, allImages, currentIndex }); @@ -306,6 +345,20 @@ export const OutfitImageGallery: React.FC = ({ {getStatusLabel(record.status)}
+ {record.status === OutfitImageStatus.Failed && ( + + )}
)} @@ -467,7 +540,22 @@ export const OutfitImageGallery: React.FC = ({ ))} )} - + + {record.status === OutfitImageStatus.Failed && ( + + )} + + + )} + {/* 删除确认对话框 */} void; model: Model; onGenerate: (request: OutfitImageGenerationRequest) => Promise; + onBatchGenerate?: (requests: OutfitImageGenerationRequest[]) => Promise; isGenerating?: boolean; } @@ -22,6 +23,7 @@ export const OutfitImageGenerationModal: React.FC { // 键盘事件处理 @@ -90,8 +92,10 @@ export const OutfitImageGenerationModal: React.FC diff --git a/apps/desktop/src/components/OutfitImageGenerator.tsx b/apps/desktop/src/components/OutfitImageGenerator.tsx index 7c44c2e..6eaf4b4 100644 --- a/apps/desktop/src/components/OutfitImageGenerator.tsx +++ b/apps/desktop/src/components/OutfitImageGenerator.tsx @@ -21,9 +21,11 @@ interface OutfitImageGeneratorProps { modelId: string; modelPhotos: ModelPhoto[]; onGenerate: (request: OutfitImageGenerationRequest) => Promise; + onBatchGenerate?: (requests: OutfitImageGenerationRequest[]) => Promise; onClose?: () => void; // 添加关闭回调 isGenerating?: boolean; disabled?: boolean; + enableBatchMode?: boolean; // 是否启用批量模式 } const SUPPORTED_IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']; @@ -36,9 +38,11 @@ export const OutfitImageGenerator: React.FC = ({ modelId, modelPhotos, onGenerate, + onBatchGenerate, onClose, isGenerating = false, - disabled = false + disabled = false, + enableBatchMode = true }) => { const [selectedModelImageId, setSelectedModelImageId] = useState(''); const [productImages, setProductImages] = useState([]); @@ -225,15 +229,37 @@ export const OutfitImageGenerator: React.FC = ({ try { setError(null); - const request: OutfitImageGenerationRequest = { - model_id: modelId, - model_image_id: selectedModelImageId, - product_image_paths: productImages, - generation_prompt: generationPrompt || undefined, - style_preferences: undefined - }; + if (enableBatchMode && onBatchGenerate && productImages.length > 1) { + // 批量模式:为每个商品图片创建独立的生成请求 + const requests: OutfitImageGenerationRequest[] = productImages.map((productImagePath, index) => ({ + model_id: modelId, + model_image_id: selectedModelImageId, + product_image_paths: [productImagePath], // 每个任务只包含一个商品图片 + generation_prompt: generationPrompt || undefined, + style_preferences: undefined, + product_index: index // 添加商品编号 + })); - await onGenerate(request); + // 使用批量生成方法(并发执行) + await onBatchGenerate(requests); + } else { + // 单个模式或回退模式:为每个商品图片创建独立的生成任务 + const generatePromises = productImages.map(async (productImagePath, index) => { + const request: OutfitImageGenerationRequest = { + model_id: modelId, + model_image_id: selectedModelImageId, + product_image_paths: [productImagePath], // 每个任务只包含一个商品图片 + generation_prompt: generationPrompt || undefined, + style_preferences: undefined, + product_index: index // 添加商品编号 + }; + + return await onGenerate(request); + }); + + // 等待所有任务创建完成 + await Promise.all(generatePromises); + } // 生成成功后清空表单 setSelectedModelImageId(''); @@ -489,6 +515,11 @@ export const OutfitImageGenerator: React.FC = ({ 上传商品图片 ({productImages.length}/10) + {enableBatchMode && productImages.length > 1 && ( + + 批量模式 + + )} {/* 拖拽上传区域 */} @@ -612,12 +643,18 @@ export const OutfitImageGenerator: React.FC = ({ {isGenerating ? ( <>
- 生成中... + {enableBatchMode && productImages.length > 1 ? + `批量生成中... (${productImages.length}个任务)` : + '生成中...' + } ) : ( <> - 生成穿搭图片 + {enableBatchMode && productImages.length > 1 ? + `批量生成穿搭图片 (${productImages.length}个任务)` : + '生成穿搭图片' + } )} @@ -633,8 +670,9 @@ export const OutfitImageGenerator: React.FC = ({
  • • 选择一张模特的个人形象照片作为基础
  • • 上传要搭配的商品图片(服装、配饰等)
  • • 可选择添加生成提示词来指导AI生成
  • -
  • • 点击生成按钮,AI将为您创建穿搭效果图
  • -
  • • 生成的图片数量与上传的商品图片数量相等
  • +
  • • 点击生成按钮,AI将为每个商品创建独立的生成任务
  • +
  • • 多个商品将同时生成,每个商品对应一个任务记录
  • +
  • • 可在生成记录中查看每个任务的进度和结果
  • diff --git a/apps/desktop/src/pages/ModelDetail.tsx b/apps/desktop/src/pages/ModelDetail.tsx index 3a5c797..6db99a6 100644 --- a/apps/desktop/src/pages/ModelDetail.tsx +++ b/apps/desktop/src/pages/ModelDetail.tsx @@ -48,6 +48,12 @@ const ModelDetail: React.FC = () => { const [statsLoading, setStatsLoading] = useState(false); const [outfitRecords, setOutfitRecords] = useState([]); + // 分页状态 + const [outfitPage, setOutfitPage] = useState(1); + const [outfitHasMore, setOutfitHasMore] = useState(false); + const [outfitLoadingMore, setOutfitLoadingMore] = useState(false); + const [outfitTotalCount, setOutfitTotalCount] = useState(0); + const [generatingOutfit, setGeneratingOutfit] = useState(false); const [activeTab, setActiveTab] = useState('overview'); const [dynamics] = useState([]); @@ -168,18 +174,41 @@ const ModelDetail: React.FC = () => { - // 加载穿搭图片生成记录 + // 加载穿搭图片生成记录(首次加载) const loadOutfitRecords = async () => { if (!id) return; try { - const records = await OutfitImageService.getOutfitImageRecords(id); - setOutfitRecords(records); + const response = await OutfitImageService.getOutfitImageRecordsPaginated(id, 1, 20); + setOutfitRecords(response.records); + setOutfitPage(1); + setOutfitHasMore(response.has_more); + setOutfitTotalCount(response.total_count); } catch (err) { console.error('加载穿搭图片记录失败:', err); } }; + // 加载更多穿搭图片记录 + const loadMoreOutfitRecords = async () => { + if (!id || outfitLoadingMore || !outfitHasMore) return; + + try { + setOutfitLoadingMore(true); + const nextPage = outfitPage + 1; + const response = await OutfitImageService.getOutfitImageRecordsPaginated(id, nextPage, 20); + + setOutfitRecords(prev => [...prev, ...response.records]); + setOutfitPage(nextPage); + setOutfitHasMore(response.has_more); + setOutfitTotalCount(response.total_count); + } catch (err) { + console.error('加载更多穿搭图片记录失败:', err); + } finally { + setOutfitLoadingMore(false); + } + }; + // 生成穿搭图片(异步模式) const handleGenerateOutfitImages = async (request: OutfitImageGenerationRequest) => { try { @@ -339,6 +368,44 @@ const ModelDetail: React.FC = () => { } }; + // 重试穿搭图片生成 + const handleRetryOutfitRecord = async (record: OutfitImageRecord) => { + try { + await OutfitImageService.retryOutfitImageGeneration(record.id); + // 重新加载记录和统计信息 + await loadOutfitRecords(); + await loadDashboardStats(); + } catch (err) { + console.error('重试穿搭生成失败:', err); + setError(`重试穿搭生成失败: ${err}`); + } + }; + + // 批量生成穿搭图片(并发模式) + const handleBatchGenerateOutfitImages = async (requests: OutfitImageGenerationRequest[]) => { + try { + setGeneratingOutfit(true); + + console.log('🚀 开始批量生成穿搭图片:', requests.length, '个任务'); + + // 使用批量生成方法(并发执行) + const recordIds = await OutfitImageService.batchGenerateOutfitImages(requests); + console.log('✅ 批量穿搭图片任务已创建并提交:', recordIds); + + // 立即重新加载记录显示新创建的任务 + await loadOutfitRecords(); + await loadDashboardStats(); + + // 关闭弹框,让用户在列表中查看进度 + setShowOutfitModal(false); + } catch (err) { + console.error('批量生成穿搭图片失败:', err); + setError(err as string); + } finally { + setGeneratingOutfit(false); + } + }; + // 初始化 useEffect(() => { loadModelDetail(); @@ -576,7 +643,11 @@ const ModelDetail: React.FC = () => { records={outfitRecords} onDelete={handleDeleteOutfitRecord} onRefresh={loadOutfitRecords} + onRetry={handleRetryOutfitRecord} + onLoadMore={loadMoreOutfitRecords} loading={false} + loadingMore={outfitLoadingMore} + hasMore={outfitHasMore} /> @@ -612,6 +683,7 @@ const ModelDetail: React.FC = () => { onClose={() => setShowOutfitModal(false)} model={model} onGenerate={handleGenerateOutfitImages} + onBatchGenerate={handleBatchGenerateOutfitImages} isGenerating={generatingOutfit} /> )} diff --git a/apps/desktop/src/services/outfitImageService.ts b/apps/desktop/src/services/outfitImageService.ts index 0fc1501..acfe224 100644 --- a/apps/desktop/src/services/outfitImageService.ts +++ b/apps/desktop/src/services/outfitImageService.ts @@ -4,7 +4,8 @@ import { OutfitImageGenerationRequest, OutfitImageGenerationResponse, OutfitImageStats, - ModelDashboardStats + ModelDashboardStats, + OutfitImageRecordsResponse } from '../types/outfitImage'; /** @@ -36,11 +37,11 @@ export class OutfitImageService { static async getOutfitImageRecords(modelId: string): Promise { try { console.log('📋 获取模特穿搭图片生成记录:', modelId); - + const records = await invoke('get_outfit_image_records', { modelId }); - + console.log('✅ 获取到穿搭图片记录:', records.length, '条'); return records; } catch (error) { @@ -49,6 +50,31 @@ export class OutfitImageService { } } + /** + * 分页获取模特的穿搭图片生成记录列表 + */ + static async getOutfitImageRecordsPaginated( + modelId: string, + page: number = 1, + pageSize: number = 20 + ): Promise { + try { + console.log('📋 分页获取模特穿搭图片生成记录:', modelId, `页码: ${page}, 每页: ${pageSize}`); + + const response = await invoke('get_outfit_image_records_paginated', { + modelId, + page, + pageSize + }); + + console.log('✅ 获取到穿搭图片记录:', response.records.length, '条', `(总数: ${response.total_count})`); + return response; + } catch (error) { + console.error('❌ 分页获取穿搭图片记录失败:', error); + throw new Error(`分页获取穿搭图片记录失败: ${error}`); + } + } + /** * 创建穿搭图片生成记录 */ @@ -222,4 +248,87 @@ export class OutfitImageService { throw new Error(`获取最近穿搭图片记录失败: ${error}`); } } + + /** + * 重试失败的穿搭图片生成任务 + */ + static async retryOutfitImageGeneration(recordId: string): Promise { + try { + console.log('🔄 重试穿搭图片生成任务:', recordId); + + // 调用后端重试接口 + await invoke('retry_outfit_image_generation', { + recordId + }); + + console.log('✅ 穿搭图片生成任务重试成功'); + } catch (error) { + console.error('❌ 重试穿搭图片生成任务失败:', error); + throw new Error(`重试穿搭图片生成任务失败: ${error}`); + } + } + + /** + * 批量创建穿搭图片生成任务(支持并发执行) + */ + static async createBatchOutfitImageTasks(requests: OutfitImageGenerationRequest[]): Promise { + try { + console.log('🎨 批量创建穿搭图片生成任务:', requests.length, '个'); + + // 并发创建所有任务记录 + const createPromises = requests.map(request => + this.createOutfitImageTask(request) + ); + + const recordIds = await Promise.all(createPromises); + + console.log('✅ 批量穿搭图片任务创建完成:', recordIds); + return recordIds; + } catch (error) { + console.error('❌ 批量穿搭图片任务创建失败:', error); + throw new Error(`批量穿搭图片任务创建失败: ${error}`); + } + } + + /** + * 批量执行穿搭图片生成任务(并发执行) + */ + static async executeBatchOutfitImageTasks(recordIds: string[]): Promise { + try { + console.log('🚀 批量执行穿搭图片生成任务:', recordIds.length, '个'); + + // 并发执行所有任务,不等待结果 + const executePromises = recordIds.map(recordId => + this.executeOutfitImageTask(recordId) + ); + + await Promise.all(executePromises); + + console.log('✅ 批量穿搭图片生成任务已提交到后台'); + } catch (error) { + console.error('❌ 批量穿搭图片任务执行失败:', error); + throw new Error(`批量穿搭图片任务执行失败: ${error}`); + } + } + + /** + * 一键批量生成穿搭图片(创建+执行) + */ + static async batchGenerateOutfitImages(requests: OutfitImageGenerationRequest[]): Promise { + try { + console.log('🎨 一键批量生成穿搭图片:', requests.length, '个任务'); + + // 1. 批量创建任务记录 + const recordIds = await this.createBatchOutfitImageTasks(requests); + + // 2. 批量执行任务(并发) + await this.executeBatchOutfitImageTasks(recordIds); + + console.log('✅ 批量穿搭图片生成完成,任务ID:', recordIds); + return recordIds; + } catch (error) { + console.error('❌ 批量穿搭图片生成失败:', error); + throw new Error(`批量穿搭图片生成失败: ${error}`); + } + } } diff --git a/apps/desktop/src/tests/outfitGeneration.test.ts b/apps/desktop/src/tests/outfitGeneration.test.ts new file mode 100644 index 0000000..b0fe41c --- /dev/null +++ b/apps/desktop/src/tests/outfitGeneration.test.ts @@ -0,0 +1,166 @@ +/** + * 穿搭生成功能测试 + * 测试新实现的重试机制、多商品生成和并发执行功能 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { OutfitImageService } from '../services/outfitImageService'; +import type { OutfitImageGenerationRequest } from '../types/outfitImage'; + +// Mock Tauri invoke +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn() +})); + +describe('OutfitImageService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('重试机制', () => { + it('应该能够重试失败的生成任务', async () => { + const { invoke } = await import('@tauri-apps/api/core'); + const mockInvoke = vi.mocked(invoke); + + mockInvoke.mockResolvedValueOnce(undefined); + + await OutfitImageService.retryOutfitImageGeneration('test-record-id'); + + expect(mockInvoke).toHaveBeenCalledWith('retry_outfit_image_generation', { + recordId: 'test-record-id' + }); + }); + + it('重试失败时应该抛出错误', async () => { + const { invoke } = await import('@tauri-apps/api/core'); + const mockInvoke = vi.mocked(invoke); + + mockInvoke.mockRejectedValueOnce(new Error('重试失败')); + + await expect( + OutfitImageService.retryOutfitImageGeneration('test-record-id') + ).rejects.toThrow('重试穿搭图片生成任务失败'); + }); + }); + + describe('批量生成', () => { + it('应该能够批量创建多个生成任务', async () => { + const { invoke } = await import('@tauri-apps/api/core'); + const mockInvoke = vi.mocked(invoke); + + // Mock 创建任务的返回值 + mockInvoke + .mockResolvedValueOnce('record-1') + .mockResolvedValueOnce('record-2') + .mockResolvedValueOnce('record-3'); + + const requests: OutfitImageGenerationRequest[] = [ + { + model_id: 'model-1', + model_image_id: 'image-1', + product_image_paths: ['product-1.jpg'] + }, + { + model_id: 'model-1', + model_image_id: 'image-1', + product_image_paths: ['product-2.jpg'] + }, + { + model_id: 'model-1', + model_image_id: 'image-1', + product_image_paths: ['product-3.jpg'] + } + ]; + + const recordIds = await OutfitImageService.createBatchOutfitImageTasks(requests); + + expect(recordIds).toEqual(['record-1', 'record-2', 'record-3']); + expect(mockInvoke).toHaveBeenCalledTimes(3); + }); + + it('应该能够批量执行多个生成任务', async () => { + const { invoke } = await import('@tauri-apps/api/core'); + const mockInvoke = vi.mocked(invoke); + + mockInvoke.mockResolvedValue(undefined); + + const recordIds = ['record-1', 'record-2', 'record-3']; + await OutfitImageService.executeBatchOutfitImageTasks(recordIds); + + expect(mockInvoke).toHaveBeenCalledTimes(3); + expect(mockInvoke).toHaveBeenCalledWith('execute_outfit_image_task', { + recordId: 'record-1' + }); + expect(mockInvoke).toHaveBeenCalledWith('execute_outfit_image_task', { + recordId: 'record-2' + }); + expect(mockInvoke).toHaveBeenCalledWith('execute_outfit_image_task', { + recordId: 'record-3' + }); + }); + + it('应该能够一键批量生成穿搭图片', async () => { + const { invoke } = await import('@tauri-apps/api/core'); + const mockInvoke = vi.mocked(invoke); + + // Mock 创建和执行任务 + mockInvoke + .mockResolvedValueOnce('record-1') + .mockResolvedValueOnce('record-2') + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + + const requests: OutfitImageGenerationRequest[] = [ + { + model_id: 'model-1', + model_image_id: 'image-1', + product_image_paths: ['product-1.jpg'] + }, + { + model_id: 'model-1', + model_image_id: 'image-1', + product_image_paths: ['product-2.jpg'] + } + ]; + + const recordIds = await OutfitImageService.batchGenerateOutfitImages(requests); + + expect(recordIds).toEqual(['record-1', 'record-2']); + expect(mockInvoke).toHaveBeenCalledTimes(4); // 2次创建 + 2次执行 + }); + }); + + describe('并发执行', () => { + it('批量任务应该并发执行而不是串行', async () => { + const { invoke } = await import('@tauri-apps/api/core'); + const mockInvoke = vi.mocked(invoke); + + let callOrder: number[] = []; + let callCount = 0; + + mockInvoke.mockImplementation(async () => { + const currentCall = ++callCount; + callOrder.push(currentCall); + // 模拟异步延迟 + await new Promise(resolve => setTimeout(resolve, 10)); + return `record-${currentCall}`; + }); + + const requests: OutfitImageGenerationRequest[] = [ + { model_id: 'model-1', model_image_id: 'image-1', product_image_paths: ['product-1.jpg'] }, + { model_id: 'model-1', model_image_id: 'image-1', product_image_paths: ['product-2.jpg'] }, + { model_id: 'model-1', model_image_id: 'image-1', product_image_paths: ['product-3.jpg'] } + ]; + + const startTime = Date.now(); + await OutfitImageService.createBatchOutfitImageTasks(requests); + const endTime = Date.now(); + + // 并发执行应该比串行执行快 + // 如果是串行执行,3个任务每个10ms延迟应该需要至少30ms + // 如果是并发执行,应该接近10ms + expect(endTime - startTime).toBeLessThan(25); + expect(callOrder).toEqual([1, 2, 3]); + }); + }); +}); diff --git a/apps/desktop/src/types/outfitImage.ts b/apps/desktop/src/types/outfitImage.ts index 669c7eb..9ef05e1 100644 --- a/apps/desktop/src/types/outfitImage.ts +++ b/apps/desktop/src/types/outfitImage.ts @@ -55,6 +55,7 @@ export interface OutfitImageGenerationRequest { product_image_paths: string[]; // 商品图片路径列表 generation_prompt?: string; // 可选的生成提示词 style_preferences?: string[]; // 风格偏好 + product_index?: number; // 商品编号(用于调试文件命名) } export interface OutfitImageGenerationResponse { @@ -75,6 +76,14 @@ export interface OutfitImageStats { failed_records: number; } +export interface OutfitImageRecordsResponse { + records: OutfitImageRecord[]; + total_count: number; + page: number; + page_size: number; + has_more: boolean; +} + // 模特个人看板统计信息 export interface ModelDashboardStats { // 基本信息