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:
imeepos
2025-07-31 10:54:19 +08:00
parent 5e92c47835
commit fcc0b64488
22 changed files with 2141 additions and 215 deletions

View 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 类型定义
## 结论
所有三个主要功能都已成功实现并集成到现有系统中:
**失败重试机制** - 用户可以轻松重试失败的生成任务
**多商品同时生成** - 支持为多个商品创建独立的生成任务
**多任务并发执行** - 显著提高了多任务生成的效率
改进后的系统提供了更好的用户体验、更高的生成效率和更强的错误恢复能力。

View File

@@ -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并保存

View File

@@ -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;

View File

@@ -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,
}

View 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(())
}

View File

@@ -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;

View File

@@ -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<()> {

View File

@@ -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))
}
}

View File

@@ -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,

View File

@@ -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'));

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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();

View File

@@ -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)
}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
/>
)}

View File

@@ -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}`);
}
}
}

View 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]);
});
});
});

View File

@@ -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 {
// 基本信息