feat: 添加系统音色管理功能和修复穿搭生成问题
- 新增系统音色管理模块 - 创建 SystemVoice 数据模型,支持多种音色类型和性别分类 - 添加系统音色数据库表和迁移文件,内置46个预设音色 - 实现 SystemVoiceRepository 仓库层,支持分页、搜索、分类查询 - 添加系统音色 Tauri 命令,提供完整的 API 接口 - 修复穿搭图片生成问题 - 修复 prompt 替换逻辑,避免错误覆盖模特描述节点 - 实现商品编号调试文件命名,支持 debug_replaced_1.json 等格式 - 添加 product_index 字段支持,便于多商品生成调试 - 修复编译错误,确保所有结构体字段完整 - 完善穿搭生成功能 - 添加分页加载功能,支持下拉加载更多记录 - 实现失败重试机制,提供用户友好的错误处理 - 支持多商品并发生成,每个商品独立任务处理 - 添加详细的测试验证文档 内置音色包括: - 基础系统音色:青涩青年、精英青年、霸道青年等 - 精品音色:所有基础音色的 beta 版本 - 童声音色:聪明男童、可爱男童、萌萌女童等 - 角色音色:病娇弟弟、俊朗男友、甜心小玲等 - 节日音色:Santa Claus、Grinch、Rudolph - 英文音色:Arnold、Charming Lady、Sweet Girl等
This commit is contained in:
122
OUTFIT_GENERATION_VERIFICATION.md
Normal file
122
OUTFIT_GENERATION_VERIFICATION.md
Normal file
@@ -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 类型定义
|
||||
|
||||
## 结论
|
||||
|
||||
所有三个主要功能都已成功实现并集成到现有系统中:
|
||||
|
||||
✅ **失败重试机制** - 用户可以轻松重试失败的生成任务
|
||||
✅ **多商品同时生成** - 支持为多个商品创建独立的生成任务
|
||||
✅ **多任务并发执行** - 显著提高了多任务生成的效率
|
||||
|
||||
改进后的系统提供了更好的用户体验、更高的生成效率和更强的错误恢复能力。
|
||||
@@ -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<F>(
|
||||
&self,
|
||||
workflow_path: &str,
|
||||
model_image_url: &str,
|
||||
product_image_url: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
progress_callback: F,
|
||||
product_index: Option<usize>,
|
||||
) -> Result<Vec<String>>
|
||||
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<F>(
|
||||
&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<usize>,
|
||||
) -> Result<Vec<UploadResult>>
|
||||
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<usize>) -> 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并保存
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>, // 商品图片路径列表
|
||||
pub generation_prompt: Option<String>, // 可选的生成提示词
|
||||
pub style_preferences: Option<Vec<String>>, // 风格偏好
|
||||
pub product_index: Option<usize>, // 商品编号(用于调试文件命名)
|
||||
}
|
||||
|
||||
/// 穿搭图片生成响应
|
||||
@@ -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<OutfitImageRecord>,
|
||||
pub total_count: u32,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
319
apps/desktop/src-tauri/src/data/models/system_voice.rs
Normal file
319
apps/desktop/src-tauri/src/data/models/system_voice.rs
Normal file
@@ -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<String>,
|
||||
pub description: Option<String>,
|
||||
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<String>,
|
||||
pub description: Option<String>,
|
||||
pub voice_type: SystemVoiceType,
|
||||
pub gender: VoiceGender,
|
||||
pub language: String,
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
impl SystemVoice {
|
||||
/// 从数据库行创建实例
|
||||
pub fn from_row(row: &Row) -> SqliteResult<Self> {
|
||||
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<SystemVoice> {
|
||||
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<Vec<SystemVoice>> {
|
||||
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<Vec<SystemVoice>> {
|
||||
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<Option<SystemVoice>> {
|
||||
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<CreateSystemVoiceRequest>) -> 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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vec<OutfitImageRecord>> {
|
||||
// 🚨 强制使用连接池,避免死锁
|
||||
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<u32> {
|
||||
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<()> {
|
||||
|
||||
|
||||
@@ -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<Database>,
|
||||
}
|
||||
|
||||
impl SystemVoiceRepository {
|
||||
/// 创建新的系统音色仓库实例
|
||||
pub fn new(database: Arc<Database>) -> Self {
|
||||
Self { database }
|
||||
}
|
||||
|
||||
/// 获取所有活跃的系统音色
|
||||
pub fn get_all_active(&self) -> Result<Vec<SystemVoice>> {
|
||||
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<Vec<SystemVoice>> {
|
||||
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<Option<SystemVoice>> {
|
||||
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<SystemVoice> {
|
||||
self.database.with_connection(|conn| {
|
||||
SystemVoice::create(conn, request)
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("创建系统音色失败: {}", e))
|
||||
}
|
||||
|
||||
/// 批量插入系统音色
|
||||
pub fn batch_insert(&self, voices: Vec<CreateSystemVoiceRequest>) -> 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<bool> {
|
||||
match self.get_by_voice_id(voice_id)? {
|
||||
Some(_) => Ok(true),
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取系统音色总数
|
||||
pub fn count_active(&self) -> Result<i64> {
|
||||
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<Vec<SystemVoice>> {
|
||||
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<Vec<SystemVoice>> {
|
||||
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<Vec<SystemVoice>> {
|
||||
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<Vec<SystemVoice>> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<OutfitImageRecordsResponse, String> {
|
||||
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<String> {
|
||||
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();
|
||||
|
||||
@@ -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<String>,
|
||||
pub gender: Option<String>,
|
||||
pub language: Option<String>,
|
||||
pub keyword: Option<String>,
|
||||
pub page: Option<i64>,
|
||||
pub page_size: Option<i64>,
|
||||
}
|
||||
|
||||
/// 系统音色响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SystemVoiceResponse {
|
||||
pub voices: Vec<SystemVoice>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub page_size: i64,
|
||||
pub total_pages: i64,
|
||||
}
|
||||
|
||||
/// 获取所有活跃的系统音色
|
||||
#[command]
|
||||
pub async fn get_system_voices(
|
||||
database: State<'_, Arc<Database>>,
|
||||
) -> Result<Vec<SystemVoice>, 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<Database>>,
|
||||
) -> Result<Vec<SystemVoice>, 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<Database>>,
|
||||
) -> Result<Vec<SystemVoice>, 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<Database>>,
|
||||
) -> Result<Vec<SystemVoice>, 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<Database>>,
|
||||
) -> Result<Vec<SystemVoice>, 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<Database>>,
|
||||
) -> Result<SystemVoiceResponse, String> {
|
||||
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<Database>>,
|
||||
) -> Result<Option<SystemVoice>, 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<Database>>,
|
||||
) -> Result<bool, String> {
|
||||
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<Database>>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
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)
|
||||
}
|
||||
@@ -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<void>;
|
||||
onRefresh: () => Promise<void>;
|
||||
onRetry?: (record: OutfitImageRecord) => Promise<void>;
|
||||
onLoadMore?: () => Promise<void>;
|
||||
loading?: boolean;
|
||||
loadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -37,7 +43,11 @@ export const OutfitImageGallery: React.FC<OutfitImageGalleryProps> = ({
|
||||
records,
|
||||
onDelete,
|
||||
onRefresh,
|
||||
onRetry,
|
||||
onLoadMore,
|
||||
loading = false,
|
||||
loadingMore = false,
|
||||
hasMore = false,
|
||||
className = ''
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
@@ -68,6 +78,9 @@ export const OutfitImageGallery: React.FC<OutfitImageGalleryProps> = ({
|
||||
deleting: false
|
||||
});
|
||||
|
||||
// 重试状态管理
|
||||
const [retryingRecords, setRetryingRecords] = useState<Set<string>>(new Set());
|
||||
|
||||
// 过滤记录
|
||||
const filteredRecords = records.filter(record => {
|
||||
const matchesFilter = filter === 'all' || record.status === filter;
|
||||
@@ -105,6 +118,32 @@ export const OutfitImageGallery: React.FC<OutfitImageGalleryProps> = ({
|
||||
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<OutfitImageGalleryProps> = ({
|
||||
<span className="ml-1">{getStatusLabel(record.status)}</span>
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
{record.status === OutfitImageStatus.Failed && (
|
||||
<button
|
||||
onClick={() => handleRetry(record)}
|
||||
disabled={retryingRecords.has(record.id)}
|
||||
className="p-1 text-blue-600 hover:bg-blue-50 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="重试生成"
|
||||
>
|
||||
{retryingRecords.has(record.id) ? (
|
||||
<Loader className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteClick(record)}
|
||||
className="p-1 text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
@@ -333,9 +386,12 @@ export const OutfitImageGallery: React.FC<OutfitImageGalleryProps> = ({
|
||||
{record.status === OutfitImageStatus.Processing && (
|
||||
<div className="text-center py-8">
|
||||
<Loader className="w-8 h-8 text-purple-500 animate-spin mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
生成进度: {Math.round(record.progress * 100)}%
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
商品图片: {record.product_images.length}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -381,9 +437,26 @@ export const OutfitImageGallery: React.FC<OutfitImageGalleryProps> = ({
|
||||
{record.status === OutfitImageStatus.Failed && (
|
||||
<div className="text-center py-8">
|
||||
<XCircle className="w-8 h-8 text-red-500 mx-auto mb-2" />
|
||||
<p className="text-sm text-red-600">
|
||||
<p className="text-sm text-red-600 mb-3">
|
||||
{record.error_message || '生成失败'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleRetry(record)}
|
||||
disabled={retryingRecords.has(record.id)}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{retryingRecords.has(record.id) ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 animate-spin mr-1" />
|
||||
重试中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
重试
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -467,7 +540,22 @@ export const OutfitImageGallery: React.FC<OutfitImageGalleryProps> = ({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{record.status === OutfitImageStatus.Failed && (
|
||||
<button
|
||||
onClick={() => handleRetry(record)}
|
||||
disabled={retryingRecords.has(record.id)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="重试生成"
|
||||
>
|
||||
{retryingRecords.has(record.id) ? (
|
||||
<Loader className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleDeleteClick(record)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
@@ -483,6 +571,26 @@ export const OutfitImageGallery: React.FC<OutfitImageGalleryProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载更多按钮 */}
|
||||
{hasMore && (
|
||||
<div className="mt-8 text-center">
|
||||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 animate-spin inline mr-2" />
|
||||
加载中...
|
||||
</>
|
||||
) : (
|
||||
`加载更多 (${records.length} 条记录)`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<DeleteConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface OutfitImageGenerationModalProps {
|
||||
onClose: () => void;
|
||||
model: Model;
|
||||
onGenerate: (request: OutfitImageGenerationRequest) => Promise<void>;
|
||||
onBatchGenerate?: (requests: OutfitImageGenerationRequest[]) => Promise<void>;
|
||||
isGenerating?: boolean;
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@ export const OutfitImageGenerationModal: React.FC<OutfitImageGenerationModalProp
|
||||
onClose,
|
||||
model,
|
||||
onGenerate,
|
||||
onBatchGenerate,
|
||||
isGenerating = false
|
||||
}) => {
|
||||
// 键盘事件处理
|
||||
@@ -90,8 +92,10 @@ export const OutfitImageGenerationModal: React.FC<OutfitImageGenerationModalProp
|
||||
modelId={model.id}
|
||||
modelPhotos={model.photos}
|
||||
onGenerate={onGenerate}
|
||||
onBatchGenerate={onBatchGenerate}
|
||||
onClose={onClose}
|
||||
isGenerating={isGenerating}
|
||||
enableBatchMode={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,11 @@ interface OutfitImageGeneratorProps {
|
||||
modelId: string;
|
||||
modelPhotos: ModelPhoto[];
|
||||
onGenerate: (request: OutfitImageGenerationRequest) => Promise<void>;
|
||||
onBatchGenerate?: (requests: OutfitImageGenerationRequest[]) => Promise<void>;
|
||||
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<OutfitImageGeneratorProps> = ({
|
||||
modelId,
|
||||
modelPhotos,
|
||||
onGenerate,
|
||||
onBatchGenerate,
|
||||
onClose,
|
||||
isGenerating = false,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
enableBatchMode = true
|
||||
}) => {
|
||||
const [selectedModelImageId, setSelectedModelImageId] = useState<string>('');
|
||||
const [productImages, setProductImages] = useState<string[]>([]);
|
||||
@@ -225,15 +229,37 @@ export const OutfitImageGenerator: React.FC<OutfitImageGeneratorProps> = ({
|
||||
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<OutfitImageGeneratorProps> = ({
|
||||
<Upload className="w-5 h-5 mr-2" />
|
||||
上传商品图片
|
||||
<span className="text-sm text-gray-500 ml-2">({productImages.length}/10)</span>
|
||||
{enableBatchMode && productImages.length > 1 && (
|
||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full ml-2">
|
||||
批量模式
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{/* 拖拽上传区域 */}
|
||||
@@ -612,12 +643,18 @@ export const OutfitImageGenerator: React.FC<OutfitImageGeneratorProps> = ({
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
生成中...
|
||||
{enableBatchMode && productImages.length > 1 ?
|
||||
`批量生成中... (${productImages.length}个任务)` :
|
||||
'生成中...'
|
||||
}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 className="w-5 h-5 mr-2" />
|
||||
生成穿搭图片
|
||||
{enableBatchMode && productImages.length > 1 ?
|
||||
`批量生成穿搭图片 (${productImages.length}个任务)` :
|
||||
'生成穿搭图片'
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -633,8 +670,9 @@ export const OutfitImageGenerator: React.FC<OutfitImageGeneratorProps> = ({
|
||||
<li>• 选择一张模特的个人形象照片作为基础</li>
|
||||
<li>• 上传要搭配的商品图片(服装、配饰等)</li>
|
||||
<li>• 可选择添加生成提示词来指导AI生成</li>
|
||||
<li>• 点击生成按钮,AI将为您创建穿搭效果图</li>
|
||||
<li>• 生成的图片数量与上传的商品图片数量相等</li>
|
||||
<li>• 点击生成按钮,AI将为每个商品创建独立的生成任务</li>
|
||||
<li>• 多个商品将同时生成,每个商品对应一个任务记录</li>
|
||||
<li>• 可在生成记录中查看每个任务的进度和结果</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,12 @@ const ModelDetail: React.FC = () => {
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [outfitRecords, setOutfitRecords] = useState<OutfitImageRecord[]>([]);
|
||||
|
||||
// 分页状态
|
||||
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<TabId>('overview');
|
||||
const [dynamics] = useState<ModelDynamic[]>([]);
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -612,6 +683,7 @@ const ModelDetail: React.FC = () => {
|
||||
onClose={() => setShowOutfitModal(false)}
|
||||
model={model}
|
||||
onGenerate={handleGenerateOutfitImages}
|
||||
onBatchGenerate={handleBatchGenerateOutfitImages}
|
||||
isGenerating={generatingOutfit}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<OutfitImageRecord[]> {
|
||||
try {
|
||||
console.log('📋 获取模特穿搭图片生成记录:', modelId);
|
||||
|
||||
|
||||
const records = await invoke<OutfitImageRecord[]>('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<OutfitImageRecordsResponse> {
|
||||
try {
|
||||
console.log('📋 分页获取模特穿搭图片生成记录:', modelId, `页码: ${page}, 每页: ${pageSize}`);
|
||||
|
||||
const response = await invoke<OutfitImageRecordsResponse>('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<void> {
|
||||
try {
|
||||
console.log('🔄 重试穿搭图片生成任务:', recordId);
|
||||
|
||||
// 调用后端重试接口
|
||||
await invoke<void>('retry_outfit_image_generation', {
|
||||
recordId
|
||||
});
|
||||
|
||||
console.log('✅ 穿搭图片生成任务重试成功');
|
||||
} catch (error) {
|
||||
console.error('❌ 重试穿搭图片生成任务失败:', error);
|
||||
throw new Error(`重试穿搭图片生成任务失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建穿搭图片生成任务(支持并发执行)
|
||||
*/
|
||||
static async createBatchOutfitImageTasks(requests: OutfitImageGenerationRequest[]): Promise<string[]> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
166
apps/desktop/src/tests/outfitGeneration.test.ts
Normal file
166
apps/desktop/src/tests/outfitGeneration.test.ts
Normal file
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
// 基本信息
|
||||
|
||||
Reference in New Issue
Block a user