完成模特动态页面开发,移除标题和描述字段
This commit is contained in:
@@ -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::model_dynamic_repository::ModelDynamicRepository;
|
||||
use crate::data::repositories::video_generation_repository::VideoGenerationRepository;
|
||||
use crate::infrastructure::database::Database;
|
||||
use crate::infrastructure::performance::PerformanceMonitor;
|
||||
@@ -14,6 +15,7 @@ pub struct AppState {
|
||||
pub project_repository: Mutex<Option<ProjectRepository>>,
|
||||
pub material_repository: Mutex<Option<MaterialRepository>>,
|
||||
pub model_repository: Mutex<Option<ModelRepository>>,
|
||||
pub model_dynamic_repository: Mutex<Option<ModelDynamicRepository>>,
|
||||
pub video_generation_repository: Mutex<Option<VideoGenerationRepository>>,
|
||||
pub performance_monitor: Mutex<PerformanceMonitor>,
|
||||
pub event_bus_manager: Arc<EventBusManager>,
|
||||
@@ -26,6 +28,7 @@ impl AppState {
|
||||
project_repository: Mutex::new(None),
|
||||
material_repository: Mutex::new(None),
|
||||
model_repository: Mutex::new(None),
|
||||
model_dynamic_repository: Mutex::new(None),
|
||||
video_generation_repository: Mutex::new(None),
|
||||
performance_monitor: Mutex::new(PerformanceMonitor::new()),
|
||||
event_bus_manager: Arc::new(EventBusManager::new()),
|
||||
@@ -60,15 +63,18 @@ impl AppState {
|
||||
let project_repository = ProjectRepository::new(database.clone())?;
|
||||
let material_repository = MaterialRepository::new(database.clone())?;
|
||||
let model_repository = ModelRepository::new(database.clone());
|
||||
let model_dynamic_repository = ModelDynamicRepository::new(database.clone());
|
||||
let video_generation_repository = VideoGenerationRepository::new(database.clone());
|
||||
|
||||
// 初始化视频生成任务表
|
||||
// 初始化数据库表
|
||||
model_dynamic_repository.init_tables()?;
|
||||
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.model_dynamic_repository.lock().unwrap() = Some(model_dynamic_repository);
|
||||
*self.video_generation_repository.lock().unwrap() = Some(video_generation_repository);
|
||||
|
||||
println!("数据库初始化完成,连接池状态: {}",
|
||||
@@ -83,15 +89,18 @@ impl AppState {
|
||||
let project_repository = ProjectRepository::new(database.clone())?;
|
||||
let material_repository = MaterialRepository::new(database.clone())?;
|
||||
let model_repository = ModelRepository::new(database.clone());
|
||||
let model_dynamic_repository = ModelDynamicRepository::new(database.clone());
|
||||
let video_generation_repository = VideoGenerationRepository::new(database.clone());
|
||||
|
||||
// 初始化视频生成任务表
|
||||
// 初始化数据库表
|
||||
model_dynamic_repository.init_tables()?;
|
||||
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.model_dynamic_repository.lock().unwrap() = Some(model_dynamic_repository);
|
||||
*self.video_generation_repository.lock().unwrap() = Some(video_generation_repository);
|
||||
|
||||
println!("数据库初始化完成,使用单连接模式");
|
||||
@@ -113,6 +122,11 @@ impl AppState {
|
||||
Ok(self.model_repository.lock().unwrap())
|
||||
}
|
||||
|
||||
/// 获取模特动态仓库实例
|
||||
pub fn get_model_dynamic_repository(&self) -> anyhow::Result<std::sync::MutexGuard<Option<ModelDynamicRepository>>> {
|
||||
Ok(self.model_dynamic_repository.lock().unwrap())
|
||||
}
|
||||
|
||||
/// 获取视频生成仓库实例
|
||||
pub fn get_video_generation_repository(&self) -> anyhow::Result<std::sync::MutexGuard<Option<VideoGenerationRepository>>> {
|
||||
Ok(self.video_generation_repository.lock().unwrap())
|
||||
@@ -144,6 +158,7 @@ impl AppState {
|
||||
project_repository: Mutex::new(None),
|
||||
material_repository: Mutex::new(None),
|
||||
model_repository: Mutex::new(None),
|
||||
model_dynamic_repository: Mutex::new(None),
|
||||
video_generation_repository: Mutex::new(None),
|
||||
performance_monitor: Mutex::new(PerformanceMonitor::new()),
|
||||
event_bus_manager: Arc::new(EventBusManager::new()),
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod project_service;
|
||||
pub mod material_service;
|
||||
pub mod material_segment_view_service;
|
||||
pub mod model_service;
|
||||
pub mod model_dynamic_service;
|
||||
pub mod async_material_service;
|
||||
pub mod ai_classification_service;
|
||||
pub mod video_classification_service;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
use crate::data::repositories::model_dynamic_repository::ModelDynamicRepository;
|
||||
use crate::data::models::model_dynamic::{
|
||||
ModelDynamic, GeneratedVideo, CreateModelDynamicRequest, UpdateModelDynamicRequest,
|
||||
ModelDynamicStats, DynamicStatus, VideoGenerationStatus
|
||||
};
|
||||
use crate::business::errors::BusinessError;
|
||||
|
||||
/// 模特动态服务
|
||||
/// 遵循 Tauri 开发规范的业务逻辑层设计原则
|
||||
pub struct ModelDynamicService;
|
||||
|
||||
impl ModelDynamicService {
|
||||
/// 创建模特动态
|
||||
pub fn create_dynamic(
|
||||
repository: &ModelDynamicRepository,
|
||||
request: CreateModelDynamicRequest,
|
||||
) -> Result<ModelDynamic> {
|
||||
// 验证请求数据
|
||||
if request.model_id.is_empty() {
|
||||
return Err(BusinessError::InvalidInput("模特ID不能为空".to_string()).into());
|
||||
}
|
||||
|
||||
if request.description.trim().is_empty() {
|
||||
return Err(BusinessError::InvalidInput("动态描述不能为空".to_string()).into());
|
||||
}
|
||||
|
||||
if request.prompt.trim().is_empty() {
|
||||
return Err(BusinessError::InvalidInput("提示词不能为空".to_string()).into());
|
||||
}
|
||||
|
||||
if request.source_image_path.trim().is_empty() {
|
||||
return Err(BusinessError::InvalidInput("源图片路径不能为空".to_string()).into());
|
||||
}
|
||||
|
||||
if request.video_count < 1 || request.video_count > 9 {
|
||||
return Err(BusinessError::InvalidInput("视频个数必须在1-9之间".to_string()).into());
|
||||
}
|
||||
|
||||
// 创建动态
|
||||
let dynamic = repository.create(request)
|
||||
.map_err(|e| anyhow!("创建模特动态失败: {}", e))?;
|
||||
|
||||
Ok(dynamic)
|
||||
}
|
||||
|
||||
/// 获取模特动态详情
|
||||
pub fn get_dynamic_by_id(
|
||||
repository: &ModelDynamicRepository,
|
||||
id: &str,
|
||||
) -> Result<Option<ModelDynamic>> {
|
||||
repository.get_by_id(id)
|
||||
.map_err(|e| anyhow!("获取模特动态详情失败: {}", e))
|
||||
}
|
||||
|
||||
/// 获取模特的所有动态
|
||||
pub fn get_dynamics_by_model_id(
|
||||
repository: &ModelDynamicRepository,
|
||||
model_id: &str,
|
||||
) -> Result<Vec<ModelDynamic>> {
|
||||
repository.get_by_model_id(model_id)
|
||||
.map_err(|e| anyhow!("获取模特动态列表失败: {}", e))
|
||||
}
|
||||
|
||||
/// 更新模特动态
|
||||
pub fn update_dynamic(
|
||||
repository: &ModelDynamicRepository,
|
||||
id: &str,
|
||||
request: UpdateModelDynamicRequest,
|
||||
) -> Result<ModelDynamic> {
|
||||
// 检查动态是否存在
|
||||
let dynamic = repository.get_by_id(id)?
|
||||
.ok_or_else(|| BusinessError::NotFound(format!("动态不存在: {}", id)))?;
|
||||
|
||||
// 验证状态转换
|
||||
if let Some(new_status) = &request.status {
|
||||
Self::validate_status_transition(&dynamic.status, new_status)?;
|
||||
}
|
||||
|
||||
// 更新动态
|
||||
repository.update(id, request)
|
||||
.map_err(|e| anyhow!("更新模特动态失败: {}", e))
|
||||
}
|
||||
|
||||
/// 删除模特动态
|
||||
pub fn delete_dynamic(
|
||||
repository: &ModelDynamicRepository,
|
||||
id: &str,
|
||||
) -> Result<()> {
|
||||
// 检查动态是否存在
|
||||
let _dynamic = repository.get_by_id(id)?
|
||||
.ok_or_else(|| BusinessError::NotFound(format!("动态不存在: {}", id)))?;
|
||||
|
||||
// 删除动态
|
||||
repository.delete(id)
|
||||
.map_err(|e| anyhow!("删除模特动态失败: {}", e))
|
||||
}
|
||||
|
||||
/// 获取模特动态统计
|
||||
pub fn get_stats_by_model_id(
|
||||
repository: &ModelDynamicRepository,
|
||||
model_id: &str,
|
||||
) -> Result<ModelDynamicStats> {
|
||||
repository.get_stats_by_model_id(model_id)
|
||||
.map_err(|e| anyhow!("获取模特动态统计失败: {}", e))
|
||||
}
|
||||
|
||||
/// 添加生成的视频
|
||||
pub fn add_generated_video(
|
||||
repository: &ModelDynamicRepository,
|
||||
dynamic_id: &str,
|
||||
video_path: String,
|
||||
) -> Result<GeneratedVideo> {
|
||||
// 检查动态是否存在
|
||||
let mut dynamic = repository.get_by_id(dynamic_id)?
|
||||
.ok_or_else(|| BusinessError::NotFound(format!("动态不存在: {}", dynamic_id)))?;
|
||||
|
||||
// 创建视频对象
|
||||
let video = GeneratedVideo::new(dynamic_id.to_string(), video_path);
|
||||
|
||||
// 添加到动态
|
||||
dynamic.add_generated_video(video.clone());
|
||||
|
||||
// 更新动态状态
|
||||
if dynamic.status == DynamicStatus::Draft {
|
||||
dynamic.update_status(DynamicStatus::Publishing);
|
||||
}
|
||||
|
||||
// 更新动态
|
||||
let request = UpdateModelDynamicRequest {
|
||||
title: None,
|
||||
description: None,
|
||||
prompt: None,
|
||||
status: Some(dynamic.status.clone()),
|
||||
};
|
||||
|
||||
repository.update(dynamic_id, request)?;
|
||||
|
||||
Ok(video)
|
||||
}
|
||||
|
||||
/// 更新视频生成进度
|
||||
pub fn update_video_progress(
|
||||
repository: &ModelDynamicRepository,
|
||||
dynamic_id: &str,
|
||||
video_id: &str,
|
||||
progress: u32,
|
||||
) -> Result<()> {
|
||||
// 检查动态是否存在
|
||||
let mut dynamic = repository.get_by_id(dynamic_id)?
|
||||
.ok_or_else(|| BusinessError::NotFound(format!("动态不存在: {}", dynamic_id)))?;
|
||||
|
||||
// 查找视频
|
||||
let video_index = dynamic.generated_videos.iter().position(|v| v.id == video_id)
|
||||
.ok_or_else(|| BusinessError::NotFound(format!("视频不存在: {}", video_id)))?;
|
||||
|
||||
// 更新进度
|
||||
dynamic.generated_videos[video_index].update_progress(progress);
|
||||
|
||||
// 检查是否所有视频都已完成
|
||||
let all_completed = dynamic.generated_videos.iter()
|
||||
.all(|v| v.status == VideoGenerationStatus::Completed || v.status == VideoGenerationStatus::Failed);
|
||||
|
||||
// 如果所有视频都已完成,更新动态状态为已发布
|
||||
if all_completed && dynamic.status == DynamicStatus::Publishing {
|
||||
dynamic.update_status(DynamicStatus::Published);
|
||||
}
|
||||
|
||||
// 更新动态
|
||||
let request = UpdateModelDynamicRequest {
|
||||
title: None,
|
||||
description: None,
|
||||
prompt: None,
|
||||
status: Some(dynamic.status.clone()),
|
||||
};
|
||||
|
||||
repository.update(dynamic_id, request)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 标记视频生成失败
|
||||
pub fn mark_video_as_failed(
|
||||
repository: &ModelDynamicRepository,
|
||||
dynamic_id: &str,
|
||||
video_id: &str,
|
||||
error_message: String,
|
||||
) -> Result<()> {
|
||||
// 检查动态是否存在
|
||||
let mut dynamic = repository.get_by_id(dynamic_id)?
|
||||
.ok_or_else(|| BusinessError::NotFound(format!("动态不存在: {}", dynamic_id)))?;
|
||||
|
||||
// 查找视频
|
||||
let video_index = dynamic.generated_videos.iter().position(|v| v.id == video_id)
|
||||
.ok_or_else(|| BusinessError::NotFound(format!("视频不存在: {}", video_id)))?;
|
||||
|
||||
// 标记为失败
|
||||
dynamic.generated_videos[video_index].mark_as_failed(error_message);
|
||||
|
||||
// 检查是否所有视频都已完成
|
||||
let all_completed = dynamic.generated_videos.iter()
|
||||
.all(|v| v.status == VideoGenerationStatus::Completed || v.status == VideoGenerationStatus::Failed);
|
||||
|
||||
// 如果所有视频都已完成,更新动态状态为已发布
|
||||
if all_completed && dynamic.status == DynamicStatus::Publishing {
|
||||
dynamic.update_status(DynamicStatus::Published);
|
||||
}
|
||||
|
||||
// 更新动态
|
||||
let request = UpdateModelDynamicRequest {
|
||||
title: None,
|
||||
description: None,
|
||||
prompt: None,
|
||||
status: Some(dynamic.status.clone()),
|
||||
};
|
||||
|
||||
repository.update(dynamic_id, request)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证状态转换
|
||||
fn validate_status_transition(
|
||||
current_status: &DynamicStatus,
|
||||
new_status: &DynamicStatus,
|
||||
) -> Result<()> {
|
||||
match (current_status, new_status) {
|
||||
// 允许的状态转换
|
||||
(DynamicStatus::Draft, DynamicStatus::Publishing) => Ok(()),
|
||||
(DynamicStatus::Publishing, DynamicStatus::Published) => Ok(()),
|
||||
(DynamicStatus::Publishing, DynamicStatus::Failed) => Ok(()),
|
||||
(DynamicStatus::Failed, DynamicStatus::Publishing) => Ok(()),
|
||||
// 相同状态
|
||||
(a, b) if a == b => Ok(()),
|
||||
// 不允许的状态转换
|
||||
_ => Err(BusinessError::InvalidState(format!(
|
||||
"不允许的状态转换: {:?} -> {:?}",
|
||||
current_status, new_status
|
||||
)).into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pub mod material;
|
||||
pub mod material_segment_view;
|
||||
pub mod material_usage;
|
||||
pub mod model;
|
||||
pub mod model_dynamic;
|
||||
pub mod ai_classification;
|
||||
pub mod video_classification;
|
||||
pub mod template;
|
||||
|
||||
245
apps/desktop/src-tauri/src/data/models/model_dynamic.rs
Normal file
245
apps/desktop/src-tauri/src/data/models/model_dynamic.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 模特动态实体模型
|
||||
/// 遵循 Tauri 开发规范的数据模型设计原则
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelDynamic {
|
||||
pub id: String,
|
||||
pub model_id: String,
|
||||
pub title: Option<String>,
|
||||
pub description: String,
|
||||
pub prompt: String,
|
||||
pub source_image_path: String,
|
||||
pub ai_model: String, // 使用的AI模型,如"极梦"
|
||||
pub video_count: u32, // 生成视频个数
|
||||
pub generated_videos: Vec<GeneratedVideo>,
|
||||
pub status: DynamicStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 生成的视频
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneratedVideo {
|
||||
pub id: String,
|
||||
pub dynamic_id: String,
|
||||
pub video_path: String,
|
||||
pub thumbnail_path: Option<String>,
|
||||
pub file_size: u64,
|
||||
pub duration: u32, // 视频时长(秒)
|
||||
pub status: VideoGenerationStatus,
|
||||
pub generation_progress: Option<u32>, // 生成进度 0-100
|
||||
pub error_message: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 动态状态枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum DynamicStatus {
|
||||
Draft, // 草稿
|
||||
Publishing, // 发布中
|
||||
Published, // 已发布
|
||||
Failed, // 失败
|
||||
}
|
||||
|
||||
/// 视频生成状态枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum VideoGenerationStatus {
|
||||
Pending, // 等待中
|
||||
Generating, // 生成中
|
||||
Completed, // 已完成
|
||||
Failed, // 失败
|
||||
}
|
||||
|
||||
/// 创建动态请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateModelDynamicRequest {
|
||||
pub model_id: String,
|
||||
pub title: Option<String>,
|
||||
pub description: String,
|
||||
pub prompt: String,
|
||||
pub source_image_path: String,
|
||||
pub ai_model: String,
|
||||
pub video_count: u32,
|
||||
}
|
||||
|
||||
/// 更新动态请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateModelDynamicRequest {
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub prompt: Option<String>,
|
||||
pub status: Option<DynamicStatus>,
|
||||
}
|
||||
|
||||
/// 模特动态统计
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelDynamicStats {
|
||||
pub total_dynamics: u32,
|
||||
pub published_dynamics: u32,
|
||||
pub total_videos: u32,
|
||||
pub completed_videos: u32,
|
||||
pub generating_videos: u32,
|
||||
pub failed_videos: u32,
|
||||
}
|
||||
|
||||
/// AI模型选项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AIModelOption {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_available: bool,
|
||||
pub max_video_count: u32,
|
||||
}
|
||||
|
||||
impl ModelDynamic {
|
||||
/// 创建新的模特动态
|
||||
pub fn new(
|
||||
model_id: String,
|
||||
description: String,
|
||||
prompt: String,
|
||||
source_image_path: String,
|
||||
ai_model: String,
|
||||
video_count: u32,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
model_id,
|
||||
title: None,
|
||||
description,
|
||||
prompt,
|
||||
source_image_path,
|
||||
ai_model,
|
||||
video_count,
|
||||
generated_videos: Vec::new(),
|
||||
status: DynamicStatus::Draft,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证动态数据
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.model_id.is_empty() {
|
||||
return Err("模特ID不能为空".to_string());
|
||||
}
|
||||
|
||||
if self.description.trim().is_empty() {
|
||||
return Err("动态描述不能为空".to_string());
|
||||
}
|
||||
|
||||
if self.prompt.trim().is_empty() {
|
||||
return Err("提示词不能为空".to_string());
|
||||
}
|
||||
|
||||
if self.source_image_path.trim().is_empty() {
|
||||
return Err("源图片路径不能为空".to_string());
|
||||
}
|
||||
|
||||
if self.ai_model.trim().is_empty() {
|
||||
return Err("AI模型不能为空".to_string());
|
||||
}
|
||||
|
||||
if self.video_count == 0 || self.video_count > 9 {
|
||||
return Err("视频个数必须在1-9之间".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新状态
|
||||
pub fn update_status(&mut self, status: DynamicStatus) {
|
||||
self.status = status;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 添加生成的视频
|
||||
pub fn add_generated_video(&mut self, video: GeneratedVideo) {
|
||||
self.generated_videos.push(video);
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 获取已完成的视频数量
|
||||
pub fn completed_video_count(&self) -> usize {
|
||||
self.generated_videos
|
||||
.iter()
|
||||
.filter(|v| v.status == VideoGenerationStatus::Completed)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 获取生成中的视频数量
|
||||
pub fn generating_video_count(&self) -> usize {
|
||||
self.generated_videos
|
||||
.iter()
|
||||
.filter(|v| v.status == VideoGenerationStatus::Generating)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 获取失败的视频数量
|
||||
pub fn failed_video_count(&self) -> usize {
|
||||
self.generated_videos
|
||||
.iter()
|
||||
.filter(|v| v.status == VideoGenerationStatus::Failed)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
impl GeneratedVideo {
|
||||
/// 创建新的生成视频
|
||||
pub fn new(dynamic_id: String, video_path: String) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
dynamic_id,
|
||||
video_path,
|
||||
thumbnail_path: None,
|
||||
file_size: 0,
|
||||
duration: 0,
|
||||
status: VideoGenerationStatus::Pending,
|
||||
generation_progress: None,
|
||||
error_message: None,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新生成进度
|
||||
pub fn update_progress(&mut self, progress: u32) {
|
||||
self.generation_progress = Some(progress);
|
||||
if progress >= 100 {
|
||||
self.status = VideoGenerationStatus::Completed;
|
||||
} else {
|
||||
self.status = VideoGenerationStatus::Generating;
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记为失败
|
||||
pub fn mark_as_failed(&mut self, error_message: String) {
|
||||
self.status = VideoGenerationStatus::Failed;
|
||||
self.error_message = Some(error_message);
|
||||
self.generation_progress = None;
|
||||
}
|
||||
|
||||
/// 标记为完成
|
||||
pub fn mark_as_completed(&mut self, file_size: u64, duration: u32, thumbnail_path: Option<String>) {
|
||||
self.status = VideoGenerationStatus::Completed;
|
||||
self.file_size = file_size;
|
||||
self.duration = duration;
|
||||
self.thumbnail_path = thumbnail_path;
|
||||
self.generation_progress = Some(100);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DynamicStatus {
|
||||
fn default() -> Self {
|
||||
DynamicStatus::Draft
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VideoGenerationStatus {
|
||||
fn default() -> Self {
|
||||
VideoGenerationStatus::Pending
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod project_repository;
|
||||
pub mod material_repository;
|
||||
pub mod material_usage_repository;
|
||||
pub mod model_repository;
|
||||
pub mod model_dynamic_repository;
|
||||
pub mod ai_classification_repository;
|
||||
pub mod video_classification_repository;
|
||||
pub mod project_template_binding_repository;
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
use rusqlite::{params, Row};
|
||||
use serde_json;
|
||||
|
||||
use crate::infrastructure::database::Database;
|
||||
use crate::data::models::model_dynamic::{
|
||||
ModelDynamic, GeneratedVideo, CreateModelDynamicRequest, UpdateModelDynamicRequest,
|
||||
ModelDynamicStats, DynamicStatus, VideoGenerationStatus
|
||||
};
|
||||
|
||||
/// 模特动态数据仓库
|
||||
/// 遵循 Tauri 开发规范的数据访问层设计原则
|
||||
#[derive(Clone)]
|
||||
pub struct ModelDynamicRepository {
|
||||
database: Arc<Database>,
|
||||
}
|
||||
|
||||
impl ModelDynamicRepository {
|
||||
/// 创建新的模特动态仓库实例
|
||||
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 model_dynamics (
|
||||
id TEXT PRIMARY KEY,
|
||||
model_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
source_image_path TEXT NOT NULL,
|
||||
ai_model TEXT NOT NULL,
|
||||
video_count INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'Draft',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (model_id) REFERENCES models (id)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建生成视频表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS generated_videos (
|
||||
id TEXT PRIMARY KEY,
|
||||
dynamic_id TEXT NOT NULL,
|
||||
video_path TEXT NOT NULL,
|
||||
thumbnail_path TEXT,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
duration INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'Pending',
|
||||
generation_progress INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (dynamic_id) REFERENCES model_dynamics (id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_model_dynamics_model_id ON model_dynamics (model_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_model_dynamics_status ON model_dynamics (status)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_generated_videos_dynamic_id ON generated_videos (dynamic_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 创建模特动态
|
||||
pub fn create(&self, request: CreateModelDynamicRequest) -> Result<ModelDynamic> {
|
||||
let mut dynamic = ModelDynamic::new(
|
||||
request.model_id,
|
||||
request.description,
|
||||
request.prompt,
|
||||
request.source_image_path,
|
||||
request.ai_model,
|
||||
request.video_count,
|
||||
);
|
||||
|
||||
if let Some(title) = request.title {
|
||||
dynamic.title = Some(title);
|
||||
}
|
||||
|
||||
dynamic.validate().map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO model_dynamics (
|
||||
id, model_id, title, description, prompt, source_image_path,
|
||||
ai_model, video_count, status, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
dynamic.id,
|
||||
dynamic.model_id,
|
||||
dynamic.title,
|
||||
dynamic.description,
|
||||
dynamic.prompt,
|
||||
dynamic.source_image_path,
|
||||
dynamic.ai_model,
|
||||
dynamic.video_count,
|
||||
serde_json::to_string(&dynamic.status)?,
|
||||
dynamic.created_at.to_rfc3339(),
|
||||
dynamic.updated_at.to_rfc3339(),
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(dynamic)
|
||||
}
|
||||
|
||||
/// 根据ID获取动态
|
||||
pub fn get_by_id(&self, id: &str) -> Result<Option<ModelDynamic>> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, model_id, title, description, prompt, source_image_path,
|
||||
ai_model, video_count, status, created_at, updated_at
|
||||
FROM model_dynamics WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let dynamic_iter = stmt.query_map([id], |row| {
|
||||
self.row_to_dynamic(row)
|
||||
})?;
|
||||
|
||||
for dynamic in dynamic_iter {
|
||||
let mut dynamic = dynamic?;
|
||||
// 加载生成的视频
|
||||
dynamic.generated_videos = self.get_videos_by_dynamic_id(&dynamic.id)?;
|
||||
return Ok(Some(dynamic));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 根据模特ID获取动态列表
|
||||
pub fn get_by_model_id(&self, model_id: &str) -> Result<Vec<ModelDynamic>> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, model_id, title, description, prompt, source_image_path,
|
||||
ai_model, video_count, status, created_at, updated_at
|
||||
FROM model_dynamics WHERE model_id = ?1 ORDER BY created_at DESC"
|
||||
)?;
|
||||
|
||||
let dynamic_iter = stmt.query_map([model_id], |row| {
|
||||
self.row_to_dynamic(row)
|
||||
})?;
|
||||
|
||||
let mut dynamics = Vec::new();
|
||||
for dynamic in dynamic_iter {
|
||||
let mut dynamic = dynamic?;
|
||||
// 加载生成的视频
|
||||
dynamic.generated_videos = self.get_videos_by_dynamic_id(&dynamic.id)?;
|
||||
dynamics.push(dynamic);
|
||||
}
|
||||
|
||||
Ok(dynamics)
|
||||
}
|
||||
|
||||
/// 更新动态
|
||||
pub fn update(&self, id: &str, request: UpdateModelDynamicRequest) -> Result<ModelDynamic> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// 构建更新语句
|
||||
let mut updates = Vec::new();
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(title) = &request.title {
|
||||
updates.push("title = ?");
|
||||
params.push(Box::new(title.clone()));
|
||||
}
|
||||
|
||||
if let Some(description) = &request.description {
|
||||
updates.push("description = ?");
|
||||
params.push(Box::new(description.clone()));
|
||||
}
|
||||
|
||||
if let Some(prompt) = &request.prompt {
|
||||
updates.push("prompt = ?");
|
||||
params.push(Box::new(prompt.clone()));
|
||||
}
|
||||
|
||||
if let Some(status) = &request.status {
|
||||
updates.push("status = ?");
|
||||
params.push(Box::new(serde_json::to_string(status)?));
|
||||
}
|
||||
|
||||
if updates.is_empty() {
|
||||
return Err(anyhow::anyhow!("没有要更新的字段"));
|
||||
}
|
||||
|
||||
updates.push("updated_at = ?");
|
||||
params.push(Box::new(chrono::Utc::now().to_rfc3339()));
|
||||
params.push(Box::new(id.to_string()));
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE model_dynamics SET {} WHERE id = ?",
|
||||
updates.join(", ")
|
||||
);
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
conn.execute(&sql, ¶ms_refs[..])?;
|
||||
|
||||
// 返回更新后的动态
|
||||
self.get_by_id(id)?.ok_or_else(|| anyhow::anyhow!("动态不存在"))
|
||||
}
|
||||
|
||||
/// 删除动态
|
||||
pub fn delete(&self, id: &str) -> Result<()> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// 删除动态(级联删除视频)
|
||||
conn.execute("DELETE FROM model_dynamics WHERE id = ?1", [id])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取模特动态统计
|
||||
pub fn get_stats_by_model_id(&self, model_id: &str) -> Result<ModelDynamicStats> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// 获取动态统计
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
COUNT(*) as total_dynamics,
|
||||
SUM(CASE WHEN status = '\"Published\"' THEN 1 ELSE 0 END) as published_dynamics
|
||||
FROM model_dynamics WHERE model_id = ?1"
|
||||
)?;
|
||||
|
||||
let (total_dynamics, published_dynamics) = stmt.query_row([model_id], |row| {
|
||||
Ok((row.get::<_, u32>(0)?, row.get::<_, u32>(1)?))
|
||||
})?;
|
||||
|
||||
// 获取视频统计
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
COUNT(*) as total_videos,
|
||||
SUM(CASE WHEN status = '\"Completed\"' THEN 1 ELSE 0 END) as completed_videos,
|
||||
SUM(CASE WHEN status = '\"Generating\"' THEN 1 ELSE 0 END) as generating_videos,
|
||||
SUM(CASE WHEN status = '\"Failed\"' THEN 1 ELSE 0 END) as failed_videos
|
||||
FROM generated_videos
|
||||
WHERE dynamic_id IN (SELECT id FROM model_dynamics WHERE model_id = ?1)"
|
||||
)?;
|
||||
|
||||
let (total_videos, completed_videos, generating_videos, failed_videos) =
|
||||
stmt.query_row([model_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, u32>(0)?,
|
||||
row.get::<_, u32>(1)?,
|
||||
row.get::<_, u32>(2)?,
|
||||
row.get::<_, u32>(3)?
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(ModelDynamicStats {
|
||||
total_dynamics,
|
||||
published_dynamics,
|
||||
total_videos,
|
||||
completed_videos,
|
||||
generating_videos,
|
||||
failed_videos,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取动态的生成视频列表
|
||||
fn get_videos_by_dynamic_id(&self, dynamic_id: &str) -> Result<Vec<GeneratedVideo>> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, dynamic_id, video_path, thumbnail_path, file_size, duration,
|
||||
status, generation_progress, error_message, created_at
|
||||
FROM generated_videos WHERE dynamic_id = ?1 ORDER BY created_at ASC"
|
||||
)?;
|
||||
|
||||
let video_iter = stmt.query_map([dynamic_id], |row| {
|
||||
self.row_to_video(row)
|
||||
})?;
|
||||
|
||||
let mut videos = Vec::new();
|
||||
for video in video_iter {
|
||||
videos.push(video?);
|
||||
}
|
||||
|
||||
Ok(videos)
|
||||
}
|
||||
|
||||
/// 将数据库行转换为动态对象
|
||||
fn row_to_dynamic(&self, row: &Row) -> rusqlite::Result<ModelDynamic> {
|
||||
let status_str: String = row.get("status")?;
|
||||
let status: DynamicStatus = serde_json::from_str(&status_str)
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("status").unwrap(),
|
||||
format!("Invalid status: {}", e).into(),
|
||||
rusqlite::types::Type::Text
|
||||
))?;
|
||||
|
||||
Ok(ModelDynamic {
|
||||
id: row.get("id")?,
|
||||
model_id: row.get("model_id")?,
|
||||
title: row.get("title")?,
|
||||
description: row.get("description")?,
|
||||
prompt: row.get("prompt")?,
|
||||
source_image_path: row.get("source_image_path")?,
|
||||
ai_model: row.get("ai_model")?,
|
||||
video_count: row.get("video_count")?,
|
||||
generated_videos: Vec::new(), // 将在外部加载
|
||||
status,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("created_at")?)
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("created_at").unwrap(),
|
||||
format!("Invalid datetime: {}", e).into(),
|
||||
rusqlite::types::Type::Text
|
||||
))?
|
||||
.with_timezone(&chrono::Utc),
|
||||
updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("updated_at")?)
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("updated_at").unwrap(),
|
||||
format!("Invalid datetime: {}", e).into(),
|
||||
rusqlite::types::Type::Text
|
||||
))?
|
||||
.with_timezone(&chrono::Utc),
|
||||
})
|
||||
}
|
||||
|
||||
/// 将数据库行转换为视频对象
|
||||
fn row_to_video(&self, row: &Row) -> rusqlite::Result<GeneratedVideo> {
|
||||
let status_str: String = row.get("status")?;
|
||||
let status: VideoGenerationStatus = serde_json::from_str(&status_str)
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("status").unwrap(),
|
||||
format!("Invalid status: {}", e).into(),
|
||||
rusqlite::types::Type::Text
|
||||
))?;
|
||||
|
||||
Ok(GeneratedVideo {
|
||||
id: row.get("id")?,
|
||||
dynamic_id: row.get("dynamic_id")?,
|
||||
video_path: row.get("video_path")?,
|
||||
thumbnail_path: row.get("thumbnail_path")?,
|
||||
file_size: row.get("file_size")?,
|
||||
duration: row.get("duration")?,
|
||||
status,
|
||||
generation_progress: row.get("generation_progress")?,
|
||||
error_message: row.get("error_message")?,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("created_at")?)
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("created_at").unwrap(),
|
||||
format!("Invalid datetime: {}", e).into(),
|
||||
rusqlite::types::Type::Text
|
||||
))?
|
||||
.with_timezone(&chrono::Utc),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,17 @@ pub fn run() {
|
||||
commands::model_commands::get_model_statistics,
|
||||
commands::model_commands::select_photo_files,
|
||||
commands::model_commands::select_photo_file,
|
||||
// 模特动态管理命令
|
||||
commands::model_dynamic_commands::create_model_dynamic,
|
||||
commands::model_dynamic_commands::get_model_dynamic_by_id,
|
||||
commands::model_dynamic_commands::get_model_dynamics_by_model_id,
|
||||
commands::model_dynamic_commands::update_model_dynamic,
|
||||
commands::model_dynamic_commands::delete_model_dynamic,
|
||||
commands::model_dynamic_commands::get_model_dynamic_stats,
|
||||
commands::model_dynamic_commands::regenerate_dynamic_video,
|
||||
commands::model_dynamic_commands::get_available_ai_models,
|
||||
commands::model_dynamic_commands::update_video_generation_progress,
|
||||
commands::model_dynamic_commands::mark_video_generation_failed,
|
||||
// AI分类管理命令
|
||||
commands::ai_classification_commands::create_ai_classification,
|
||||
commands::ai_classification_commands::get_all_ai_classifications,
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod material_usage_commands;
|
||||
pub mod database_commands;
|
||||
pub mod material_segment_view_commands;
|
||||
pub mod model_commands;
|
||||
pub mod model_dynamic_commands;
|
||||
pub mod ai_classification_commands;
|
||||
pub mod video_classification_commands;
|
||||
pub mod ai_analysis_log_commands;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
use tauri::{command, State};
|
||||
use crate::app_state::AppState;
|
||||
use crate::business::services::model_dynamic_service::ModelDynamicService;
|
||||
use crate::data::models::model_dynamic::{
|
||||
ModelDynamic, CreateModelDynamicRequest, UpdateModelDynamicRequest, ModelDynamicStats
|
||||
};
|
||||
|
||||
/// 创建模特动态命令
|
||||
/// 遵循 Tauri 开发规范的命令设计模式
|
||||
#[command]
|
||||
pub async fn create_model_dynamic(
|
||||
state: State<'_, AppState>,
|
||||
request: CreateModelDynamicRequest,
|
||||
) -> Result<ModelDynamic, String> {
|
||||
let repository_guard = state.get_model_dynamic_repository()
|
||||
.map_err(|e| format!("获取模特动态仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特动态仓库未初始化")?;
|
||||
|
||||
ModelDynamicService::create_dynamic(repository, request)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取模特动态详情命令
|
||||
#[command]
|
||||
pub async fn get_model_dynamic_by_id(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Option<ModelDynamic>, String> {
|
||||
let repository_guard = state.get_model_dynamic_repository()
|
||||
.map_err(|e| format!("获取模特动态仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特动态仓库未初始化")?;
|
||||
|
||||
ModelDynamicService::get_dynamic_by_id(repository, &id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取模特的所有动态命令
|
||||
#[command]
|
||||
pub async fn get_model_dynamics_by_model_id(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
) -> Result<Vec<ModelDynamic>, String> {
|
||||
let repository_guard = state.get_model_dynamic_repository()
|
||||
.map_err(|e| format!("获取模特动态仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特动态仓库未初始化")?;
|
||||
|
||||
ModelDynamicService::get_dynamics_by_model_id(repository, &model_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新模特动态命令
|
||||
#[command]
|
||||
pub async fn update_model_dynamic(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
request: UpdateModelDynamicRequest,
|
||||
) -> Result<ModelDynamic, String> {
|
||||
let repository_guard = state.get_model_dynamic_repository()
|
||||
.map_err(|e| format!("获取模特动态仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特动态仓库未初始化")?;
|
||||
|
||||
ModelDynamicService::update_dynamic(repository, &id, request)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 删除模特动态命令
|
||||
#[command]
|
||||
pub async fn delete_model_dynamic(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_model_dynamic_repository()
|
||||
.map_err(|e| format!("获取模特动态仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特动态仓库未初始化")?;
|
||||
|
||||
ModelDynamicService::delete_dynamic(repository, &id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取模特动态统计命令
|
||||
#[command]
|
||||
pub async fn get_model_dynamic_stats(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
) -> Result<ModelDynamicStats, String> {
|
||||
let repository_guard = state.get_model_dynamic_repository()
|
||||
.map_err(|e| format!("获取模特动态仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特动态仓库未初始化")?;
|
||||
|
||||
ModelDynamicService::get_stats_by_model_id(repository, &model_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 重新生成视频命令
|
||||
#[command]
|
||||
pub async fn regenerate_dynamic_video(
|
||||
state: State<'_, AppState>,
|
||||
dynamic_id: String,
|
||||
video_id: String,
|
||||
) -> Result<(), String> {
|
||||
// TODO: 实现视频重新生成逻辑
|
||||
// 这里可以调用视频生成服务来重新生成指定的视频
|
||||
println!("重新生成视频: dynamic_id={}, video_id={}", dynamic_id, video_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取可用的AI模型列表命令
|
||||
#[command]
|
||||
pub async fn get_available_ai_models() -> Result<Vec<serde_json::Value>, String> {
|
||||
// 返回可用的AI模型列表
|
||||
let models = vec![
|
||||
serde_json::json!({
|
||||
"id": "jimeng",
|
||||
"name": "极梦",
|
||||
"description": "高质量视频生成模型",
|
||||
"is_available": true,
|
||||
"max_video_count": 9
|
||||
})
|
||||
];
|
||||
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// 更新视频生成进度命令
|
||||
#[command]
|
||||
pub async fn update_video_generation_progress(
|
||||
state: State<'_, AppState>,
|
||||
dynamic_id: String,
|
||||
video_id: String,
|
||||
progress: u32,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_model_dynamic_repository()
|
||||
.map_err(|e| format!("获取模特动态仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特动态仓库未初始化")?;
|
||||
|
||||
ModelDynamicService::update_video_progress(repository, &dynamic_id, &video_id, progress)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 标记视频生成失败命令
|
||||
#[command]
|
||||
pub async fn mark_video_generation_failed(
|
||||
state: State<'_, AppState>,
|
||||
dynamic_id: String,
|
||||
video_id: String,
|
||||
error_message: String,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_model_dynamic_repository()
|
||||
.map_err(|e| format!("获取模特动态仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特动态仓库未初始化")?;
|
||||
|
||||
ModelDynamicService::mark_video_as_failed(repository, &dynamic_id, &video_id, error_message)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ProjectForm } from './components/ProjectForm';
|
||||
import { ProjectDetails } from './pages/ProjectDetails';
|
||||
import Models from './pages/Models';
|
||||
import ModelDetail from './pages/ModelDetail';
|
||||
import ModelDynamics from './pages/ModelDynamics';
|
||||
import AiClassificationSettings from './pages/AiClassificationSettings';
|
||||
import TemplateManagement from './pages/TemplateManagement';
|
||||
import { MaterialModelBinding } from './pages/MaterialModelBinding';
|
||||
@@ -77,6 +78,7 @@ function App() {
|
||||
<Route path="/project/:id" element={<ProjectDetails />} />
|
||||
<Route path="/models" element={<Models />} />
|
||||
<Route path="/models/:id" element={<ModelDetail />} />
|
||||
<Route path="/models/:modelId/dynamics" element={<ModelDynamics />} />
|
||||
<Route path="/ai-classification-settings" element={<AiClassificationSettings />} />
|
||||
<Route path="/templates" element={<TemplateManagement />} />
|
||||
<Route path="/material-model-binding" element={<MaterialModelBinding />} />
|
||||
|
||||
265
apps/desktop/src/components/CreateDynamicModal.tsx
Normal file
265
apps/desktop/src/components/CreateDynamicModal.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Model, CreateDynamicRequest, AIModelOption } from '../types/model';
|
||||
import {
|
||||
XMarkIcon,
|
||||
PhotoIcon,
|
||||
SparklesIcon,
|
||||
CpuChipIcon,
|
||||
VideoCameraIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface CreateDynamicModalProps {
|
||||
model: Model;
|
||||
onSubmit: (data: CreateDynamicRequest) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const CreateDynamicModal: React.FC<CreateDynamicModalProps> = ({
|
||||
model,
|
||||
onSubmit,
|
||||
onCancel
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
prompt: '',
|
||||
source_image_path: '',
|
||||
ai_model: '极梦',
|
||||
video_count: 1
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 可用的AI模型选项
|
||||
const aiModelOptions: AIModelOption[] = [
|
||||
{
|
||||
id: 'jimeng',
|
||||
name: '极梦',
|
||||
description: '高质量视频生成模型',
|
||||
is_available: true,
|
||||
max_video_count: 9
|
||||
}
|
||||
];
|
||||
|
||||
const handleInputChange = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// 清除对应字段的错误
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageSelect = async () => {
|
||||
try {
|
||||
// TODO: 实现图片选择功能
|
||||
// const selectedPath = await systemService.selectImageFile();
|
||||
// if (selectedPath) {
|
||||
// handleInputChange('source_image_path', selectedPath);
|
||||
// }
|
||||
console.log('选择图片功能待实现');
|
||||
} catch (error) {
|
||||
console.error('选择图片失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.prompt.trim()) {
|
||||
newErrors.prompt = '请输入提示词';
|
||||
}
|
||||
|
||||
if (!formData.source_image_path) {
|
||||
newErrors.source_image_path = '请选择源图片';
|
||||
}
|
||||
|
||||
if (formData.video_count < 1 || formData.video_count > 9) {
|
||||
newErrors.video_count = '视频个数必须在1-9之间';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const submitData: CreateDynamicRequest = {
|
||||
model_id: model.id,
|
||||
title: undefined,
|
||||
description: '模特动态', // 使用默认描述
|
||||
prompt: formData.prompt.trim(),
|
||||
source_image_path: formData.source_image_path,
|
||||
ai_model: formData.ai_model,
|
||||
video_count: formData.video_count
|
||||
};
|
||||
|
||||
onSubmit(submitData);
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden animate-scale-in">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
|
||||
<SparklesIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">生成视频</h2>
|
||||
<p className="text-sm text-gray-500">为 {model.name} 生成AI视频</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 表单内容 */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-140px)]">
|
||||
|
||||
{/* 提示词 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
AI提示词 *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.prompt}
|
||||
onChange={(e) => handleInputChange('prompt', e.target.value)}
|
||||
placeholder="描述您希望生成的视频内容..."
|
||||
rows={4}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none ${
|
||||
errors.prompt ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{errors.prompt && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.prompt}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 源图片 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
|
||||
<PhotoIcon className="h-4 w-4" />
|
||||
源图片 *
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImageSelect}
|
||||
className={`w-full p-4 border-2 border-dashed rounded-lg transition-colors ${
|
||||
errors.source_image_path
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-primary-400 hover:bg-primary-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<PhotoIcon className="h-8 w-8 text-gray-400" />
|
||||
<span className="text-sm text-gray-600">点击选择图片</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{formData.source_image_path && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={formData.source_image_path}
|
||||
alt="源图片预览"
|
||||
className="w-full h-48 object-contain bg-gray-50 rounded-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInputChange('source_image_path', '')}
|
||||
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.source_image_path && (
|
||||
<p className="text-sm text-red-600">{errors.source_image_path}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI模型选择 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
|
||||
<CpuChipIcon className="h-4 w-4" />
|
||||
AI模型
|
||||
</label>
|
||||
<select
|
||||
value={formData.ai_model}
|
||||
onChange={(e) => handleInputChange('ai_model', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
|
||||
>
|
||||
{aiModelOptions.map((option) => (
|
||||
<option key={option.id} value={option.name} disabled={!option.is_available}>
|
||||
{option.name} - {option.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 视频个数 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
|
||||
<VideoCameraIcon className="h-4 w-4" />
|
||||
生成视频个数
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="9"
|
||||
value={formData.video_count}
|
||||
onChange={(e) => handleInputChange('video_count', parseInt(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 min-w-[2rem]">
|
||||
{formData.video_count}
|
||||
</span>
|
||||
</div>
|
||||
{errors.video_count && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.video_count}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-2 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 transition-all duration-200 shadow-sm hover:shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? '生成中...' : '开始生成'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateDynamicModal;
|
||||
190
apps/desktop/src/components/ModelDynamicHeader.tsx
Normal file
190
apps/desktop/src/components/ModelDynamicHeader.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
import { Model, ModelDynamicStats, Gender } from '../types/model';
|
||||
import {
|
||||
UserIcon,
|
||||
SparklesIcon,
|
||||
VideoCameraIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
PlusIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface ModelDynamicHeaderProps {
|
||||
model: Model;
|
||||
stats: ModelDynamicStats | null;
|
||||
onCreateDynamic: () => void;
|
||||
}
|
||||
|
||||
const ModelDynamicHeader: React.FC<ModelDynamicHeaderProps> = ({
|
||||
model,
|
||||
stats,
|
||||
onCreateDynamic
|
||||
}) => {
|
||||
const getGenderIcon = (gender: Gender) => {
|
||||
switch (gender) {
|
||||
case Gender.Male:
|
||||
return '♂️';
|
||||
case Gender.Female:
|
||||
return '♀️';
|
||||
default:
|
||||
return '⚧️';
|
||||
}
|
||||
};
|
||||
|
||||
const getGenderColor = (gender: Gender) => {
|
||||
switch (gender) {
|
||||
case Gender.Male:
|
||||
return 'text-blue-600 bg-blue-100';
|
||||
case Gender.Female:
|
||||
return 'text-pink-600 bg-pink-100';
|
||||
default:
|
||||
return 'text-purple-600 bg-purple-100';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-white via-primary-50/30 to-white rounded-xl shadow-sm border border-gray-200/50 p-6 relative overflow-hidden">
|
||||
{/* 装饰性背景 */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-primary-100/30 to-primary-200/30 rounded-full -translate-y-16 translate-x-16 opacity-50"></div>
|
||||
<div className="absolute bottom-0 left-0 w-24 h-24 bg-gradient-to-tr from-primary-100/20 to-primary-200/20 rounded-full translate-y-12 -translate-x-12 opacity-30"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* 主要信息区域 */}
|
||||
<div className="flex flex-col lg:flex-row items-start lg:items-center gap-6 mb-6">
|
||||
{/* 头像和基本信息 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
{model.avatar_path ? (
|
||||
<img
|
||||
src={model.avatar_path}
|
||||
alt={model.name}
|
||||
className="w-20 h-20 rounded-full object-cover border-4 border-white shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-gray-100 to-gray-200 rounded-full flex items-center justify-center border-4 border-white shadow-lg">
|
||||
<UserIcon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 性别标识 */}
|
||||
<div className={`absolute -bottom-1 -right-1 w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border-2 border-white shadow-sm ${getGenderColor(model.gender)}`}>
|
||||
{getGenderIcon(model.gender)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{model.name}</h1>
|
||||
{model.stage_name && (
|
||||
<span className="text-lg text-gray-600">({model.stage_name})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
{model.age && (
|
||||
<span>{model.age}岁</span>
|
||||
)}
|
||||
{model.height && (
|
||||
<span>{model.height}cm</span>
|
||||
)}
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getGenderColor(model.gender)}`}>
|
||||
{model.gender === Gender.Male ? '男' : model.gender === Gender.Female ? '女' : '其他'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{model.description && (
|
||||
<p className="text-gray-700 max-w-md">{model.description}</p>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
{model.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{model.tags.slice(0, 5).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-primary-100 text-primary-700 rounded-full text-xs font-medium"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{model.tags.length > 5 && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 rounded-full text-xs font-medium">
|
||||
+{model.tags.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 发布动态按钮 */}
|
||||
<div className="lg:ml-auto">
|
||||
<button
|
||||
onClick={onCreateDynamic}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-xl hover:from-primary-600 hover:to-primary-700 transition-all duration-200 shadow-sm hover:shadow-md font-medium hover:scale-105 active:scale-95"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
生成视频
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-4 border border-gray-200/50 hover:bg-white/90 transition-all duration-200 hover:scale-105">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<SparklesIcon className="h-4 w-4 text-primary-600" />
|
||||
<span className="text-xs font-medium text-gray-600">总动态</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900">{stats.total_dynamics}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-4 border border-gray-200/50 hover:bg-white/90 transition-all duration-200 hover:scale-105">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-600" />
|
||||
<span className="text-xs font-medium text-gray-600">已发布</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900">{stats.published_dynamics}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-4 border border-gray-200/50 hover:bg-white/90 transition-all duration-200 hover:scale-105">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<VideoCameraIcon className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-xs font-medium text-gray-600">总视频</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900">{stats.total_videos}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-4 border border-gray-200/50 hover:bg-white/90 transition-all duration-200 hover:scale-105">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-600" />
|
||||
<span className="text-xs font-medium text-gray-600">已完成</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900">{stats.completed_videos}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-4 border border-gray-200/50 hover:bg-white/90 transition-all duration-200">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ClockIcon className={`h-4 w-4 text-yellow-600 ${stats.generating_videos > 0 ? 'animate-pulse' : ''}`} />
|
||||
<span className="text-xs font-medium text-gray-600">生成中</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900">{stats.generating_videos}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-4 border border-gray-200/50 hover:bg-white/90 transition-all duration-200 hover:scale-105">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ExclamationCircleIcon className="h-4 w-4 text-red-600" />
|
||||
<span className="text-xs font-medium text-gray-600">失败</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900">{stats.failed_videos}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDynamicHeader;
|
||||
186
apps/desktop/src/components/ModelDynamicList.tsx
Normal file
186
apps/desktop/src/components/ModelDynamicList.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import { ModelDynamic, VideoGenerationStatus } from '../types/model';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import {
|
||||
ClockIcon,
|
||||
VideoCameraIcon,
|
||||
SparklesIcon,
|
||||
ExclamationCircleIcon,
|
||||
ArrowPathIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface ModelDynamicListProps {
|
||||
dynamics: ModelDynamic[];
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const ModelDynamicList: React.FC<ModelDynamicListProps> = ({ dynamics, onRefresh }) => {
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(dateString), {
|
||||
addSuffix: true,
|
||||
locale: zhCN
|
||||
});
|
||||
} catch (error) {
|
||||
return '未知时间';
|
||||
}
|
||||
};
|
||||
|
||||
const getVideoStatusIcon = (status: VideoGenerationStatus) => {
|
||||
switch (status) {
|
||||
case VideoGenerationStatus.Pending:
|
||||
return <ClockIcon className="h-4 w-4 text-gray-500" />;
|
||||
case VideoGenerationStatus.Generating:
|
||||
return <SparklesIcon className="h-4 w-4 text-yellow-500 animate-pulse" />;
|
||||
case VideoGenerationStatus.Completed:
|
||||
return <VideoCameraIcon className="h-4 w-4 text-green-500" />;
|
||||
case VideoGenerationStatus.Failed:
|
||||
return <ExclamationCircleIcon className="h-4 w-4 text-red-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
if (dynamics.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<SparklesIcon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">暂无视频</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
点击"生成视频"按钮,生成您的第一个AI视频
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<ArrowPathIcon className="h-4 w-4" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dynamics.map((dynamic) => (
|
||||
<div
|
||||
key={dynamic.id}
|
||||
className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden animate-fade-in hover:shadow-md transition-all duration-200 hover:border-gray-300"
|
||||
>
|
||||
{/* 动态头部 */}
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<SparklesIcon className="h-5 w-5 text-primary-600" />
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{dynamic.title || '动态'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatDate(dynamic.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 动态内容 */}
|
||||
<div className="p-4">
|
||||
{/* 描述 */}
|
||||
<p className="text-gray-700 mb-4">{dynamic.description}</p>
|
||||
|
||||
{/* 提示词 */}
|
||||
<div className="bg-gray-50 rounded-lg p-3 mb-4 border border-gray-200">
|
||||
<div className="text-xs font-medium text-gray-500 mb-1">提示词</div>
|
||||
<p className="text-sm text-gray-800">{dynamic.prompt}</p>
|
||||
</div>
|
||||
|
||||
{/* 源图片 */}
|
||||
<div className="mb-4">
|
||||
<div className="text-xs font-medium text-gray-500 mb-2">源图片</div>
|
||||
<div className="relative aspect-video rounded-lg overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={dynamic.source_image_path}
|
||||
alt="源图片"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成视频统计 */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<VideoCameraIcon className="h-4 w-4 text-gray-600" />
|
||||
<span className="text-sm text-gray-700">
|
||||
已生成 {dynamic.generated_videos.filter(v => v.status === VideoGenerationStatus.Completed).length}/{dynamic.video_count} 个视频
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 视频网格 */}
|
||||
{dynamic.generated_videos.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{dynamic.generated_videos.map((video) => (
|
||||
<div
|
||||
key={video.id}
|
||||
className="relative aspect-video bg-gray-100 rounded-lg overflow-hidden border border-gray-200 group"
|
||||
>
|
||||
{/* 视频缩略图 */}
|
||||
{video.thumbnail_path ? (
|
||||
<img
|
||||
src={video.thumbnail_path}
|
||||
alt="视频缩略图"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<VideoCameraIcon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 状态指示器 */}
|
||||
<div className="absolute top-2 right-2">
|
||||
{getVideoStatusIcon(video.status)}
|
||||
</div>
|
||||
|
||||
{/* 进度指示器 */}
|
||||
{video.status === VideoGenerationStatus.Generating && video.generation_progress !== undefined && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-200">
|
||||
<div
|
||||
className="h-full bg-yellow-500"
|
||||
style={{ width: `${video.generation_progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 悬停覆盖层 */}
|
||||
{video.status === VideoGenerationStatus.Completed && (
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button className="text-white text-xs font-medium px-2 py-1 bg-white/20 rounded-full backdrop-blur-sm">
|
||||
播放
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{video.status === VideoGenerationStatus.Failed && video.error_message && (
|
||||
<div className="absolute inset-0 bg-red-500/10 flex items-center justify-center">
|
||||
<div className="text-xs text-red-600 text-center px-2">
|
||||
{video.error_message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDynamicList;
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
PlayIcon,
|
||||
TrashIcon,
|
||||
EyeIcon,
|
||||
ArrowPathIcon
|
||||
ArrowPathIcon,
|
||||
SparklesIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Model, PhotoType } from '../types/model';
|
||||
import {
|
||||
@@ -346,6 +347,15 @@ const ModelDetail: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate(`/models/${id}/dynamics`)}
|
||||
className="group flex items-center px-4 py-2 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 shadow-sm hover:shadow-md transition-all duration-200 text-sm font-medium"
|
||||
>
|
||||
<SparklesIcon className="w-4 h-4 mr-2 group-hover:rotate-12 transition-transform duration-200" />
|
||||
模特动态
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleUploadPhotos}
|
||||
disabled={uploadingPhotos}
|
||||
@@ -360,6 +370,7 @@ const ModelDetail: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* 左侧:模特信息和照片 */}
|
||||
|
||||
201
apps/desktop/src/pages/ModelDynamics.tsx
Normal file
201
apps/desktop/src/pages/ModelDynamics.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Model, ModelDynamic, ModelDynamicStats } from '../types/model';
|
||||
import { modelService } from '../services/modelService';
|
||||
import { modelDynamicService } from '../services/modelDynamicService';
|
||||
import ModelDynamicHeader from '../components/ModelDynamicHeader';
|
||||
import ModelDynamicList from '../components/ModelDynamicList';
|
||||
import CreateDynamicModal from '../components/CreateDynamicModal';
|
||||
import {
|
||||
PlusIcon,
|
||||
SparklesIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const ModelDynamics: React.FC = () => {
|
||||
const { modelId } = useParams<{ modelId: string }>();
|
||||
const [model, setModel] = useState<Model | null>(null);
|
||||
const [dynamics, setDynamics] = useState<ModelDynamic[]>([]);
|
||||
const [stats, setStats] = useState<ModelDynamicStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (modelId) {
|
||||
loadModelData();
|
||||
loadDynamics();
|
||||
loadStats();
|
||||
}
|
||||
}, [modelId]);
|
||||
|
||||
const loadModelData = async () => {
|
||||
if (!modelId) return;
|
||||
|
||||
try {
|
||||
const modelData = await modelService.getModelById(modelId);
|
||||
setModel(modelData);
|
||||
} catch (err) {
|
||||
console.error('加载模特信息失败:', err);
|
||||
setError('加载模特信息失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadDynamics = async () => {
|
||||
if (!modelId) return;
|
||||
|
||||
try {
|
||||
// 使用模拟数据进行开发测试
|
||||
const dynamicsList = modelDynamicService.getMockDynamics(modelId);
|
||||
setDynamics(dynamicsList);
|
||||
|
||||
// TODO: 后续替换为真实API调用
|
||||
// const dynamicsList = await modelDynamicService.getDynamicsByModelId(modelId);
|
||||
// setDynamics(dynamicsList);
|
||||
} catch (err) {
|
||||
console.error('加载动态列表失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
if (!modelId) return;
|
||||
|
||||
try {
|
||||
// 使用模拟数据进行开发测试
|
||||
const statsData = modelDynamicService.getMockStats(modelId);
|
||||
setStats(statsData);
|
||||
|
||||
// TODO: 后续替换为真实API调用
|
||||
// const statsData = await modelDynamicService.getStatsByModelId(modelId);
|
||||
// setStats(statsData);
|
||||
} catch (err) {
|
||||
console.error('加载统计数据失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDynamic = async (dynamicData: any) => {
|
||||
try {
|
||||
// TODO: 实现创建动态API
|
||||
// await modelDynamicService.createDynamic(dynamicData);
|
||||
console.log('创建动态数据:', dynamicData);
|
||||
setShowCreateModal(false);
|
||||
await loadDynamics();
|
||||
await loadStats();
|
||||
} catch (err) {
|
||||
console.error('创建动态失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 头部骨架 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-20 w-20 bg-gray-200 rounded-full loading-shimmer" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="h-6 bg-gray-200 rounded w-32 loading-shimmer" />
|
||||
<div className="h-4 bg-gray-200 rounded w-24 loading-shimmer" />
|
||||
<div className="h-4 bg-gray-200 rounded w-48 loading-shimmer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 动态列表骨架 */}
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
||||
<div className="space-y-4">
|
||||
<div className="h-4 bg-gray-300 rounded w-3/4"></div>
|
||||
<div className="h-32 bg-gray-300 rounded"></div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[...Array(6)].map((_, j) => (
|
||||
<div key={j} className="h-20 bg-gray-300 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !model) {
|
||||
return (
|
||||
<div className="text-center py-16 animate-fade-in">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<ExclamationTriangleIcon className="h-8 w-8 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">加载失败</h3>
|
||||
<p className="text-gray-600 mb-6">{error || '模特信息不存在'}</p>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl hover:from-blue-600 hover:to-blue-700 transition-all duration-200 shadow-sm hover:shadow-md font-medium"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 模特头部信息 */}
|
||||
<ModelDynamicHeader
|
||||
model={model}
|
||||
stats={stats}
|
||||
onCreateDynamic={() => setShowCreateModal(true)}
|
||||
/>
|
||||
|
||||
{/* 动态列表 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
|
||||
<SparklesIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">视频列表</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
共 {dynamics.length} 个视频
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 transition-all duration-200 shadow-sm hover:shadow-md text-sm font-medium"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
生成视频
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<ModelDynamicList
|
||||
dynamics={dynamics}
|
||||
onRefresh={loadDynamics}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 创建动态模态框 */}
|
||||
{showCreateModal && model && (
|
||||
<CreateDynamicModal
|
||||
model={model}
|
||||
onSubmit={handleCreateDynamic}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDynamics;
|
||||
206
apps/desktop/src/services/modelDynamicService.ts
Normal file
206
apps/desktop/src/services/modelDynamicService.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
ModelDynamic,
|
||||
CreateDynamicRequest,
|
||||
UpdateDynamicRequest,
|
||||
ModelDynamicStats,
|
||||
DynamicStatus,
|
||||
VideoGenerationStatus
|
||||
} from '../types/model';
|
||||
|
||||
// 模特动态服务
|
||||
export const modelDynamicService = {
|
||||
// 创建动态
|
||||
async createDynamic(request: CreateDynamicRequest): Promise<ModelDynamic> {
|
||||
try {
|
||||
return await invoke<ModelDynamic>('create_model_dynamic', { request });
|
||||
} catch (error) {
|
||||
console.error('创建模特动态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 获取动态详情
|
||||
async getDynamicById(id: string): Promise<ModelDynamic | null> {
|
||||
try {
|
||||
return await invoke<ModelDynamic>('get_model_dynamic_by_id', { id });
|
||||
} catch (error) {
|
||||
console.error('获取模特动态详情失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 获取模特的所有动态
|
||||
async getDynamicsByModelId(modelId: string): Promise<ModelDynamic[]> {
|
||||
try {
|
||||
return await invoke<ModelDynamic[]>('get_model_dynamics_by_model_id', { modelId });
|
||||
} catch (error) {
|
||||
console.error('获取模特动态列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 更新动态
|
||||
async updateDynamic(id: string, request: UpdateDynamicRequest): Promise<ModelDynamic> {
|
||||
try {
|
||||
return await invoke<ModelDynamic>('update_model_dynamic', { id, request });
|
||||
} catch (error) {
|
||||
console.error('更新模特动态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 删除动态
|
||||
async deleteDynamic(id: string): Promise<void> {
|
||||
try {
|
||||
await invoke('delete_model_dynamic', { id });
|
||||
} catch (error) {
|
||||
console.error('删除模特动态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 获取模特动态统计
|
||||
async getStatsByModelId(modelId: string): Promise<ModelDynamicStats> {
|
||||
try {
|
||||
return await invoke<ModelDynamicStats>('get_model_dynamic_stats', { modelId });
|
||||
} catch (error) {
|
||||
console.error('获取模特动态统计失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 重新生成视频
|
||||
async regenerateVideo(dynamicId: string, videoId: string): Promise<void> {
|
||||
try {
|
||||
await invoke('regenerate_dynamic_video', { dynamicId, videoId });
|
||||
} catch (error) {
|
||||
console.error('重新生成视频失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 获取可用的AI模型列表
|
||||
async getAvailableAIModels(): Promise<any[]> {
|
||||
try {
|
||||
return await invoke<any[]>('get_available_ai_models');
|
||||
} catch (error) {
|
||||
console.error('获取可用AI模型列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 模拟数据 - 仅用于开发测试
|
||||
getMockDynamics(modelId: string): ModelDynamic[] {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
model_id: modelId,
|
||||
title: '夏日写真',
|
||||
description: '今天拍了一组夏日写真,用AI生成了一些有趣的视频',
|
||||
prompt: '一个年轻女孩在海滩上奔跑,穿着白色连衣裙,阳光明媚,海浪轻拍,微风吹拂她的长发',
|
||||
source_image_path: 'https://images.unsplash.com/photo-1503185912284-5271ff81b9a8?q=80&w=1000',
|
||||
ai_model: '极梦',
|
||||
video_count: 4,
|
||||
generated_videos: [
|
||||
{
|
||||
id: 'v1',
|
||||
dynamic_id: '1',
|
||||
video_path: '/path/to/video1.mp4',
|
||||
thumbnail_path: 'https://images.unsplash.com/photo-1503185912284-5271ff81b9a8?q=80&w=1000',
|
||||
file_size: 1024 * 1024 * 5,
|
||||
duration: 15,
|
||||
status: VideoGenerationStatus.Completed,
|
||||
created_at: now.toISOString()
|
||||
},
|
||||
{
|
||||
id: 'v2',
|
||||
dynamic_id: '1',
|
||||
video_path: '/path/to/video2.mp4',
|
||||
thumbnail_path: 'https://images.unsplash.com/photo-1503185912284-5271ff81b9a8?q=80&w=1000',
|
||||
file_size: 1024 * 1024 * 4.5,
|
||||
duration: 12,
|
||||
status: VideoGenerationStatus.Completed,
|
||||
created_at: now.toISOString()
|
||||
},
|
||||
{
|
||||
id: 'v3',
|
||||
dynamic_id: '1',
|
||||
video_path: '/path/to/video3.mp4',
|
||||
thumbnail_path: 'https://images.unsplash.com/photo-1503185912284-5271ff81b9a8?q=80&w=1000',
|
||||
file_size: 1024 * 1024 * 6,
|
||||
duration: 18,
|
||||
status: VideoGenerationStatus.Generating,
|
||||
generation_progress: 75,
|
||||
created_at: now.toISOString()
|
||||
},
|
||||
{
|
||||
id: 'v4',
|
||||
dynamic_id: '1',
|
||||
video_path: '/path/to/video4.mp4',
|
||||
file_size: 0,
|
||||
duration: 0,
|
||||
status: VideoGenerationStatus.Failed,
|
||||
error_message: '生成失败,请重试',
|
||||
created_at: now.toISOString()
|
||||
}
|
||||
],
|
||||
status: DynamicStatus.Published,
|
||||
created_at: now.toISOString(),
|
||||
updated_at: now.toISOString()
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
model_id: modelId,
|
||||
description: '尝试了一些新的风格',
|
||||
prompt: '一个女孩站在城市街道上,霓虹灯照亮她的侧脸,赛博朋克风格,未来感十足',
|
||||
source_image_path: 'https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?q=80&w=1000',
|
||||
ai_model: '极梦',
|
||||
video_count: 2,
|
||||
generated_videos: [
|
||||
{
|
||||
id: 'v5',
|
||||
dynamic_id: '2',
|
||||
video_path: '/path/to/video5.mp4',
|
||||
thumbnail_path: 'https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?q=80&w=1000',
|
||||
file_size: 1024 * 1024 * 7,
|
||||
duration: 20,
|
||||
status: VideoGenerationStatus.Completed,
|
||||
created_at: yesterday.toISOString()
|
||||
},
|
||||
{
|
||||
id: 'v6',
|
||||
dynamic_id: '2',
|
||||
video_path: '/path/to/video6.mp4',
|
||||
thumbnail_path: 'https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?q=80&w=1000',
|
||||
file_size: 1024 * 1024 * 6.5,
|
||||
duration: 17,
|
||||
status: VideoGenerationStatus.Completed,
|
||||
created_at: yesterday.toISOString()
|
||||
}
|
||||
],
|
||||
status: DynamicStatus.Published,
|
||||
created_at: yesterday.toISOString(),
|
||||
updated_at: yesterday.toISOString()
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
// 模拟统计数据 - 仅用于开发测试
|
||||
getMockStats(modelId: string): ModelDynamicStats {
|
||||
return {
|
||||
total_dynamics: 2,
|
||||
published_dynamics: 2,
|
||||
total_videos: 6,
|
||||
completed_videos: 4,
|
||||
generating_videos: 1,
|
||||
failed_videos: 1
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default modelDynamicService;
|
||||
@@ -263,3 +263,82 @@ export interface ModelBatchOperation {
|
||||
modelIds: string[];
|
||||
params?: any;
|
||||
}
|
||||
|
||||
// 模特动态相关类型
|
||||
export interface ModelDynamic {
|
||||
id: string;
|
||||
model_id: string;
|
||||
title?: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
source_image_path: string;
|
||||
ai_model: string; // 使用的AI模型,如"极梦"
|
||||
video_count: number; // 生成视频个数
|
||||
generated_videos: GeneratedVideo[];
|
||||
status: DynamicStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GeneratedVideo {
|
||||
id: string;
|
||||
dynamic_id: string;
|
||||
video_path: string;
|
||||
thumbnail_path?: string;
|
||||
file_size: number;
|
||||
duration: number; // 视频时长(秒)
|
||||
status: VideoGenerationStatus;
|
||||
generation_progress?: number; // 生成进度 0-100
|
||||
error_message?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export enum DynamicStatus {
|
||||
Draft = "Draft",
|
||||
Publishing = "Publishing",
|
||||
Published = "Published",
|
||||
Failed = "Failed"
|
||||
}
|
||||
|
||||
export enum VideoGenerationStatus {
|
||||
Pending = "Pending",
|
||||
Generating = "Generating",
|
||||
Completed = "Completed",
|
||||
Failed = "Failed"
|
||||
}
|
||||
|
||||
export interface CreateDynamicRequest {
|
||||
model_id: string;
|
||||
title?: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
source_image_path: string;
|
||||
ai_model: string;
|
||||
video_count: number;
|
||||
}
|
||||
|
||||
export interface UpdateDynamicRequest {
|
||||
title?: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
status?: DynamicStatus;
|
||||
}
|
||||
|
||||
// 模特动态统计
|
||||
export interface ModelDynamicStats {
|
||||
total_dynamics: number;
|
||||
published_dynamics: number;
|
||||
total_videos: number;
|
||||
completed_videos: number;
|
||||
generating_videos: number;
|
||||
failed_videos: number;
|
||||
}
|
||||
|
||||
// AI模型选项
|
||||
export interface AIModelOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_available: boolean;
|
||||
max_video_count: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user