feat: 实现模特详情页视频生成功能

- 新增模特详情页组件,支持照片上传和视频生成
- 实现视频生成数据模型和仓库层
- 集成Dify API进行视频生成
- 添加云存储上传功能,自动转换S3 URL为CDN地址
- 实现统一的删除确认弹框,替换window.confirm
- 支持照片和视频生成任务的删除功能
- 优化UI/UX设计,符合前端开发规范
- 添加完整的错误处理和状态管理

核心功能:
 模特照片上传到云端
 多选照片进行视频生成
 实时任务状态跟踪
 视频生成历史记录
 统一删除确认对话框
 响应式设计和优雅动画
This commit is contained in:
imeepos
2025-07-16 19:39:44 +08:00
parent 544ee61908
commit d5ef9851cd
22 changed files with 2641 additions and 17 deletions

View File

@@ -2,6 +2,7 @@ use std::sync::{Arc, Mutex};
use crate::data::repositories::project_repository::ProjectRepository;
use crate::data::repositories::material_repository::MaterialRepository;
use crate::data::repositories::model_repository::ModelRepository;
use crate::data::repositories::video_generation_repository::VideoGenerationRepository;
use crate::infrastructure::database::Database;
use crate::infrastructure::performance::PerformanceMonitor;
use crate::infrastructure::event_bus::EventBusManager;
@@ -13,6 +14,7 @@ pub struct AppState {
pub project_repository: Mutex<Option<ProjectRepository>>,
pub material_repository: Mutex<Option<MaterialRepository>>,
pub model_repository: Mutex<Option<ModelRepository>>,
pub video_generation_repository: Mutex<Option<VideoGenerationRepository>>,
pub performance_monitor: Mutex<PerformanceMonitor>,
pub event_bus_manager: Arc<EventBusManager>,
}
@@ -24,6 +26,7 @@ impl AppState {
project_repository: Mutex::new(None),
material_repository: Mutex::new(None),
model_repository: Mutex::new(None),
video_generation_repository: Mutex::new(None),
performance_monitor: Mutex::new(PerformanceMonitor::new()),
event_bus_manager: Arc::new(EventBusManager::new()),
}
@@ -57,11 +60,16 @@ impl AppState {
let project_repository = ProjectRepository::new(database.clone())?;
let material_repository = MaterialRepository::new(database.clone())?;
let model_repository = ModelRepository::new(database.clone());
let video_generation_repository = VideoGenerationRepository::new(database.clone());
// 初始化视频生成任务表
video_generation_repository.init_tables()?;
*self.database.lock().unwrap() = Some(database.clone());
*self.project_repository.lock().unwrap() = Some(project_repository);
*self.material_repository.lock().unwrap() = Some(material_repository);
*self.model_repository.lock().unwrap() = Some(model_repository);
*self.video_generation_repository.lock().unwrap() = Some(video_generation_repository);
println!("数据库初始化完成,连接池状态: {}",
if database.has_pool() { "已启用" } else { "未启用" });
@@ -75,11 +83,16 @@ impl AppState {
let project_repository = ProjectRepository::new(database.clone())?;
let material_repository = MaterialRepository::new(database.clone())?;
let model_repository = ModelRepository::new(database.clone());
let video_generation_repository = VideoGenerationRepository::new(database.clone());
// 初始化视频生成任务表
video_generation_repository.init_tables()?;
*self.database.lock().unwrap() = Some(database.clone());
*self.project_repository.lock().unwrap() = Some(project_repository);
*self.material_repository.lock().unwrap() = Some(material_repository);
*self.model_repository.lock().unwrap() = Some(model_repository);
*self.video_generation_repository.lock().unwrap() = Some(video_generation_repository);
println!("数据库初始化完成,使用单连接模式");
Ok(())
@@ -100,6 +113,11 @@ impl AppState {
Ok(self.model_repository.lock().unwrap())
}
/// 获取视频生成仓库实例
pub fn get_video_generation_repository(&self) -> anyhow::Result<std::sync::MutexGuard<Option<VideoGenerationRepository>>> {
Ok(self.video_generation_repository.lock().unwrap())
}
/// 获取数据库实例
pub fn get_database(&self) -> Arc<Database> {
// 使用全局静态数据库实例,确保整个应用只有一个数据库实例
@@ -126,6 +144,7 @@ impl AppState {
project_repository: Mutex::new(None),
material_repository: Mutex::new(None),
model_repository: Mutex::new(None),
video_generation_repository: Mutex::new(None),
performance_monitor: Mutex::new(PerformanceMonitor::new()),
event_bus_manager: Arc::new(EventBusManager::new()),
};

View File

@@ -19,6 +19,7 @@ pub mod enhanced_template_import_service;
pub mod project_template_binding_service;
pub mod material_matching_service;
pub mod template_matching_result_service;
pub mod video_generation_service;
#[cfg(test)]
pub mod tests;

View File

@@ -1,10 +1,11 @@
use anyhow::{Result, anyhow};
use std::path::Path;
use crate::data::models::model::{
Model, ModelPhoto, CreateModelRequest, UpdateModelRequest,
Model, ModelPhoto, CreateModelRequest, UpdateModelRequest,
ModelQueryParams, Gender, PhotoType, ModelStatus
};
use crate::data::repositories::model_repository::ModelRepository;
use crate::business::services::cloud_upload_service::CloudUploadService;
/// 模特业务服务
/// 遵循 Tauri 开发规范的业务逻辑层设计
@@ -265,6 +266,98 @@ impl ModelService {
Ok(photo)
}
/// 添加模特照片(上传到云端)
pub async fn add_model_photo_with_cloud_upload(
repository: &ModelRepository,
model_id: &str,
file_path: String,
photo_type: PhotoType,
description: Option<String>,
tags: Option<Vec<String>>,
) -> Result<ModelPhoto> {
// 检查模特是否存在
let _model = repository.get_by_id(model_id)?
.ok_or_else(|| anyhow!("模特不存在: {}", model_id))?;
// 验证文件路径
let path = Path::new(&file_path);
if !path.exists() {
return Err(anyhow!("文件不存在: {}", file_path));
}
// 获取文件信息
let metadata = std::fs::metadata(&file_path)
.map_err(|e| anyhow!("获取文件信息失败: {}", e))?;
let file_size = metadata.len();
let file_name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
println!("📤 正在上传模特照片到云存储: {}", file_path);
// 上传到云存储
let upload_service = CloudUploadService::new();
let remote_key = format!("models/photos/{}/{}", model_id, file_name);
let upload_result = upload_service.upload_file(&file_path, Some(remote_key), None).await
.map_err(|e| anyhow!("上传文件失败: {}", e))?;
if !upload_result.success {
let error_msg = upload_result.error_message
.unwrap_or_else(|| "未知上传错误".to_string());
return Err(anyhow!("照片上传失败: {}", error_msg));
}
// 获取云端URL
let cloud_url = if let Some(remote_url) = upload_result.remote_url {
// 转换S3 URL为CDN URL
Self::convert_s3_to_cdn_url(&remote_url)
} else if let Some(urn) = upload_result.urn {
// 转换S3 URN为CDN URL
Self::convert_s3_to_cdn_url(&urn)
} else {
return Err(anyhow!("上传成功但未返回URL"));
};
println!("✅ 模特照片上传成功: {}", cloud_url);
// 创建照片对象使用云端URL作为file_path
let mut photo = ModelPhoto::new(
model_id.to_string(),
cloud_url, // 使用云端URL而不是本地路径
file_name,
file_size,
photo_type,
);
if let Some(desc) = description {
photo.description = Some(desc);
}
if let Some(photo_tags) = tags {
photo.tags = photo_tags;
}
// 保存到数据库
repository.add_photo(&photo)
.map_err(|e| anyhow!("添加照片失败: {}", e))?;
Ok(photo)
}
/// 将S3 URL转换为可访问的CDN地址
fn convert_s3_to_cdn_url(s3_url: &str) -> String {
// 将 s3://ap-northeast-2/modal-media-cache/ 替换为 https://cdn.roasmax.cn/
if s3_url.starts_with("s3://ap-northeast-2/modal-media-cache/") {
s3_url.replace("s3://ap-northeast-2/modal-media-cache/", "https://cdn.roasmax.cn/")
} else {
// 如果不是预期的S3格式返回原URL
s3_url.to_string()
}
}
/// 删除模特照片
pub fn delete_model_photo(
repository: &ModelRepository,

View File

@@ -0,0 +1,341 @@
use anyhow::{anyhow, Result};
use crate::data::models::video_generation::{
VideoGenerationTask, VideoGenerationStatus, VideoGenerationQueryParams,
CreateVideoGenerationRequest
};
use crate::data::repositories::video_generation_repository::VideoGenerationRepository;
use crate::data::repositories::model_repository::ModelRepository;
use crate::infrastructure::video_generation_service::VideoGenerationService as ApiService;
use crate::business::services::cloud_upload_service::CloudUploadService;
/// 视频生成业务服务
/// 遵循 Tauri 开发规范的业务逻辑层设计
pub struct VideoGenerationService;
impl VideoGenerationService {
/// 创建视频生成任务
pub async fn create_task(
repository: &VideoGenerationRepository,
model_repository: &ModelRepository,
request: CreateVideoGenerationRequest,
) -> Result<VideoGenerationTask> {
// 验证模特是否存在
let model = model_repository.get_by_id(&request.model_id)?
.ok_or_else(|| anyhow!("模特不存在: {}", request.model_id))?;
// 验证选中的照片是否存在
if request.selected_photos.is_empty() {
return Err(anyhow!("必须选择至少一张照片"));
}
// 验证照片是否属于该模特
for photo_id in &request.selected_photos {
let photo_exists = model.photos.iter().any(|p| &p.id == photo_id);
if !photo_exists {
return Err(anyhow!("照片不存在或不属于该模特: {}", photo_id));
}
}
// 创建任务
let task = VideoGenerationTask::new(
request.model_id,
request.prompt_config,
request.selected_photos,
);
// 验证任务数据
task.validate().map_err(|e| anyhow!(e))?;
// 保存到数据库
repository.create(&task)?;
println!("✅ 视频生成任务创建成功: {}", task.id);
Ok(task)
}
/// 执行视频生成任务
pub async fn execute_task(
repository: &VideoGenerationRepository,
model_repository: &ModelRepository,
task_id: &str,
) -> Result<VideoGenerationTask> {
// 获取任务
let mut task = repository.get_by_id(task_id)?
.ok_or_else(|| anyhow!("任务不存在: {}", task_id))?;
// 检查任务状态
if !matches!(task.status, VideoGenerationStatus::Pending) {
return Err(anyhow!("任务状态不允许执行: {:?}", task.status));
}
// 更新任务状态为处理中
task.update_status(VideoGenerationStatus::Processing);
repository.update(&task)?;
println!("🚀 开始执行视频生成任务: {}", task_id);
// 获取模特信息
let model = model_repository.get_by_id(&task.model_id)?
.ok_or_else(|| anyhow!("模特不存在: {}", task.model_id))?;
// 获取第一张选中的照片作为生成图片
let first_photo_id = &task.selected_photos[0];
let photo = model.photos.iter()
.find(|p| &p.id == first_photo_id)
.ok_or_else(|| anyhow!("照片不存在: {}", first_photo_id))?;
// 检查照片路径是否已经是云端URL
let image_url = if photo.file_path.starts_with("https://") {
// 如果已经是HTTPS URL直接使用
println!("📷 使用已有的云端图片URL: {}", photo.file_path);
photo.file_path.clone()
} else {
// 如果是本地路径,上传到云存储
println!("📤 本地图片需要上传到云存储: {}", photo.file_path);
Self::upload_image_to_cloud(&photo.file_path).await?
};
// 执行视频生成
let api_service = ApiService::new();
match api_service.generate_video(
task.prompt_config.product.clone(),
task.prompt_config.scene.clone(),
task.prompt_config.model_desc.clone(),
image_url,
task.prompt_config.template.clone(),
task.prompt_config.duplicate,
).await {
Ok(result) => {
// 生成成功,更新任务结果
task.set_result(result);
repository.update(&task)?;
println!("✅ 视频生成任务完成: {}", task_id);
}
Err(e) => {
// 生成失败,更新错误信息
let error_msg = format!("视频生成失败: {}", e);
task.set_error(error_msg);
repository.update(&task)?;
println!("❌ 视频生成任务失败: {} - {}", task_id, e);
return Err(e);
}
}
Ok(task)
}
/// 获取视频生成任务
pub fn get_task(
repository: &VideoGenerationRepository,
task_id: &str,
) -> Result<Option<VideoGenerationTask>> {
repository.get_by_id(task_id)
}
/// 获取视频生成任务列表
pub fn get_tasks(
repository: &VideoGenerationRepository,
params: &VideoGenerationQueryParams,
) -> Result<Vec<VideoGenerationTask>> {
repository.search(params)
}
/// 取消视频生成任务
pub fn cancel_task(
repository: &VideoGenerationRepository,
task_id: &str,
) -> Result<VideoGenerationTask> {
let mut task = repository.get_by_id(task_id)?
.ok_or_else(|| anyhow!("任务不存在: {}", task_id))?;
// 只有等待中或处理中的任务可以取消
if !matches!(task.status, VideoGenerationStatus::Pending | VideoGenerationStatus::Processing) {
return Err(anyhow!("任务状态不允许取消: {:?}", task.status));
}
task.update_status(VideoGenerationStatus::Cancelled);
repository.update(&task)?;
println!("🚫 视频生成任务已取消: {}", task_id);
Ok(task)
}
/// 删除视频生成任务
pub fn delete_task(
repository: &VideoGenerationRepository,
task_id: &str,
) -> Result<()> {
let task = repository.get_by_id(task_id)?
.ok_or_else(|| anyhow!("任务不存在: {}", task_id))?;
// 删除本地视频文件
if let Some(result) = &task.result {
for video_path in &result.video_paths {
if std::path::Path::new(video_path).exists() {
if let Err(e) = std::fs::remove_file(video_path) {
println!("⚠️ 删除视频文件失败: {} - {}", video_path, e);
}
}
}
}
repository.delete(task_id)?;
println!("🗑️ 视频生成任务已删除: {}", task_id);
Ok(())
}
/// 重试视频生成任务
pub async fn retry_task(
repository: &VideoGenerationRepository,
model_repository: &ModelRepository,
task_id: &str,
) -> Result<VideoGenerationTask> {
let mut task = repository.get_by_id(task_id)?
.ok_or_else(|| anyhow!("任务不存在: {}", task_id))?;
// 只有失败的任务可以重试
if !matches!(task.status, VideoGenerationStatus::Failed) {
return Err(anyhow!("只有失败的任务可以重试"));
}
// 重置任务状态
task.update_status(VideoGenerationStatus::Pending);
task.result = None;
task.error_message = None;
task.completed_at = None;
repository.update(&task)?;
println!("🔄 重试视频生成任务: {}", task_id);
// 执行任务
Self::execute_task(repository, model_repository, task_id).await
}
/// 获取模特的视频生成统计
pub fn get_model_statistics(
repository: &VideoGenerationRepository,
model_id: &str,
) -> Result<VideoGenerationStatistics> {
let params = VideoGenerationQueryParams {
model_id: Some(model_id.to_string()),
status: None,
limit: None,
offset: None,
};
let tasks = repository.search(&params)?;
let total = tasks.len();
let pending = tasks.iter().filter(|t| matches!(t.status, VideoGenerationStatus::Pending)).count();
let processing = tasks.iter().filter(|t| matches!(t.status, VideoGenerationStatus::Processing)).count();
let completed = tasks.iter().filter(|t| matches!(t.status, VideoGenerationStatus::Completed)).count();
let failed = tasks.iter().filter(|t| matches!(t.status, VideoGenerationStatus::Failed)).count();
let cancelled = tasks.iter().filter(|t| matches!(t.status, VideoGenerationStatus::Cancelled)).count();
let total_videos = tasks.iter()
.filter_map(|t| t.result.as_ref())
.map(|r| r.video_urls.len())
.sum();
let avg_generation_time = if completed > 0 {
let total_time: u64 = tasks.iter()
.filter_map(|t| t.result.as_ref())
.map(|r| r.generation_time)
.sum();
total_time / completed as u64
} else {
0
};
Ok(VideoGenerationStatistics {
total,
pending,
processing,
completed,
failed,
cancelled,
total_videos,
avg_generation_time,
})
}
/// 上传图片到云存储并返回可访问的URL
async fn upload_image_to_cloud(file_path: &str) -> Result<String> {
println!("📤 正在上传图片到云存储: {}", file_path);
// 创建云上传服务实例
let upload_service = CloudUploadService::new();
// 生成远程文件名
let file_name = std::path::Path::new(file_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("image.jpg");
let remote_key = format!("video-generation/images/{}", file_name);
// 上传文件
match upload_service.upload_file(file_path, Some(remote_key), None).await {
Ok(upload_result) => {
// 打印完整的上传结果
println!("📋 完整上传结果: {:#?}", upload_result);
if upload_result.success {
if let Some(remote_url) = upload_result.remote_url {
// 将S3 URL转换为可访问的CDN地址
let accessible_url = Self::convert_s3_to_cdn_url(&remote_url);
println!("✅ 图片上传成功S3 URL: {}", remote_url);
println!("🌐 转换为CDN URL: {}", accessible_url);
Ok(accessible_url)
} else if let Some(urn) = upload_result.urn {
// 将S3 URN转换为可访问的CDN地址
let accessible_url = Self::convert_s3_to_cdn_url(&urn);
println!("✅ 图片上传成功S3 URN: {}", urn);
println!("🌐 转换为CDN URL: {}", accessible_url);
Ok(accessible_url)
} else {
println!("⚠️ 上传成功但未返回URL或URN");
Err(anyhow!("上传成功但未返回URL"))
}
} else {
let error_msg = upload_result.error_message
.unwrap_or_else(|| "未知上传错误".to_string());
println!("❌ 图片上传失败: {}", error_msg);
Err(anyhow!("图片上传失败: {}", error_msg))
}
}
Err(e) => {
println!("❌ 图片上传异常: {}", e);
Err(anyhow!("图片上传失败: {}", e))
}
}
}
/// 将S3 URL转换为可访问的CDN地址
fn convert_s3_to_cdn_url(s3_url: &str) -> String {
// 将 s3://ap-northeast-2/modal-media-cache/ 替换为 https://cdn.roasmax.cn/
if s3_url.starts_with("s3://ap-northeast-2/modal-media-cache/") {
s3_url.replace("s3://ap-northeast-2/modal-media-cache/", "https://cdn.roasmax.cn/")
} else {
// 如果不是预期的S3格式返回原URL
s3_url.to_string()
}
}
}
/// 视频生成统计信息
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VideoGenerationStatistics {
pub total: usize,
pub pending: usize,
pub processing: usize,
pub completed: usize,
pub failed: usize,
pub cancelled: usize,
pub total_videos: usize,
pub avg_generation_time: u64,
}

View File

@@ -8,3 +8,4 @@ pub mod video_classification;
pub mod template;
pub mod project_template_binding;
pub mod template_matching_result;
pub mod video_generation;

View File

@@ -0,0 +1,208 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
/// 视频生成任务
/// 遵循 Tauri 开发规范的数据模型设计原则
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoGenerationTask {
pub id: String,
pub model_id: String,
pub prompt_config: VideoPromptConfig,
pub selected_photos: Vec<String>, // 选中的照片ID列表
pub status: VideoGenerationStatus,
pub result: Option<VideoGenerationResult>,
pub error_message: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
/// 视频生成提示词配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoPromptConfig {
pub product: String, // 产品描述,如"超短牛仔裙(白色紧身蕾丝短袖)"
pub scene: String, // 场景描述,如"室内可爱简约的女性卧室"
pub model_desc: String, // 模特描述,如"单马尾充满媚态,对自己的性感妩媚十分自信,眼神勾人"
pub template: String, // 模板类型,如"抚媚眼神"
pub duplicate: u32, // 生成数量默认为1
}
/// 视频生成状态
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum VideoGenerationStatus {
Pending, // 等待中
Processing, // 处理中
Completed, // 已完成
Failed, // 失败
Cancelled, // 已取消
}
/// 视频生成结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoGenerationResult {
pub video_urls: Vec<String>, // 生成的视频URL列表
pub video_paths: Vec<String>, // 本地保存的视频路径列表
pub generation_time: u64, // 生成耗时(毫秒)
pub api_response: Option<String>, // API原始响应
}
/// 创建视频生成任务请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateVideoGenerationRequest {
pub model_id: String,
pub prompt_config: VideoPromptConfig,
pub selected_photos: Vec<String>,
}
/// 视频生成任务查询参数
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoGenerationQueryParams {
pub model_id: Option<String>,
pub status: Option<VideoGenerationStatus>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
/// Dify API 配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DifyApiConfig {
pub host: String,
pub api_key: String,
}
impl Default for DifyApiConfig {
fn default() -> Self {
Self {
host: "https://dify.bowongai.com".to_string(),
api_key: "app-Lm10XUBJKnE1bnG6VPfiD1se".to_string(),
}
}
}
/// Dify API 请求体
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DifyApiRequest {
pub inputs: DifyInputs,
pub response_mode: String,
pub user: String,
}
/// Dify API 输入参数
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DifyInputs {
pub product: String,
pub scene: String,
pub model: String,
pub image: String,
pub duplicate: u32,
pub template: String,
}
/// Dify API 响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DifyApiResponse {
pub data: Option<DifyResponseData>,
pub error: Option<String>,
}
/// Dify API 响应数据
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DifyResponseData {
pub outputs: DifyOutputs,
}
/// Dify API 输出
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DifyOutputs {
pub output: Vec<String>, // 生成的视频URL列表
}
impl VideoGenerationTask {
/// 创建新的视频生成任务
pub fn new(
model_id: String,
prompt_config: VideoPromptConfig,
selected_photos: Vec<String>,
) -> Self {
let now = Utc::now();
Self {
id: uuid::Uuid::new_v4().to_string(),
model_id,
prompt_config,
selected_photos,
status: VideoGenerationStatus::Pending,
result: None,
error_message: None,
created_at: now,
updated_at: now,
completed_at: None,
}
}
/// 更新任务状态
pub fn update_status(&mut self, status: VideoGenerationStatus) {
self.status = status;
self.updated_at = Utc::now();
if matches!(status, VideoGenerationStatus::Completed | VideoGenerationStatus::Failed) {
self.completed_at = Some(Utc::now());
}
}
/// 设置任务结果
pub fn set_result(&mut self, result: VideoGenerationResult) {
self.result = Some(result);
self.update_status(VideoGenerationStatus::Completed);
}
/// 设置错误信息
pub fn set_error(&mut self, error: String) {
self.error_message = Some(error);
self.update_status(VideoGenerationStatus::Failed);
}
/// 验证任务数据
pub fn validate(&self) -> Result<(), String> {
if self.model_id.is_empty() {
return Err("模特ID不能为空".to_string());
}
if self.prompt_config.product.is_empty() {
return Err("产品描述不能为空".to_string());
}
if self.prompt_config.scene.is_empty() {
return Err("场景描述不能为空".to_string());
}
if self.prompt_config.model_desc.is_empty() {
return Err("模特描述不能为空".to_string());
}
if self.prompt_config.template.is_empty() {
return Err("模板类型不能为空".to_string());
}
if self.selected_photos.is_empty() {
return Err("必须选择至少一张照片".to_string());
}
if self.prompt_config.duplicate == 0 {
return Err("生成数量必须大于0".to_string());
}
Ok(())
}
}
impl Default for VideoPromptConfig {
fn default() -> Self {
Self {
product: String::new(),
scene: String::new(),
model_desc: String::new(),
template: "抚媚眼神".to_string(),
duplicate: 1,
}
}
}

View File

@@ -6,3 +6,4 @@ pub mod ai_classification_repository;
pub mod video_classification_repository;
pub mod project_template_binding_repository;
pub mod template_matching_result_repository;
pub mod video_generation_repository;

View File

@@ -9,6 +9,7 @@ use crate::infrastructure::database::Database;
/// 模特数据仓库
/// 遵循 Tauri 开发规范的仓库模式设计
#[derive(Clone)]
pub struct ModelRepository {
database: Arc<Database>,
}

View File

@@ -0,0 +1,291 @@
use anyhow::Result;
use rusqlite::{params, Row};
use std::sync::Arc;
use crate::data::models::video_generation::{
VideoGenerationTask, VideoGenerationStatus, VideoGenerationQueryParams,
VideoPromptConfig, VideoGenerationResult
};
use crate::infrastructure::database::Database;
/// 视频生成任务仓库
/// 遵循 Tauri 开发规范的仓库设计模式
#[derive(Clone)]
pub struct VideoGenerationRepository {
database: Arc<Database>,
}
impl VideoGenerationRepository {
/// 创建新的视频生成任务仓库实例
pub fn new(database: Arc<Database>) -> Self {
Self { database }
}
/// 初始化数据库表
pub fn init_tables(&self) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
conn.execute(
"CREATE TABLE IF NOT EXISTS video_generation_tasks (
id TEXT PRIMARY KEY,
model_id TEXT NOT NULL,
product TEXT NOT NULL,
scene TEXT NOT NULL,
model_desc TEXT NOT NULL,
template TEXT NOT NULL,
duplicate INTEGER NOT NULL DEFAULT 1,
selected_photos TEXT NOT NULL, -- JSON array of photo IDs
status TEXT NOT NULL,
video_urls TEXT, -- JSON array of video URLs
video_paths TEXT, -- JSON array of local video paths
generation_time INTEGER, -- milliseconds
api_response TEXT,
error_message TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
completed_at DATETIME,
FOREIGN KEY (model_id) REFERENCES models (id)
)",
[],
)?;
Ok(())
}
/// 创建视频生成任务
pub fn create(&self, task: &VideoGenerationTask) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let selected_photos_json = serde_json::to_string(&task.selected_photos)?;
let video_urls_json = task.result.as_ref()
.map(|r| serde_json::to_string(&r.video_urls))
.transpose()?;
let video_paths_json = task.result.as_ref()
.map(|r| serde_json::to_string(&r.video_paths))
.transpose()?;
conn.execute(
"INSERT INTO video_generation_tasks (
id, model_id, product, scene, model_desc, template, duplicate,
selected_photos, status, video_urls, video_paths, generation_time,
api_response, error_message, created_at, updated_at, completed_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
params![
task.id,
task.model_id,
task.prompt_config.product,
task.prompt_config.scene,
task.prompt_config.model_desc,
task.prompt_config.template,
task.prompt_config.duplicate,
selected_photos_json,
format!("{:?}", task.status),
video_urls_json,
video_paths_json,
task.result.as_ref().map(|r| r.generation_time as i64),
task.result.as_ref().and_then(|r| r.api_response.as_ref()),
task.error_message,
task.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
task.updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
task.completed_at.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()),
],
)?;
Ok(())
}
/// 根据ID获取视频生成任务
pub fn get_by_id(&self, id: &str) -> Result<Option<VideoGenerationTask>> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, model_id, product, scene, model_desc, template, duplicate,
selected_photos, status, video_urls, video_paths, generation_time,
api_response, error_message, created_at, updated_at, completed_at
FROM video_generation_tasks WHERE id = ?1"
)?;
let task_iter = stmt.query_map([id], |row| {
self.row_to_task(row)
})?;
for task in task_iter {
return Ok(Some(task?));
}
Ok(None)
}
/// 根据查询参数获取视频生成任务列表
pub fn search(&self, params: &VideoGenerationQueryParams) -> Result<Vec<VideoGenerationTask>> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let mut query = String::from(
"SELECT id, model_id, product, scene, model_desc, template, duplicate,
selected_photos, status, video_urls, video_paths, generation_time,
api_response, error_message, created_at, updated_at, completed_at
FROM video_generation_tasks WHERE 1=1"
);
let mut query_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(ref model_id) = params.model_id {
query.push_str(" AND model_id = ?");
query_params.push(Box::new(model_id.clone()));
}
if let Some(ref status) = params.status {
query.push_str(" AND status = ?");
query_params.push(Box::new(format!("{:?}", status)));
}
query.push_str(" ORDER BY created_at DESC");
if let Some(limit) = params.limit {
query.push_str(" LIMIT ?");
query_params.push(Box::new(limit as i64));
}
if let Some(offset) = params.offset {
query.push_str(" OFFSET ?");
query_params.push(Box::new(offset as i64));
}
let mut stmt = conn.prepare(&query)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = query_params.iter().map(|p| p.as_ref()).collect();
let task_iter = stmt.query_map(param_refs.as_slice(), |row| {
self.row_to_task(row)
})?;
let mut tasks = Vec::new();
for task in task_iter {
tasks.push(task?);
}
Ok(tasks)
}
/// 更新视频生成任务
pub fn update(&self, task: &VideoGenerationTask) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let selected_photos_json = serde_json::to_string(&task.selected_photos)?;
let video_urls_json = task.result.as_ref()
.map(|r| serde_json::to_string(&r.video_urls))
.transpose()?;
let video_paths_json = task.result.as_ref()
.map(|r| serde_json::to_string(&r.video_paths))
.transpose()?;
conn.execute(
"UPDATE video_generation_tasks SET
model_id = ?2, product = ?3, scene = ?4, model_desc = ?5, template = ?6,
duplicate = ?7, selected_photos = ?8, status = ?9, video_urls = ?10,
video_paths = ?11, generation_time = ?12, api_response = ?13,
error_message = ?14, updated_at = ?15, completed_at = ?16
WHERE id = ?1",
params![
task.id,
task.model_id,
task.prompt_config.product,
task.prompt_config.scene,
task.prompt_config.model_desc,
task.prompt_config.template,
task.prompt_config.duplicate,
selected_photos_json,
format!("{:?}", task.status),
video_urls_json,
video_paths_json,
task.result.as_ref().map(|r| r.generation_time as i64),
task.result.as_ref().and_then(|r| r.api_response.as_ref()),
task.error_message,
task.updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
task.completed_at.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()),
],
)?;
Ok(())
}
/// 删除视频生成任务
pub fn delete(&self, id: &str) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
conn.execute("DELETE FROM video_generation_tasks WHERE id = ?1", [id])?;
Ok(())
}
/// 将数据库行转换为VideoGenerationTask对象
fn row_to_task(&self, row: &Row) -> rusqlite::Result<VideoGenerationTask> {
let selected_photos_json: String = row.get("selected_photos")?;
let selected_photos: Vec<String> = serde_json::from_str(&selected_photos_json)
.map_err(|_e| rusqlite::Error::InvalidColumnType(
row.as_ref().column_index("selected_photos").unwrap(),
"selected_photos".to_string(),
rusqlite::types::Type::Text
))?;
let status_str: String = row.get("status")?;
let status = match status_str.as_str() {
"Pending" => VideoGenerationStatus::Pending,
"Processing" => VideoGenerationStatus::Processing,
"Completed" => VideoGenerationStatus::Completed,
"Failed" => VideoGenerationStatus::Failed,
"Cancelled" => VideoGenerationStatus::Cancelled,
_ => VideoGenerationStatus::Pending,
};
let result = if let (Some(video_urls_json), Some(video_paths_json)) =
(row.get::<_, Option<String>>("video_urls")?, row.get::<_, Option<String>>("video_paths")?) {
let video_urls: Vec<String> = serde_json::from_str(&video_urls_json).unwrap_or_default();
let video_paths: Vec<String> = serde_json::from_str(&video_paths_json).unwrap_or_default();
let generation_time: Option<i64> = row.get("generation_time")?;
let api_response: Option<String> = row.get("api_response")?;
Some(VideoGenerationResult {
video_urls,
video_paths,
generation_time: generation_time.unwrap_or(0) as u64,
api_response,
})
} else {
None
};
let created_at_str: String = row.get("created_at")?;
let updated_at_str: String = row.get("updated_at")?;
let completed_at_str: Option<String> = row.get("completed_at")?;
Ok(VideoGenerationTask {
id: row.get("id")?,
model_id: row.get("model_id")?,
prompt_config: VideoPromptConfig {
product: row.get("product")?,
scene: row.get("scene")?,
model_desc: row.get("model_desc")?,
template: row.get("template")?,
duplicate: row.get::<_, i64>("duplicate")? as u32,
},
selected_photos,
status,
result,
error_message: row.get("error_message")?,
created_at: chrono::DateTime::parse_from_str(&format!("{} +00:00", created_at_str), "%Y-%m-%d %H:%M:%S %z")
.unwrap().with_timezone(&chrono::Utc),
updated_at: chrono::DateTime::parse_from_str(&format!("{} +00:00", updated_at_str), "%Y-%m-%d %H:%M:%S %z")
.unwrap().with_timezone(&chrono::Utc),
completed_at: completed_at_str.map(|s|
chrono::DateTime::parse_from_str(&format!("{} +00:00", s), "%Y-%m-%d %H:%M:%S %z")
.unwrap().with_timezone(&chrono::Utc)
),
})
}
}

View File

@@ -9,3 +9,4 @@ pub mod ffmpeg;
pub mod monitoring;
pub mod logging;
pub mod gemini_service;
pub mod video_generation_service;

View File

@@ -0,0 +1,283 @@
use anyhow::{anyhow, Result};
use reqwest::Client;
use std::time::Duration;
use crate::data::models::video_generation::{
DifyApiConfig, DifyApiRequest, DifyApiResponse, DifyInputs, VideoGenerationResult
};
/// 视频生成服务
/// 基于Dify API实现视频生成功能
pub struct VideoGenerationService {
client: Client,
config: DifyApiConfig,
}
impl VideoGenerationService {
/// 创建新的视频生成服务实例
pub fn new() -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(300)) // 5分钟超时
.build()
.expect("Failed to create HTTP client");
Self {
client,
config: DifyApiConfig::default(),
}
}
/// 使用自定义配置创建视频生成服务
pub fn with_config(config: DifyApiConfig) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(300))
.build()
.expect("Failed to create HTTP client");
Self { client, config }
}
/// 生成视频
pub async fn generate_video(
&self,
product: String,
scene: String,
model_desc: String,
image_url: String,
template: String,
duplicate: u32,
) -> Result<VideoGenerationResult> {
println!("🎬 开始生成视频...");
println!("产品: {}", product);
println!("场景: {}", scene);
println!("模特: {}", model_desc);
println!("图片: {}", image_url);
println!("模板: {}", template);
println!("数量: {}", duplicate);
let start_time = std::time::Instant::now();
// 构建请求数据
let request_data = DifyApiRequest {
inputs: DifyInputs {
product: product.clone(),
scene: scene.clone(),
model: model_desc.clone(),
image: image_url.clone(),
duplicate,
template: template.clone(),
},
response_mode: "blocking".to_string(),
user: "mixvideo-desktop".to_string(),
};
// 发送请求
let url = format!("{}/v1/workflows/run", self.config.host);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.config.api_key))
.header("Content-Type", "application/json")
.json(&request_data)
.send()
.await
.map_err(|e| anyhow!("请求失败: {}", e))?;
let status = response.status();
let response_text = response
.text()
.await
.map_err(|e| anyhow!("读取响应失败: {}", e))?;
println!("📡 API响应状态: {}", status);
println!("📡 API响应内容: {}", response_text);
if !status.is_success() {
return Err(anyhow!("API请求失败: {} - {}", status, response_text));
}
// 解析响应
let api_response: DifyApiResponse = serde_json::from_str(&response_text)
.map_err(|e| anyhow!("解析响应失败: {} - 响应内容: {}", e, response_text))?;
if let Some(error) = api_response.error {
return Err(anyhow!("API返回错误: {}", error));
}
let data = api_response
.data
.ok_or_else(|| anyhow!("API响应中缺少data字段"))?;
let video_urls = data.outputs.output;
if video_urls.is_empty() {
return Err(anyhow!("API未返回任何视频URL"));
}
let generation_time = start_time.elapsed().as_millis() as u64;
println!("✅ 视频生成成功!");
println!("🎥 生成的视频数量: {}", video_urls.len());
println!("⏱️ 生成耗时: {}ms", generation_time);
// 处理视频路径如果是file://协议,直接使用本地路径)
let video_paths = self.process_video_paths(&video_urls).await?;
Ok(VideoGenerationResult {
video_urls,
video_paths,
generation_time,
api_response: Some(response_text),
})
}
/// 处理视频路径支持file://协议和HTTP URL
async fn process_video_paths(&self, video_urls: &[String]) -> Result<Vec<String>> {
let mut video_paths = Vec::new();
for url in video_urls.iter() {
if url.starts_with("file://") {
// 如果是file://协议,提取本地路径
let local_path = url.replace("file://", "").replace("file:///", "");
println!("📁 使用本地视频文件: {}", local_path);
video_paths.push(local_path);
} else if url.starts_with("http://") || url.starts_with("https://") {
// 如果是HTTP URL尝试下载
match self.download_single_video(url, video_paths.len()).await {
Ok(path) => {
println!("📥 视频下载成功: {}", path);
video_paths.push(path);
}
Err(e) => {
println!("⚠️ 视频下载失败: {} - {}", url, e);
// 下载失败时使用原URL作为路径
video_paths.push(url.clone());
}
}
} else {
// 其他情况直接使用原路径
println!("📄 使用原始路径: {}", url);
video_paths.push(url.clone());
}
}
Ok(video_paths)
}
/// 下载视频到本地(保留原方法以备后用)
async fn download_videos(&self, video_urls: &[String]) -> Result<Vec<String>> {
let mut video_paths = Vec::new();
for (index, url) in video_urls.iter().enumerate() {
match self.download_single_video(url, index).await {
Ok(path) => {
println!("📥 视频下载成功: {}", path);
video_paths.push(path);
}
Err(e) => {
println!("⚠️ 视频下载失败: {} - {}", url, e);
// 下载失败时使用原URL作为路径
video_paths.push(url.clone());
}
}
}
Ok(video_paths)
}
/// 下载单个视频文件
async fn download_single_video(&self, url: &str, index: usize) -> Result<String> {
// 创建下载目录
let download_dir = std::env::current_dir()?.join("downloads").join("videos");
std::fs::create_dir_all(&download_dir)?;
// 生成文件名
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
let filename = format!("video_{}_{}.mp4", timestamp, index);
let file_path = download_dir.join(&filename);
// 下载文件
let response = self
.client
.get(url)
.send()
.await
.map_err(|e| anyhow!("下载请求失败: {}", e))?;
if !response.status().is_success() {
return Err(anyhow!("下载失败: {}", response.status()));
}
let bytes = response
.bytes()
.await
.map_err(|e| anyhow!("读取视频数据失败: {}", e))?;
std::fs::write(&file_path, bytes)
.map_err(|e| anyhow!("保存视频文件失败: {}", e))?;
Ok(file_path.to_string_lossy().to_string())
}
/// 验证图片URL是否可访问
pub async fn validate_image_url(&self, url: &str) -> Result<bool> {
match self.client.head(url).send().await {
Ok(response) => Ok(response.status().is_success()),
Err(_) => Ok(false),
}
}
/// 获取API配置
pub fn get_config(&self) -> &DifyApiConfig {
&self.config
}
/// 更新API配置
pub fn update_config(&mut self, config: DifyApiConfig) {
self.config = config;
}
/// 测试API连接
pub async fn test_connection(&self) -> Result<bool> {
let url = format!("{}/health", self.config.host);
match self.client.get(&url).send().await {
Ok(response) => Ok(response.status().is_success()),
Err(_) => Ok(false),
}
}
}
impl Default for VideoGenerationService {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_video_generation_service_creation() {
let service = VideoGenerationService::new();
assert_eq!(service.config.host, "https://dify.bowongai.com");
assert_eq!(service.config.api_key, "app-Lm10XUBJKnE1bnG6VPfiD1se");
}
#[tokio::test]
async fn test_custom_config() {
let custom_config = DifyApiConfig {
host: "https://custom.api.com".to_string(),
api_key: "custom-key".to_string(),
};
let service = VideoGenerationService::with_config(custom_config.clone());
assert_eq!(service.config.host, custom_config.host);
assert_eq!(service.config.api_key, custom_config.api_key);
}
#[tokio::test]
async fn test_validate_image_url() {
let service = VideoGenerationService::new();
// 测试一个公开的图片URL
let result = service.validate_image_url("https://httpbin.org/status/200").await;
assert!(result.is_ok());
}
}

View File

@@ -222,6 +222,17 @@ pub fn run() {
// MaterialSegment聚合视图命令
commands::material_segment_view_commands::get_project_segment_view,
commands::material_segment_view_commands::get_project_segment_view_with_query,
// 视频生成命令
commands::video_generation_commands::create_video_generation_task,
commands::video_generation_commands::execute_video_generation_task,
commands::video_generation_commands::get_video_generation_task,
commands::video_generation_commands::get_video_generation_tasks,
commands::video_generation_commands::cancel_video_generation_task,
commands::video_generation_commands::delete_video_generation_task,
commands::video_generation_commands::retry_video_generation_task,
commands::video_generation_commands::get_model_video_generation_statistics,
commands::video_generation_commands::get_model_detail_with_photos,
commands::video_generation_commands::batch_upload_model_photos,
// 测试命令
commands::test_commands::test_database_connection,
commands::test_commands::test_template_table,

View File

@@ -14,3 +14,4 @@ pub mod debug_commands;
pub mod project_template_binding_commands;
pub mod material_matching_commands;
pub mod template_matching_result_commands;
pub mod video_generation_commands;

View File

@@ -0,0 +1,234 @@
use tauri::{command, State};
use crate::app_state::AppState;
use crate::business::services::video_generation_service::{VideoGenerationService, VideoGenerationStatistics};
use crate::data::models::video_generation::{
VideoGenerationTask, VideoGenerationQueryParams, CreateVideoGenerationRequest
};
/// 创建视频生成任务命令
/// 遵循 Tauri 开发规范的命令设计模式
#[command]
pub async fn create_video_generation_task(
state: State<'_, AppState>,
request: CreateVideoGenerationRequest,
) -> Result<VideoGenerationTask, String> {
// 克隆仓库引用以避免跨await边界的Send问题
let (video_gen_repo, model_repo) = {
let video_gen_repository_guard = state.get_video_generation_repository()
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
let video_gen_repository = video_gen_repository_guard.as_ref()
.ok_or("视频生成仓库未初始化")?;
let model_repository_guard = state.get_model_repository()
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
let model_repository = model_repository_guard.as_ref()
.ok_or("模特仓库未初始化")?;
// 这里我们需要克隆仓库但由于仓库包含Arc这应该是轻量级的
(video_gen_repository.clone(), model_repository.clone())
};
VideoGenerationService::create_task(&video_gen_repo, &model_repo, request)
.await
.map_err(|e| e.to_string())
}
/// 执行视频生成任务命令
#[command]
pub async fn execute_video_generation_task(
state: State<'_, AppState>,
task_id: String,
) -> Result<VideoGenerationTask, String> {
let (video_gen_repo, model_repo) = {
let video_gen_repository_guard = state.get_video_generation_repository()
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
let video_gen_repository = video_gen_repository_guard.as_ref()
.ok_or("视频生成仓库未初始化")?;
let model_repository_guard = state.get_model_repository()
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
let model_repository = model_repository_guard.as_ref()
.ok_or("模特仓库未初始化")?;
(video_gen_repository.clone(), model_repository.clone())
};
VideoGenerationService::execute_task(&video_gen_repo, &model_repo, &task_id)
.await
.map_err(|e| e.to_string())
}
/// 获取视频生成任务命令
#[command]
pub async fn get_video_generation_task(
state: State<'_, AppState>,
task_id: String,
) -> Result<Option<VideoGenerationTask>, String> {
let repository_guard = state.get_video_generation_repository()
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("视频生成仓库未初始化")?;
VideoGenerationService::get_task(repository, &task_id)
.map_err(|e| e.to_string())
}
/// 获取视频生成任务列表命令
#[command]
pub async fn get_video_generation_tasks(
state: State<'_, AppState>,
params: VideoGenerationQueryParams,
) -> Result<Vec<VideoGenerationTask>, String> {
let repository_guard = state.get_video_generation_repository()
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("视频生成仓库未初始化")?;
VideoGenerationService::get_tasks(repository, &params)
.map_err(|e| e.to_string())
}
/// 取消视频生成任务命令
#[command]
pub async fn cancel_video_generation_task(
state: State<'_, AppState>,
task_id: String,
) -> Result<VideoGenerationTask, String> {
let repository_guard = state.get_video_generation_repository()
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("视频生成仓库未初始化")?;
VideoGenerationService::cancel_task(repository, &task_id)
.map_err(|e| e.to_string())
}
/// 删除视频生成任务命令
#[command]
pub async fn delete_video_generation_task(
state: State<'_, AppState>,
task_id: String,
) -> Result<(), String> {
let repository_guard = state.get_video_generation_repository()
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("视频生成仓库未初始化")?;
VideoGenerationService::delete_task(repository, &task_id)
.map_err(|e| e.to_string())
}
/// 重试视频生成任务命令
#[command]
pub async fn retry_video_generation_task(
state: State<'_, AppState>,
task_id: String,
) -> Result<VideoGenerationTask, String> {
let (video_gen_repo, model_repo) = {
let video_gen_repository_guard = state.get_video_generation_repository()
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
let video_gen_repository = video_gen_repository_guard.as_ref()
.ok_or("视频生成仓库未初始化")?;
let model_repository_guard = state.get_model_repository()
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
let model_repository = model_repository_guard.as_ref()
.ok_or("模特仓库未初始化")?;
(video_gen_repository.clone(), model_repository.clone())
};
VideoGenerationService::retry_task(&video_gen_repo, &model_repo, &task_id)
.await
.map_err(|e| e.to_string())
}
/// 获取模特视频生成统计命令
#[command]
pub async fn get_model_video_generation_statistics(
state: State<'_, AppState>,
model_id: String,
) -> Result<VideoGenerationStatistics, String> {
let repository_guard = state.get_video_generation_repository()
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("视频生成仓库未初始化")?;
VideoGenerationService::get_model_statistics(repository, &model_id)
.map_err(|e| e.to_string())
}
/// 获取模特详情(包含照片)命令
#[command]
pub async fn get_model_detail_with_photos(
state: State<'_, AppState>,
model_id: String,
) -> Result<Option<crate::data::models::model::Model>, String> {
let repository_guard = state.get_model_repository()
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("模特仓库未初始化")?;
// 使用完整的get_by_id方法获取包含照片的模特信息
repository.get_by_id(&model_id)
.map_err(|e| e.to_string())
}
/// 批量上传模特照片命令(上传到云端)
#[command]
pub async fn batch_upload_model_photos(
state: State<'_, AppState>,
model_id: String,
file_paths: Vec<String>,
photo_type: crate::data::models::model::PhotoType,
description: Option<String>,
tags: Option<Vec<String>>,
) -> Result<Vec<crate::data::models::model::ModelPhoto>, String> {
// 克隆仓库引用以避免跨await边界的Send问题
let repository = {
let repository_guard = state.get_model_repository()
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("模特仓库未初始化")?;
repository.clone()
};
let mut uploaded_photos = Vec::new();
for file_path in file_paths {
match crate::business::services::model_service::ModelService::add_model_photo_with_cloud_upload(
&repository,
&model_id,
file_path,
photo_type.clone(),
description.clone(),
tags.clone(),
).await {
Ok(photo) => uploaded_photos.push(photo),
Err(e) => {
println!("⚠️ 上传照片失败: {}", e);
// 继续上传其他照片,不中断整个过程
}
}
}
if uploaded_photos.is_empty() {
return Err("没有成功上传任何照片".to_string());
}
Ok(uploaded_photos)
}

View File

@@ -4,6 +4,7 @@ import { ProjectList } from './components/ProjectList';
import { ProjectForm } from './components/ProjectForm';
import { ProjectDetails } from './pages/ProjectDetails';
import Models from './pages/Models';
import ModelDetail from './pages/ModelDetail';
import AiClassificationSettings from './pages/AiClassificationSettings';
import TemplateManagement from './pages/TemplateManagement';
import { MaterialModelBinding } from './pages/MaterialModelBinding';
@@ -73,6 +74,7 @@ function App() {
<Route path="/" element={<ProjectList />} />
<Route path="/project/:id" element={<ProjectDetails />} />
<Route path="/models" element={<Models />} />
<Route path="/models/:id" element={<ModelDetail />} />
<Route path="/ai-classification-settings" element={<AiClassificationSettings />} />
<Route path="/templates" element={<TemplateManagement />} />
<Route path="/material-model-binding" element={<MaterialModelBinding />} />

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Model, ModelStatus, Gender, ModelViewMode } from '../types/model';
import {
PencilIcon,
@@ -9,7 +10,6 @@ import {
CalendarIcon,
EyeIcon,
HeartIcon,
ShareIcon,
PhotoIcon
} from '@heroicons/react/24/outline';
import {
@@ -37,10 +37,23 @@ const ModelCard: React.FC<ModelCardProps> = ({
isFavorite = false,
isLoading = false
}) => {
const navigate = useNavigate();
const [isHovered, setIsHovered] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
// 处理卡片点击事件
const handleCardClick = (e: React.MouseEvent) => {
// 如果点击的是操作按钮,不触发导航
const target = e.target as HTMLElement;
if (target.closest('button')) {
return;
}
// 导航到模特详情页
navigate(`/models/${model.id}`);
};
const getStatusColor = (status: ModelStatus) => {
switch (status) {
case ModelStatus.Active:
@@ -121,7 +134,10 @@ const ModelCard: React.FC<ModelCardProps> = ({
if (viewMode === ModelViewMode.List) {
// 列表视图
return (
<div className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-300 animate-fade-in">
<div
className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-300 animate-fade-in cursor-pointer"
onClick={handleCardClick}
>
<div className="flex">
{/* 头像 */}
<div className="relative w-24 min-h-full flex-shrink-0">
@@ -245,10 +261,10 @@ const ModelCard: React.FC<ModelCardProps> = ({
// 网格视图 - 增强的现代化设计
return (
<div
className="group relative bg-gradient-to-br from-white to-gray-50/50 hover:from-white hover:to-primary-50/30 rounded-2xl shadow-sm border border-gray-100/50 overflow-hidden hover:shadow-xl hover:border-primary-200 transition-all duration-500 hover:-translate-y-2 hover:scale-[1.02] animate-scale-in"
className="group relative bg-gradient-to-br from-white to-gray-50/50 hover:from-white hover:to-primary-50/30 rounded-2xl shadow-sm border border-gray-100/50 overflow-hidden hover:shadow-xl hover:border-primary-200 transition-all duration-500 hover:-translate-y-2 hover:scale-[1.02] animate-scale-in cursor-pointer"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onSelect}
onClick={handleCardClick}
>
{/* 装饰性背景 */}
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-primary-100/30 to-primary-200/30 rounded-full -translate-y-10 translate-x-10 opacity-0 group-hover:opacity-100 transition-all duration-500"></div>
@@ -435,16 +451,6 @@ const ModelCard: React.FC<ModelCardProps> = ({
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
// 分享功能
}}
className="flex items-center justify-center px-3 py-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-green-50 hover:text-green-600 transition-all duration-200 text-sm font-medium focus-ring"
title="分享"
>
<ShareIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>

View File

@@ -1,6 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./App.css";
import "./styles/design-system.css";
import "./styles/animations.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>

View File

@@ -0,0 +1,698 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ArrowLeftIcon,
PhotoIcon,
PlusIcon,
PlayIcon,
TrashIcon,
EyeIcon,
ArrowPathIcon
} from '@heroicons/react/24/outline';
import { Model, PhotoType } from '../types/model';
import {
VideoGenerationTask,
VideoPromptConfig,
VideoGenerationStatus,
TEMPLATE_OPTIONS,
SCENE_OPTIONS,
PRODUCT_OPTIONS,
VIDEO_GENERATION_STATUS_CONFIG
} from '../types/videoGeneration';
import { videoGenerationService } from '../services/videoGenerationService';
import { open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { CustomSelect } from '../components/CustomSelect';
import { LoadingSpinner } from '../components/LoadingSpinner';
import { DeleteConfirmDialog } from '../components/DeleteConfirmDialog';
const ModelDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// 状态管理
const [model, setModel] = useState<Model | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedPhotos, setSelectedPhotos] = useState<string[]>([]);
const [promptConfig, setPromptConfig] = useState<VideoPromptConfig>({
product: '',
scene: '',
model_desc: '',
template: '抚媚眼神',
duplicate: 1
});
const [videoTasks, setVideoTasks] = useState<VideoGenerationTask[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [uploadingPhotos, setUploadingPhotos] = useState(false);
const [deletePhotoConfirm, setDeletePhotoConfirm] = useState<{
show: boolean;
photoId: string | null;
photoName: string | null;
deleting: boolean;
}>({
show: false,
photoId: null,
photoName: null,
deleting: false,
});
const [_deleteTaskConfirm, setDeleteTaskConfirm] = useState<{
show: boolean;
taskId: string | null;
taskName: string | null;
deleting: boolean;
}>({
show: false,
taskId: null,
taskName: null,
deleting: false,
});
// 加载模特详情
const loadModelDetail = async () => {
if (!id) return;
try {
setLoading(true);
setError(null);
const modelData = await videoGenerationService.getModelDetailWithPhotos(id);
if (modelData) {
setModel(modelData);
// 设置默认的模特描述
setPromptConfig(prev => ({
...prev,
model_desc: modelData.description || `${modelData.name}${modelData.tags.join('、')}`
}));
} else {
setError('模特不存在');
}
} catch (err) {
setError(err as string);
} finally {
setLoading(false);
}
};
// 加载视频生成任务
const loadVideoTasks = async () => {
if (!id) return;
try {
const tasks = await videoGenerationService.getVideoGenerationTasks({
model_id: id
});
setVideoTasks(tasks);
} catch (err) {
console.error('加载视频任务失败:', err);
}
};
// 上传照片
const handleUploadPhotos = async () => {
if (!id) return;
try {
setUploadingPhotos(true);
const selected = await open({
multiple: true,
filters: [{
name: 'Images',
extensions: ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']
}]
});
if (selected && Array.isArray(selected)) {
const photos = await videoGenerationService.batchUploadModelPhotos(
id,
selected,
PhotoType.Portrait,
'模特照片',
['视频生成']
);
// 重新加载模特数据
await loadModelDetail();
console.log(`成功上传 ${photos.length} 张照片`);
}
} catch (err) {
setError(`上传照片失败: ${err}`);
} finally {
setUploadingPhotos(false);
}
};
// 选择/取消选择照片
const togglePhotoSelection = (photoId: string) => {
setSelectedPhotos(prev =>
prev.includes(photoId)
? prev.filter(id => id !== photoId)
: [...prev, photoId]
);
};
// 生成视频
const handleGenerateVideo = async () => {
if (!id || selectedPhotos.length === 0) {
setError('请至少选择一张照片');
return;
}
if (!promptConfig.product.trim() || !promptConfig.scene.trim()) {
setError('请填写产品描述和场景描述');
return;
}
try {
setIsGenerating(true);
setError(null);
const request = {
model_id: id,
prompt_config: promptConfig,
selected_photos: selectedPhotos
};
// 创建并执行任务
const task = await videoGenerationService.createAndExecuteVideoGeneration(request);
// 重新加载任务列表
await loadVideoTasks();
// 开始轮询任务状态
videoGenerationService.pollTaskUntilComplete(
task.id,
(updatedTask) => {
// 更新任务列表中的任务状态
setVideoTasks(prev =>
prev.map(t => t.id === updatedTask.id ? updatedTask : t)
);
}
).then(() => {
// 任务完成后重新加载任务列表
loadVideoTasks();
}).catch(err => {
console.error('轮询任务状态失败:', err);
});
} catch (err) {
setError(`生成视频失败: ${err}`);
} finally {
setIsGenerating(false);
}
};
// 重试任务
const handleRetryTask = async (taskId: string) => {
try {
await videoGenerationService.retryVideoGenerationTask(taskId);
await loadVideoTasks();
} catch (err) {
setError(`重试任务失败: ${err}`);
}
};
// 显示删除任务确认弹框
const showDeleteTaskConfirm = (taskId: string) => {
const task = videoTasks.find(t => t.id === taskId);
const taskName = task ? `${task.prompt_config.product} - ${task.prompt_config.template}` : '未知任务';
setDeleteTaskConfirm({
show: true,
taskId,
taskName,
deleting: false,
});
};
// 显示删除照片确认弹框
const showDeletePhotoConfirm = (photoId: string) => {
if (!model) return;
const photo = model.photos.find(p => p.id === photoId);
const photoName = photo?.description || photo?.file_name || '未知照片';
setDeletePhotoConfirm({
show: true,
photoId,
photoName,
deleting: false,
});
};
// 确认删除照片
const confirmDeletePhoto = async () => {
if (!deletePhotoConfirm.photoId || !id) return;
setDeletePhotoConfirm(prev => ({ ...prev, deleting: true }));
try {
await invoke('delete_model_photo', {
modelId: id,
photoId: deletePhotoConfirm.photoId
});
// 重新加载模特数据
await loadModelDetail();
// 如果删除的照片在选中列表中,移除它
setSelectedPhotos(prev => prev.filter(selectedId => selectedId !== deletePhotoConfirm.photoId));
console.log('照片删除成功');
// 关闭确认弹框
setDeletePhotoConfirm({
show: false,
photoId: null,
photoName: null,
deleting: false,
});
} catch (err) {
setError(`删除照片失败: ${err}`);
setDeletePhotoConfirm(prev => ({ ...prev, deleting: false }));
}
};
// 取消删除照片
const cancelDeletePhoto = () => {
setDeletePhotoConfirm({
show: false,
photoId: null,
photoName: null,
deleting: false,
});
};
// 初始化
useEffect(() => {
loadModelDetail();
loadVideoTasks();
}, [id]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size="large" />
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-600">{error}</p>
<button
onClick={() => setError(null)}
className="mt-2 text-sm text-red-500 hover:text-red-700"
>
</button>
</div>
);
}
if (!model) {
return (
<div className="text-center py-8">
<p className="text-gray-500"></p>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-blue-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* 头部 - 优化设计 */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6 animate-fade-in">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => navigate('/models')}
className="group flex items-center text-gray-600 hover:text-blue-600 transition-all duration-200 hover:bg-blue-50 px-3 py-2 rounded-lg"
>
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-200" />
</button>
<div className="h-8 w-px bg-gray-200"></div>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-blue-600 bg-clip-text text-transparent">
{model.name}
</h1>
{model.stage_name && (
<span className="text-lg text-gray-500 font-medium">({model.stage_name})</span>
)}
</div>
</div>
<button
onClick={handleUploadPhotos}
disabled={uploadingPhotos}
className="group flex items-center px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-105"
>
{uploadingPhotos ? (
<LoadingSpinner size="small" className="mr-2" color="white" />
) : (
<PlusIcon className="w-5 h-5 mr-2 group-hover:rotate-90 transition-transform duration-200" />
)}
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 左侧:模特信息和照片 */}
<div className="lg:col-span-2 space-y-8">
{/* 模特基本信息 - 优化设计 */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 hover:shadow-md transition-all duration-300 animate-slide-up">
<div className="flex items-center mb-6">
<div className="w-2 h-8 bg-gradient-to-b from-blue-500 to-blue-600 rounded-full mr-4"></div>
<h2 className="text-xl font-bold text-gray-900"></h2>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500"></span>
<span>{model.gender}</span>
</div>
{model.age && (
<div>
<span className="text-gray-500"></span>
<span>{model.age}</span>
</div>
)}
{model.height && (
<div>
<span className="text-gray-500"></span>
<span>{model.height}cm</span>
</div>
)}
{model.weight && (
<div>
<span className="text-gray-500"></span>
<span>{model.weight}kg</span>
</div>
)}
</div>
{model.description && (
<div className="mt-4">
<span className="text-gray-500"></span>
<p className="mt-1 text-gray-900">{model.description}</p>
</div>
)}
{model.tags.length > 0 && (
<div className="mt-4">
<span className="text-gray-500"></span>
<div className="mt-1 flex flex-wrap gap-2">
{model.tags.map((tag, index) => (
<span
key={index}
className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
</div>
{/* 照片选择 */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"></h2>
<span className="text-sm text-gray-500">
{selectedPhotos.length}
</span>
</div>
{model.photos.length === 0 ? (
<div className="text-center py-8">
<PhotoIcon className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500"></p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{model.photos.map((photo) => (
<div
key={photo.id}
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all group ${selectedPhotos.includes(photo.id)
? 'border-blue-500 ring-2 ring-blue-200'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div
className="w-full h-full cursor-pointer"
onClick={() => togglePhotoSelection(photo.id)}
>
<img
src={photo.file_path.startsWith('http') ? photo.file_path : `file://${photo.file_path}`}
alt={photo.description || '模特照片'}
className="w-full h-full object-cover"
/>
</div>
{/* 删除按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
showDeletePhotoConfirm(photo.id);
}}
className="absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center hover:bg-red-600"
title="删除照片"
>
<TrashIcon className="w-3 h-3" />
</button>
{/* 选中状态显示 */}
{selectedPhotos.includes(photo.id) && (
<div className="absolute inset-0 bg-blue-500 bg-opacity-20 flex items-center justify-center pointer-events-none">
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs font-bold">
{selectedPhotos.indexOf(photo.id) + 1}
</span>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* 右侧:视频生成配置和任务 */}
<div className="space-y-6">
{/* 视频生成配置 */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="space-y-4">
{/* 产品描述 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
value={promptConfig.product}
onChange={(e) => setPromptConfig(prev => ({ ...prev, product: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200"
placeholder="输入产品描述,如:超短牛仔裙(白色紧身蕾丝短袖)"
list="product-options"
/>
<datalist id="product-options">
{PRODUCT_OPTIONS.map((option, index) => (
<option key={index} value={option} />
))}
</datalist>
</div>
{/* 场景描述 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
value={promptConfig.scene}
onChange={(e) => setPromptConfig(prev => ({ ...prev, scene: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200"
placeholder="输入场景描述,如:室内可爱简约的女性卧室"
list="scene-options"
/>
<datalist id="scene-options">
{SCENE_OPTIONS.map((option, index) => (
<option key={index} value={option} />
))}
</datalist>
</div>
{/* 模特描述 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<textarea
value={promptConfig.model_desc}
onChange={(e) => setPromptConfig(prev => ({ ...prev, model_desc: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="描述模特的特征和风格"
/>
</div>
{/* 模板类型 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<CustomSelect
value={promptConfig.template}
onChange={(value) => setPromptConfig(prev => ({ ...prev, template: value }))}
options={TEMPLATE_OPTIONS}
placeholder="选择模板类型"
/>
</div>
{/* 生成数量 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="number"
min="1"
max="5"
value={promptConfig.duplicate}
onChange={(e) => setPromptConfig(prev => ({ ...prev, duplicate: parseInt(e.target.value) || 1 }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* 生成按钮 */}
<button
onClick={handleGenerateVideo}
disabled={isGenerating || selectedPhotos.length === 0}
className="w-full flex items-center justify-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating ? (
<>
<LoadingSpinner size="small" className="mr-2" />
...
</>
) : (
<>
<PlayIcon className="w-5 h-5 mr-2" />
</>
)}
</button>
</div>
</div>
{/* 视频生成任务 */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"></h2>
<button
onClick={loadVideoTasks}
className="text-gray-500 hover:text-gray-700"
>
<ArrowPathIcon className="w-5 h-5" />
</button>
</div>
{videoTasks.length === 0 ? (
<p className="text-gray-500 text-center py-4"></p>
) : (
<div className="space-y-3">
{videoTasks.map((task) => {
const statusConfig = VIDEO_GENERATION_STATUS_CONFIG[task.status];
return (
<div
key={task.id}
className={`p-3 rounded-lg border ${statusConfig.borderColor} ${statusConfig.bgColor}`}
>
<div className="flex items-center justify-between mb-2">
<span className={`text-sm font-medium ${statusConfig.color}`}>
{statusConfig.label}
</span>
<div className="flex items-center space-x-2">
{task.status === VideoGenerationStatus.Failed && (
<button
onClick={() => handleRetryTask(task.id)}
className="text-blue-600 hover:text-blue-800"
title="重试"
>
<ArrowPathIcon className="w-4 h-4" />
</button>
)}
<button
onClick={() => showDeleteTaskConfirm(task.id)}
className="text-red-600 hover:text-red-800"
title="删除"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
<div className="text-xs text-gray-600">
<p>: {task.prompt_config.product}</p>
<p>: {task.prompt_config.template}</p>
<p>: {new Date(task.created_at).toLocaleString()}</p>
</div>
{task.result && task.result.video_urls.length > 0 && (
<div className="mt-2">
<p className="text-xs text-gray-600 mb-1">:</p>
<div className="flex flex-wrap gap-2">
{task.result.video_urls.map((url, index) => (
<a
key={index}
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-blue-600 hover:text-blue-800 text-xs"
>
<EyeIcon className="w-3 h-3 mr-1" />
{index + 1}
</a>
))}
</div>
</div>
)}
{task.error_message && (
<div className="mt-2 text-xs text-red-600">
: {task.error_message}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
{/* 删除照片确认弹框 */}
<DeleteConfirmDialog
isOpen={deletePhotoConfirm.show}
title="删除照片"
message="确定要删除这张照片吗?此操作不可撤销。"
itemName={deletePhotoConfirm.photoName || undefined}
deleting={deletePhotoConfirm.deleting}
onConfirm={confirmDeletePhoto}
onCancel={cancelDeletePhoto}
/>
</div>
);
};
export default ModelDetail;

View File

@@ -175,7 +175,7 @@ export const ProjectDetails: React.FC = () => {
}, []);
// 加载项目分类统计信息
const loadProjectClassificationStats = useCallback(async (projectId: string, materialList?: any[]) => {
const loadProjectClassificationStats = useCallback(async (_projectId: string, materialList?: any[]) => {
try {
// 使用传入的素材列表或当前的素材列表
const materialsToProcess = materialList || materials;

View File

@@ -0,0 +1,241 @@
import { invoke } from '@tauri-apps/api/core';
import {
VideoGenerationTask,
VideoGenerationQueryParams,
CreateVideoGenerationRequest,
VideoGenerationAPI,
} from '../types/videoGeneration';
import { Model, ModelPhoto, PhotoType } from '../types/model';
/**
* 视频生成服务
* 提供视频生成相关的API调用功能
*/
class VideoGenerationService implements VideoGenerationAPI {
/**
* 创建视频生成任务
*/
async createVideoGenerationTask(request: CreateVideoGenerationRequest): Promise<VideoGenerationTask> {
try {
const task = await invoke<VideoGenerationTask>('create_video_generation_task', {
request
});
return task;
} catch (error) {
console.error('创建视频生成任务失败:', error);
throw new Error(`创建视频生成任务失败: ${error}`);
}
}
/**
* 执行视频生成任务
*/
async executeVideoGenerationTask(taskId: string): Promise<VideoGenerationTask> {
try {
const task = await invoke<VideoGenerationTask>('execute_video_generation_task', {
taskId
});
return task;
} catch (error) {
console.error('执行视频生成任务失败:', error);
throw new Error(`执行视频生成任务失败: ${error}`);
}
}
/**
* 获取视频生成任务
*/
async getVideoGenerationTask(taskId: string): Promise<VideoGenerationTask | null> {
try {
const task = await invoke<VideoGenerationTask | null>('get_video_generation_task', {
taskId
});
return task;
} catch (error) {
console.error('获取视频生成任务失败:', error);
throw new Error(`获取视频生成任务失败: ${error}`);
}
}
/**
* 获取视频生成任务列表
*/
async getVideoGenerationTasks(params?: VideoGenerationQueryParams): Promise<VideoGenerationTask[]> {
try {
const tasks = await invoke<VideoGenerationTask[]>('get_video_generation_tasks', {
params: params || {}
});
return tasks;
} catch (error) {
console.error('获取视频生成任务列表失败:', error);
throw new Error(`获取视频生成任务列表失败: ${error}`);
}
}
/**
* 取消视频生成任务
*/
async cancelVideoGenerationTask(taskId: string): Promise<void> {
try {
await invoke('cancel_video_generation_task', {
taskId
});
} catch (error) {
console.error('取消视频生成任务失败:', error);
throw new Error(`取消视频生成任务失败: ${error}`);
}
}
/**
* 删除视频生成任务
*/
async deleteVideoGenerationTask(taskId: string): Promise<void> {
try {
await invoke('delete_video_generation_task', {
taskId
});
} catch (error) {
console.error('删除视频生成任务失败:', error);
throw new Error(`删除视频生成任务失败: ${error}`);
}
}
/**
* 重试视频生成任务
*/
async retryVideoGenerationTask(taskId: string): Promise<VideoGenerationTask> {
try {
const task = await invoke<VideoGenerationTask>('retry_video_generation_task', {
taskId
});
return task;
} catch (error) {
console.error('重试视频生成任务失败:', error);
throw new Error(`重试视频生成任务失败: ${error}`);
}
}
/**
* 获取模特详情(包含照片)
*/
async getModelDetailWithPhotos(modelId: string): Promise<Model | null> {
try {
const model = await invoke<Model | null>('get_model_detail_with_photos', {
modelId
});
return model;
} catch (error) {
console.error('获取模特详情失败:', error);
throw new Error(`获取模特详情失败: ${error}`);
}
}
/**
* 批量上传模特照片
*/
async batchUploadModelPhotos(
modelId: string,
filePaths: string[],
photoType: PhotoType,
description?: string,
tags?: string[]
): Promise<ModelPhoto[]> {
try {
const photos = await invoke<ModelPhoto[]>('batch_upload_model_photos', {
modelId,
filePaths,
photoType,
description,
tags
});
return photos;
} catch (error) {
console.error('批量上传模特照片失败:', error);
throw new Error(`批量上传模特照片失败: ${error}`);
}
}
/**
* 获取模特视频生成统计
*/
async getModelVideoGenerationStatistics(modelId: string) {
try {
const stats = await invoke('get_model_video_generation_statistics', {
modelId
});
return stats;
} catch (error) {
console.error('获取模特视频生成统计失败:', error);
throw new Error(`获取模特视频生成统计失败: ${error}`);
}
}
/**
* 创建并执行视频生成任务(一步完成)
*/
async createAndExecuteVideoGeneration(request: CreateVideoGenerationRequest): Promise<VideoGenerationTask> {
try {
// 1. 创建任务
const task = await this.createVideoGenerationTask(request);
// 2. 执行任务
const executedTask = await this.executeVideoGenerationTask(task.id);
return executedTask;
} catch (error) {
console.error('创建并执行视频生成任务失败:', error);
throw error;
}
}
/**
* 轮询任务状态直到完成
*/
async pollTaskUntilComplete(
taskId: string,
onProgress?: (task: VideoGenerationTask) => void,
maxAttempts: number = 60,
intervalMs: number = 2000
): Promise<VideoGenerationTask> {
let attempts = 0;
while (attempts < maxAttempts) {
try {
const task = await this.getVideoGenerationTask(taskId);
if (!task) {
throw new Error('任务不存在');
}
if (onProgress) {
onProgress(task);
}
// 检查任务是否完成
if (task.status === 'Completed' || task.status === 'Failed' || task.status === 'Cancelled') {
return task;
}
// 等待一段时间后继续轮询
await new Promise(resolve => setTimeout(resolve, intervalMs));
attempts++;
} catch (error) {
console.error('轮询任务状态失败:', error);
attempts++;
if (attempts >= maxAttempts) {
throw new Error(`轮询任务状态超时: ${error}`);
}
// 等待一段时间后重试
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
}
throw new Error('轮询任务状态超时');
}
}
// 导出单例实例
export const videoGenerationService = new VideoGenerationService();
export default videoGenerationService;

View File

@@ -0,0 +1,188 @@
// 视频生成相关类型定义
export interface VideoGenerationTask {
id: string;
model_id: string;
prompt_config: VideoPromptConfig;
selected_photos: string[];
status: VideoGenerationStatus;
result?: VideoGenerationResult;
error_message?: string;
created_at: string;
updated_at: string;
completed_at?: string;
}
export interface VideoPromptConfig {
product: string; // 产品描述
scene: string; // 场景描述
model_desc: string; // 模特描述
template: string; // 模板类型
duplicate: number; // 生成数量
}
export enum VideoGenerationStatus {
Pending = "Pending",
Processing = "Processing",
Completed = "Completed",
Failed = "Failed",
Cancelled = "Cancelled"
}
export interface VideoGenerationResult {
video_urls: string[];
video_paths: string[];
generation_time: number;
api_response?: string;
}
export interface CreateVideoGenerationRequest {
model_id: string;
prompt_config: VideoPromptConfig;
selected_photos: string[];
}
export interface VideoGenerationQueryParams {
model_id?: string;
status?: VideoGenerationStatus;
limit?: number;
offset?: number;
}
export interface DifyApiConfig {
host: string;
api_key: string;
}
export interface DifyApiRequest {
inputs: DifyInputs;
response_mode: string;
user: string;
}
export interface DifyInputs {
product: string;
scene: string;
model: string;
image: string;
duplicate: number;
template: string;
}
export interface DifyApiResponse {
data?: DifyResponseData;
error?: string;
}
export interface DifyResponseData {
outputs: DifyOutputs;
}
export interface DifyOutputs {
output: string[];
}
// 视频生成表单数据
export interface VideoGenerationFormData {
product: string;
scene: string;
model_desc: string;
template: string;
duplicate: number;
selected_photos: string[];
}
// 视频生成表单错误
export interface VideoGenerationFormErrors {
product?: string;
scene?: string;
model_desc?: string;
template?: string;
duplicate?: string;
selected_photos?: string;
}
// 预设模板选项
export interface TemplateOption {
value: string;
label: string;
description?: string;
}
// 常用模板预设
export const TEMPLATE_OPTIONS: TemplateOption[] = [
{ value: "抚媚眼神", label: "抚媚眼神", description: "展现模特魅惑的眼神和表情" },
{ value: "清纯甜美", label: "清纯甜美", description: "展现模特清纯可爱的一面" },
{ value: "性感妩媚", label: "性感妩媚", description: "展现模特性感迷人的魅力" },
{ value: "优雅知性", label: "优雅知性", description: "展现模特优雅大方的气质" },
{ value: "活力青春", label: "活力青春", description: "展现模特青春活力的状态" },
{ value: "神秘冷艳", label: "神秘冷艳", description: "展现模特神秘高冷的气质" },
];
// 常用场景预设
export const SCENE_OPTIONS = [
"室内可爱简约的女性卧室",
"现代简约的客厅环境",
"温馨浪漫的咖啡厅",
"时尚现代的摄影棚",
"自然清新的户外花园",
"优雅精致的酒店套房",
"艺术感十足的工作室",
"温暖舒适的家居环境",
];
// 常用产品描述预设
export const PRODUCT_OPTIONS = [
"超短牛仔裙(白色紧身蕾丝短袖)",
"黑色修身连衣裙",
"白色衬衫配黑色短裙",
"粉色针织毛衣配牛仔裤",
"红色晚礼服",
"休闲运动装",
"职业套装",
"夏日清新连衣裙",
];
// 视频生成API相关类型
export interface VideoGenerationAPI {
createVideoGenerationTask: (request: CreateVideoGenerationRequest) => Promise<VideoGenerationTask>;
getVideoGenerationTask: (taskId: string) => Promise<VideoGenerationTask | null>;
getVideoGenerationTasks: (params?: VideoGenerationQueryParams) => Promise<VideoGenerationTask[]>;
cancelVideoGenerationTask: (taskId: string) => Promise<void>;
deleteVideoGenerationTask: (taskId: string) => Promise<void>;
retryVideoGenerationTask: (taskId: string) => Promise<VideoGenerationTask>;
}
// 视频生成状态显示配置
export const VIDEO_GENERATION_STATUS_CONFIG = {
[VideoGenerationStatus.Pending]: {
label: "等待中",
color: "text-yellow-600",
bgColor: "bg-yellow-50",
borderColor: "border-yellow-200",
},
[VideoGenerationStatus.Processing]: {
label: "处理中",
color: "text-blue-600",
bgColor: "bg-blue-50",
borderColor: "border-blue-200",
},
[VideoGenerationStatus.Completed]: {
label: "已完成",
color: "text-green-600",
bgColor: "bg-green-50",
borderColor: "border-green-200",
},
[VideoGenerationStatus.Failed]: {
label: "失败",
color: "text-red-600",
bgColor: "bg-red-50",
borderColor: "border-red-200",
},
[VideoGenerationStatus.Cancelled]: {
label: "已取消",
color: "text-gray-600",
bgColor: "bg-gray-50",
borderColor: "border-gray-200",
},
};

View File

@@ -15,7 +15,7 @@ export default defineConfig(async () => ({
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 5173,
strictPort: true,
strictPort: false,
host: host || false,
hmr: host
? {