From c190fdae1bf245fe6405fd23f6ef3e28b990bcca Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 17 Jul 2025 13:03:51 +0800 Subject: [PATCH] feat: implement export record tracking system - Add export_count field to template_matching_results table - Create ExportRecord data model with comprehensive tracking - Implement ExportRecordRepository for CRUD operations - Create ExportRecordService for business logic - Add export record tracking to jianying export functions - Create ExportRecordManager component with filtering and pagination - Add ExportRecordsPage with full management interface - Integrate export records into navigation and routing - Add Tauri commands for export record management - Include statistics, validation, and cleanup functionality Follows Tauri development specifications and frontend standards. --- .../services/export_record_service.rs | 279 +++++++++++++ .../src-tauri/src/business/services/mod.rs | 1 + .../src/data/models/export_record.rs | 202 +++++++++ apps/desktop/src-tauri/src/data/models/mod.rs | 1 + .../data/models/template_matching_result.rs | 2 + .../repositories/export_record_repository.rs | 344 ++++++++++++++++ .../src-tauri/src/data/repositories/mod.rs | 1 + .../template_matching_result_repository.rs | 25 +- .../src-tauri/src/infrastructure/database.rs | 68 +++ apps/desktop/src-tauri/src/lib.rs | 11 + .../commands/export_record_commands.rs | 257 ++++++++++++ .../src/presentation/commands/mod.rs | 1 + .../template_matching_result_commands.rs | 96 +++++ apps/desktop/src/App.tsx | 2 + .../src/components/ExportRecordManager.tsx | 387 ++++++++++++++++++ apps/desktop/src/components/Navigation.tsx | 9 +- apps/desktop/src/pages/ExportRecordsPage.tsx | 16 + apps/desktop/src/types/exportRecord.ts | 294 +++++++++++++ .../src/types/templateMatchingResult.ts | 1 + 19 files changed, 1992 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src-tauri/src/business/services/export_record_service.rs create mode 100644 apps/desktop/src-tauri/src/data/models/export_record.rs create mode 100644 apps/desktop/src-tauri/src/data/repositories/export_record_repository.rs create mode 100644 apps/desktop/src-tauri/src/presentation/commands/export_record_commands.rs create mode 100644 apps/desktop/src/components/ExportRecordManager.tsx create mode 100644 apps/desktop/src/pages/ExportRecordsPage.tsx create mode 100644 apps/desktop/src/types/exportRecord.ts diff --git a/apps/desktop/src-tauri/src/business/services/export_record_service.rs b/apps/desktop/src-tauri/src/business/services/export_record_service.rs new file mode 100644 index 0000000..bf81faf --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/export_record_service.rs @@ -0,0 +1,279 @@ +use std::sync::Arc; +use anyhow::Result; +use chrono::Utc; +use std::fs; + +use crate::data::repositories::export_record_repository::ExportRecordRepository; +use crate::data::repositories::template_matching_result_repository::TemplateMatchingResultRepository; +use crate::data::models::export_record::{ + ExportRecord, ExportType, ExportFormat, + CreateExportRecordRequest, ExportRecordQueryOptions, ExportRecordStatistics, +}; + +/// 导出记录服务 +/// 遵循 Tauri 开发规范的业务逻辑设计原则 +pub struct ExportRecordService { + export_record_repository: Arc, + matching_result_repository: Arc, +} + +impl ExportRecordService { + /// 创建新的导出记录服务实例 + pub fn new( + export_record_repository: Arc, + matching_result_repository: Arc, + ) -> Self { + Self { + export_record_repository, + matching_result_repository, + } + } + + /// 创建导出记录 + pub async fn create_export_record( + &self, + matching_result_id: String, + export_type: ExportType, + export_format: ExportFormat, + file_path: String, + metadata: Option, + ) -> Result { + let request = CreateExportRecordRequest { + matching_result_id, + export_type, + export_format, + file_path, + metadata, + }; + + let export_record = self.export_record_repository.create(request) + .map_err(|e| anyhow::anyhow!("创建导出记录失败: {}", e))?; + + Ok(export_record) + } + + /// 标记导出成功并更新匹配结果的导出次数 + pub async fn mark_export_success( + &self, + export_record_id: String, + file_path: String, + duration_ms: u64, + ) -> Result<()> { + // 获取导出记录 + let mut export_record = self.export_record_repository.get_by_id(&export_record_id) + .map_err(|e| anyhow::anyhow!("获取导出记录失败: {}", e))? + .ok_or_else(|| anyhow::anyhow!("导出记录不存在: {}", export_record_id))?; + + // 获取文件大小 + let file_size = match fs::metadata(&file_path) { + Ok(metadata) => Some(metadata.len()), + Err(_) => None, + }; + + // 标记导出成功 + export_record.mark_success(file_size, duration_ms); + + // 更新导出记录 + self.export_record_repository.update(&export_record) + .map_err(|e| anyhow::anyhow!("更新导出记录失败: {}", e))?; + + // 增加匹配结果的导出次数 + self.matching_result_repository.increment_export_count(&export_record.matching_result_id) + .map_err(|e| anyhow::anyhow!("更新导出次数失败: {}", e))?; + + println!("✅ 导出记录已更新: {} -> 成功", export_record_id); + println!("📊 匹配结果导出次数已增加: {}", export_record.matching_result_id); + + Ok(()) + } + + /// 标记导出失败 + pub async fn mark_export_failed( + &self, + export_record_id: String, + error_message: String, + duration_ms: u64, + ) -> Result<()> { + // 获取导出记录 + let mut export_record = self.export_record_repository.get_by_id(&export_record_id) + .map_err(|e| anyhow::anyhow!("获取导出记录失败: {}", e))? + .ok_or_else(|| anyhow::anyhow!("导出记录不存在: {}", export_record_id))?; + + // 标记导出失败 + export_record.mark_failed(error_message.clone(), duration_ms); + + // 更新导出记录 + self.export_record_repository.update(&export_record) + .map_err(|e| anyhow::anyhow!("更新导出记录失败: {}", e))?; + + println!("❌ 导出记录已更新: {} -> 失败: {}", export_record_id, error_message); + + Ok(()) + } + + /// 获取导出记录详情 + pub async fn get_export_record(&self, id: &str) -> Result> { + let export_record = self.export_record_repository.get_by_id(id) + .map_err(|e| anyhow::anyhow!("获取导出记录失败: {}", e))?; + + Ok(export_record) + } + + /// 查询导出记录列表 + pub async fn list_export_records( + &self, + options: ExportRecordQueryOptions, + ) -> Result> { + let export_records = self.export_record_repository.list(options) + .map_err(|e| anyhow::anyhow!("查询导出记录列表失败: {}", e))?; + + Ok(export_records) + } + + /// 删除导出记录 + pub async fn delete_export_record(&self, id: &str) -> Result<()> { + self.export_record_repository.delete(id) + .map_err(|e| anyhow::anyhow!("删除导出记录失败: {}", e))?; + + println!("🗑️ 导出记录已删除: {}", id); + + Ok(()) + } + + /// 获取项目的导出记录统计信息 + pub async fn get_project_export_statistics( + &self, + project_id: &str, + ) -> Result { + let statistics = self.export_record_repository.get_statistics(Some(project_id)) + .map_err(|e| anyhow::anyhow!("获取导出统计信息失败: {}", e))?; + + Ok(statistics) + } + + /// 获取全局导出记录统计信息 + pub async fn get_global_export_statistics(&self) -> Result { + let statistics = self.export_record_repository.get_statistics(None) + .map_err(|e| anyhow::anyhow!("获取全局导出统计信息失败: {}", e))?; + + Ok(statistics) + } + + /// 清理过期的导出记录 + pub async fn cleanup_expired_records(&self, days: u32) -> Result { + let cutoff_date = Utc::now() - chrono::Duration::days(days as i64); + + // 查询过期的导出记录 + let options = ExportRecordQueryOptions { + project_id: None, + matching_result_id: None, + template_id: None, + export_type: None, + export_status: None, + limit: None, + offset: None, + search_keyword: None, + sort_by: None, + sort_order: None, + date_range: Some(crate::data::models::export_record::DateRange { + start_date: chrono::DateTime::from_timestamp(0, 0).unwrap_or_default(), + end_date: cutoff_date, + }), + }; + + let expired_records = self.list_export_records(options).await?; + let count = expired_records.len() as u32; + + // 删除过期记录 + for record in expired_records { + self.delete_export_record(&record.id).await?; + } + + println!("🧹 已清理 {} 条过期导出记录(超过 {} 天)", count, days); + + Ok(count) + } + + /// 验证文件是否存在 + pub async fn validate_export_file(&self, export_record_id: &str) -> Result { + let export_record = self.get_export_record(export_record_id).await? + .ok_or_else(|| anyhow::anyhow!("导出记录不存在: {}", export_record_id))?; + + let file_exists = fs::metadata(&export_record.file_path).is_ok(); + + if !file_exists { + println!("⚠️ 导出文件不存在: {}", export_record.file_path); + } + + Ok(file_exists) + } + + /// 获取匹配结果的所有导出记录 + pub async fn get_matching_result_exports( + &self, + matching_result_id: &str, + ) -> Result> { + let options = ExportRecordQueryOptions { + project_id: None, + matching_result_id: Some(matching_result_id.to_string()), + template_id: None, + export_type: None, + export_status: None, + limit: None, + offset: None, + search_keyword: None, + sort_by: Some("created_at".to_string()), + sort_order: Some("desc".to_string()), + date_range: None, + }; + + self.list_export_records(options).await + } + + /// 获取项目的导出记录 + pub async fn get_project_exports( + &self, + project_id: &str, + limit: Option, + offset: Option, + ) -> Result> { + let options = ExportRecordQueryOptions { + project_id: Some(project_id.to_string()), + matching_result_id: None, + template_id: None, + export_type: None, + export_status: None, + limit, + offset, + search_keyword: None, + sort_by: Some("created_at".to_string()), + sort_order: Some("desc".to_string()), + date_range: None, + }; + + self.list_export_records(options).await + } + + /// 重新导出(基于现有导出记录) + pub async fn re_export( + &self, + export_record_id: &str, + new_file_path: String, + ) -> Result { + let original_record = self.get_export_record(export_record_id).await? + .ok_or_else(|| anyhow::anyhow!("原始导出记录不存在: {}", export_record_id))?; + + // 创建新的导出记录 + let new_record = self.create_export_record( + original_record.matching_result_id, + original_record.export_type, + original_record.export_format, + new_file_path, + original_record.metadata, + ).await?; + + println!("🔄 基于记录 {} 创建了新的导出记录: {}", export_record_id, new_record.id); + + Ok(new_record) + } +} diff --git a/apps/desktop/src-tauri/src/business/services/mod.rs b/apps/desktop/src-tauri/src/business/services/mod.rs index 89b6f69..345c39c 100644 --- a/apps/desktop/src-tauri/src/business/services/mod.rs +++ b/apps/desktop/src-tauri/src/business/services/mod.rs @@ -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 export_record_service; pub mod video_generation_service; pub mod jianying_export; diff --git a/apps/desktop/src-tauri/src/data/models/export_record.rs b/apps/desktop/src-tauri/src/data/models/export_record.rs new file mode 100644 index 0000000..4cb3ada --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/export_record.rs @@ -0,0 +1,202 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; + +/// 导出记录实体模型 +/// 遵循 Tauri 开发规范的数据模型设计原则 +/// 记录每次导出操作的详细信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportRecord { + pub id: String, + pub matching_result_id: String, // 关联的匹配结果ID + pub project_id: String, // 项目ID + pub template_id: String, // 模板ID + pub export_type: ExportType, // 导出类型 + pub export_format: ExportFormat, // 导出格式 + pub file_path: String, // 导出文件路径 + pub file_size: Option, // 文件大小(字节) + pub export_status: ExportStatus, // 导出状态 + pub export_duration_ms: u64, // 导出耗时(毫秒) + pub error_message: Option, // 错误信息 + pub metadata: Option, // JSON格式的额外元数据 + pub created_at: DateTime, + pub updated_at: DateTime, + pub is_active: bool, +} + +/// 导出类型 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ExportType { + /// 剪映导出 V1 + JianYingV1, + /// 剪映导出 V2 + JianYingV2, + /// JSON格式导出 + Json, + /// CSV格式导出 + Csv, + /// Excel格式导出 + Excel, +} + +impl Default for ExportType { + fn default() -> Self { + Self::JianYingV2 + } +} + +/// 导出格式 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ExportFormat { + /// JSON文件 + Json, + /// CSV文件 + Csv, + /// Excel文件 + Excel, + /// 其他格式 + Other(String), +} + +impl Default for ExportFormat { + fn default() -> Self { + Self::Json + } +} + +/// 导出状态 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ExportStatus { + /// 导出成功 + Success, + /// 导出失败 + Failed, + /// 导出进行中 + InProgress, + /// 导出被取消 + Cancelled, +} + +impl Default for ExportStatus { + fn default() -> Self { + Self::Success + } +} + +impl ExportRecord { + /// 创建新的导出记录实例 + pub fn new( + id: String, + matching_result_id: String, + project_id: String, + template_id: String, + export_type: ExportType, + export_format: ExportFormat, + file_path: String, + ) -> Self { + let now = Utc::now(); + Self { + id, + matching_result_id, + project_id, + template_id, + export_type, + export_format, + file_path, + file_size: None, + export_status: ExportStatus::InProgress, + export_duration_ms: 0, + error_message: None, + metadata: None, + created_at: now, + updated_at: now, + is_active: true, + } + } + + /// 标记导出成功 + pub fn mark_success(&mut self, file_size: Option, duration_ms: u64) { + self.export_status = ExportStatus::Success; + self.file_size = file_size; + self.export_duration_ms = duration_ms; + self.updated_at = Utc::now(); + } + + /// 标记导出失败 + pub fn mark_failed(&mut self, error_message: String, duration_ms: u64) { + self.export_status = ExportStatus::Failed; + self.error_message = Some(error_message); + self.export_duration_ms = duration_ms; + self.updated_at = Utc::now(); + } + + /// 设置元数据 + pub fn set_metadata(&mut self, metadata: String) { + self.metadata = Some(metadata); + self.updated_at = Utc::now(); + } + + /// 获取导出类型的显示名称 + pub fn get_export_type_display(&self) -> &'static str { + match self.export_type { + ExportType::JianYingV1 => "剪映导出 V1", + ExportType::JianYingV2 => "剪映导出 V2", + ExportType::Json => "JSON导出", + ExportType::Csv => "CSV导出", + ExportType::Excel => "Excel导出", + } + } + + /// 获取导出状态的显示名称 + pub fn get_export_status_display(&self) -> &'static str { + match self.export_status { + ExportStatus::Success => "成功", + ExportStatus::Failed => "失败", + ExportStatus::InProgress => "进行中", + ExportStatus::Cancelled => "已取消", + } + } +} + +/// 创建导出记录请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateExportRecordRequest { + pub matching_result_id: String, + pub export_type: ExportType, + pub export_format: ExportFormat, + pub file_path: String, + pub metadata: Option, +} + +/// 导出记录查询选项 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportRecordQueryOptions { + pub project_id: Option, + pub matching_result_id: Option, + pub template_id: Option, + pub export_type: Option, + pub export_status: Option, + pub limit: Option, + pub offset: Option, + pub search_keyword: Option, + pub sort_by: Option, // "created_at", "export_duration_ms", "file_size" + pub sort_order: Option, // "asc", "desc" + pub date_range: Option, +} + +/// 日期范围 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DateRange { + pub start_date: DateTime, + pub end_date: DateTime, +} + +/// 导出记录统计信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportRecordStatistics { + pub total_exports: u32, + pub successful_exports: u32, + pub failed_exports: u32, + pub total_file_size: u64, + pub average_export_duration_ms: u64, + pub export_type_counts: std::collections::HashMap, +} diff --git a/apps/desktop/src-tauri/src/data/models/mod.rs b/apps/desktop/src-tauri/src/data/models/mod.rs index 8560fac..8be0d13 100644 --- a/apps/desktop/src-tauri/src/data/models/mod.rs +++ b/apps/desktop/src-tauri/src/data/models/mod.rs @@ -8,4 +8,5 @@ pub mod video_classification; pub mod template; pub mod project_template_binding; pub mod template_matching_result; +pub mod export_record; pub mod video_generation; diff --git a/apps/desktop/src-tauri/src/data/models/template_matching_result.rs b/apps/desktop/src-tauri/src/data/models/template_matching_result.rs index 6671d88..e34b1a5 100644 --- a/apps/desktop/src-tauri/src/data/models/template_matching_result.rs +++ b/apps/desktop/src-tauri/src/data/models/template_matching_result.rs @@ -22,6 +22,7 @@ pub struct TemplateMatchingResult { pub quality_score: Option, // 匹配质量评分 pub status: MatchingResultStatus, pub metadata: Option, // JSON格式的额外元数据 + pub export_count: u32, // 导出次数 pub created_at: DateTime, pub updated_at: DateTime, pub is_active: bool, @@ -136,6 +137,7 @@ impl TemplateMatchingResult { quality_score: None, status: MatchingResultStatus::default(), metadata: None, + export_count: 0, created_at: now, updated_at: now, is_active: true, diff --git a/apps/desktop/src-tauri/src/data/repositories/export_record_repository.rs b/apps/desktop/src-tauri/src/data/repositories/export_record_repository.rs new file mode 100644 index 0000000..de8b62a --- /dev/null +++ b/apps/desktop/src-tauri/src/data/repositories/export_record_repository.rs @@ -0,0 +1,344 @@ +use rusqlite::{Result, Row}; +use std::sync::Arc; +use chrono::{DateTime, Utc}; +use crate::infrastructure::database::Database; + +use crate::data::models::export_record::{ + ExportRecord, ExportType, ExportFormat, ExportStatus, + CreateExportRecordRequest, ExportRecordQueryOptions, ExportRecordStatistics, +}; + +/// 导出记录数据库操作仓储 +/// 遵循 Tauri 开发规范的数据访问层设计原则 +pub struct ExportRecordRepository { + database: Arc, +} + +impl ExportRecordRepository { + /// 创建新的导出记录仓储实例 + pub fn new(database: Arc) -> Self { + Self { database } + } + + /// 创建导出记录 + pub fn create(&self, request: CreateExportRecordRequest) -> Result { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + // 生成唯一ID + let id = uuid::Uuid::new_v4().to_string(); + + // 获取项目ID和模板ID(从匹配结果中查询) + let (project_id, template_id) = conn.query_row( + "SELECT project_id, template_id FROM template_matching_results WHERE id = ?1", + [&request.matching_result_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + )?; + + let mut export_record = ExportRecord::new( + id, + request.matching_result_id, + project_id, + template_id, + request.export_type, + request.export_format, + request.file_path, + ); + + if let Some(metadata) = request.metadata { + export_record.set_metadata(metadata); + } + + conn.execute( + "INSERT INTO export_records ( + id, matching_result_id, project_id, template_id, export_type, export_format, + file_path, file_size, export_status, export_duration_ms, error_message, + metadata, created_at, updated_at, is_active + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + rusqlite::params![ + &export_record.id, + &export_record.matching_result_id, + &export_record.project_id, + &export_record.template_id, + &serde_json::to_string(&export_record.export_type).unwrap(), + &serde_json::to_string(&export_record.export_format).unwrap(), + &export_record.file_path, + &export_record.file_size, + &serde_json::to_string(&export_record.export_status).unwrap(), + &export_record.export_duration_ms, + &export_record.error_message, + &export_record.metadata, + &export_record.created_at.to_rfc3339(), + &export_record.updated_at.to_rfc3339(), + &(export_record.is_active as i32), + ], + )?; + + Ok(export_record) + } + + /// 根据ID获取导出记录 + pub fn get_by_id(&self, id: &str) -> Result> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT id, matching_result_id, project_id, template_id, export_type, export_format, + file_path, file_size, export_status, export_duration_ms, error_message, + metadata, created_at, updated_at, is_active + FROM export_records WHERE id = ?1" + )?; + + let result_iter = stmt.query_map([id], |row| { + self.row_to_export_record(row) + })?; + + for result in result_iter { + return Ok(Some(result?)); + } + + Ok(None) + } + + /// 更新导出记录 + pub fn update(&self, export_record: &ExportRecord) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + conn.execute( + "UPDATE export_records SET + file_size = ?2, export_status = ?3, export_duration_ms = ?4, + error_message = ?5, metadata = ?6, updated_at = ?7 + WHERE id = ?1", + rusqlite::params![ + &export_record.id, + &export_record.file_size, + &serde_json::to_string(&export_record.export_status).unwrap(), + &export_record.export_duration_ms, + &export_record.error_message, + &export_record.metadata, + &export_record.updated_at.to_rfc3339(), + ], + )?; + + Ok(()) + } + + /// 查询导出记录列表 + pub fn list(&self, options: ExportRecordQueryOptions) -> Result> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + let mut query = "SELECT id, matching_result_id, project_id, template_id, export_type, export_format, + file_path, file_size, export_status, export_duration_ms, error_message, + metadata, created_at, updated_at, is_active + FROM export_records WHERE is_active = 1".to_string(); + let mut params: Vec = Vec::new(); + + // 添加查询条件 + if let Some(project_id) = &options.project_id { + query.push_str(" AND project_id = ?"); + params.push(project_id.clone()); + } + + if let Some(matching_result_id) = &options.matching_result_id { + query.push_str(" AND matching_result_id = ?"); + params.push(matching_result_id.clone()); + } + + if let Some(template_id) = &options.template_id { + query.push_str(" AND template_id = ?"); + params.push(template_id.clone()); + } + + if let Some(export_type) = &options.export_type { + query.push_str(" AND export_type = ?"); + params.push(serde_json::to_string(export_type).unwrap()); + } + + if let Some(export_status) = &options.export_status { + query.push_str(" AND export_status = ?"); + params.push(serde_json::to_string(export_status).unwrap()); + } + + if let Some(keyword) = &options.search_keyword { + query.push_str(" AND (file_path LIKE ? OR error_message LIKE ?)"); + let search_pattern = format!("%{}%", keyword); + params.push(search_pattern.clone()); + params.push(search_pattern); + } + + // 添加日期范围过滤 + if let Some(date_range) = &options.date_range { + query.push_str(" AND created_at >= ? AND created_at <= ?"); + params.push(date_range.start_date.to_rfc3339()); + params.push(date_range.end_date.to_rfc3339()); + } + + // 添加排序 + let sort_by = options.sort_by.as_deref().unwrap_or("created_at"); + let sort_order = options.sort_order.as_deref().unwrap_or("desc"); + query.push_str(&format!(" ORDER BY {} {}", sort_by, sort_order)); + + // 添加分页 + if let Some(limit) = options.limit { + query.push_str(&format!(" LIMIT {}", limit)); + if let Some(offset) = options.offset { + query.push_str(&format!(" OFFSET {}", offset)); + } + } + + let mut stmt = conn.prepare(&query)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p as &dyn rusqlite::ToSql).collect(); + + let export_record_iter = stmt.query_map(param_refs.as_slice(), |row| { + self.row_to_export_record(row) + })?; + + let mut export_records = Vec::new(); + for export_record in export_record_iter { + export_records.push(export_record?); + } + + Ok(export_records) + } + + /// 删除导出记录(软删除) + pub fn delete(&self, id: &str) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + conn.execute( + "UPDATE export_records SET is_active = 0, updated_at = ?2 WHERE id = ?1", + rusqlite::params![id, Utc::now().to_rfc3339()], + )?; + + Ok(()) + } + + /// 获取导出记录统计信息 + pub fn get_statistics(&self, project_id: Option<&str>) -> Result { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + let mut base_query = "FROM export_records WHERE is_active = 1".to_string(); + let mut params: Vec = Vec::new(); + + if let Some(pid) = project_id { + base_query.push_str(" AND project_id = ?"); + params.push(pid.to_string()); + } + + // 总导出次数 + let total_exports: u32 = conn.query_row( + &format!("SELECT COUNT(*) {}", base_query), + params.iter().map(|p| p as &dyn rusqlite::ToSql).collect::>().as_slice(), + |row| row.get(0) + )?; + + // 成功导出次数 + let mut success_params = params.clone(); + success_params.push("\"Success\"".to_string()); + let successful_exports: u32 = conn.query_row( + &format!("SELECT COUNT(*) {} AND export_status = ?", base_query), + success_params.iter().map(|p| p as &dyn rusqlite::ToSql).collect::>().as_slice(), + |row| row.get(0) + )?; + + // 失败导出次数 + let failed_exports = total_exports - successful_exports; + + // 总文件大小 + let total_file_size: u64 = conn.query_row( + &format!("SELECT COALESCE(SUM(file_size), 0) {} AND export_status = '\"Success\"'", base_query), + params.iter().map(|p| p as &dyn rusqlite::ToSql).collect::>().as_slice(), + |row| row.get(0) + ).unwrap_or(0); + + // 平均导出时长 + let average_export_duration_ms: u64 = conn.query_row( + &format!("SELECT COALESCE(AVG(export_duration_ms), 0) {}", base_query), + params.iter().map(|p| p as &dyn rusqlite::ToSql).collect::>().as_slice(), + |row| row.get(0) + ).unwrap_or(0); + + // 导出类型统计 + let mut export_type_counts = std::collections::HashMap::new(); + let mut stmt = conn.prepare(&format!( + "SELECT export_type, COUNT(*) {} GROUP BY export_type", base_query + ))?; + + let type_iter = stmt.query_map( + params.iter().map(|p| p as &dyn rusqlite::ToSql).collect::>().as_slice(), + |row| { + let export_type: String = row.get(0)?; + let count: u32 = row.get(1)?; + Ok((export_type, count)) + } + )?; + + for result in type_iter { + let (export_type, count) = result?; + export_type_counts.insert(export_type, count); + } + + Ok(ExportRecordStatistics { + total_exports, + successful_exports, + failed_exports, + total_file_size, + average_export_duration_ms, + export_type_counts, + }) + } + + /// 将数据库行转换为导出记录实体 + fn row_to_export_record(&self, row: &Row) -> Result { + let export_type_str: String = row.get("export_type")?; + let export_type: ExportType = serde_json::from_str(&export_type_str) + .unwrap_or(ExportType::default()); + + let export_format_str: String = row.get("export_format")?; + let export_format: ExportFormat = serde_json::from_str(&export_format_str) + .unwrap_or(ExportFormat::default()); + + let export_status_str: String = row.get("export_status")?; + let export_status: ExportStatus = serde_json::from_str(&export_status_str) + .unwrap_or(ExportStatus::default()); + + let created_at_str: String = row.get("created_at")?; + let updated_at_str: String = row.get("updated_at")?; + let created_at = DateTime::parse_from_rfc3339(&created_at_str) + .unwrap_or_else(|_| Utc::now().into()) + .with_timezone(&Utc); + let updated_at = DateTime::parse_from_rfc3339(&updated_at_str) + .unwrap_or_else(|_| Utc::now().into()) + .with_timezone(&Utc); + + // 正确处理 is_active 字段 + let is_active = match row.get::<_, rusqlite::types::Value>("is_active")? { + rusqlite::types::Value::Integer(i) => i != 0, + rusqlite::types::Value::Text(s) => s == "1" || s.to_lowercase() == "true", + rusqlite::types::Value::Real(f) => f != 0.0, + _ => true, + }; + + Ok(ExportRecord { + id: row.get("id")?, + matching_result_id: row.get("matching_result_id")?, + project_id: row.get("project_id")?, + template_id: row.get("template_id")?, + export_type, + export_format, + file_path: row.get("file_path")?, + file_size: row.get("file_size")?, + export_status, + export_duration_ms: row.get("export_duration_ms")?, + error_message: row.get("error_message")?, + metadata: row.get("metadata")?, + created_at, + updated_at, + is_active, + }) + } +} diff --git a/apps/desktop/src-tauri/src/data/repositories/mod.rs b/apps/desktop/src-tauri/src/data/repositories/mod.rs index e642f74..c1e9e9f 100644 --- a/apps/desktop/src-tauri/src/data/repositories/mod.rs +++ b/apps/desktop/src-tauri/src/data/repositories/mod.rs @@ -6,4 +6,5 @@ pub mod ai_classification_repository; pub mod video_classification_repository; pub mod project_template_binding_repository; pub mod template_matching_result_repository; +pub mod export_record_repository; pub mod video_generation_repository; diff --git a/apps/desktop/src-tauri/src/data/repositories/template_matching_result_repository.rs b/apps/desktop/src-tauri/src/data/repositories/template_matching_result_repository.rs index 40ec7e4..b2b04d9 100644 --- a/apps/desktop/src-tauri/src/data/repositories/template_matching_result_repository.rs +++ b/apps/desktop/src-tauri/src/data/repositories/template_matching_result_repository.rs @@ -44,8 +44,8 @@ impl TemplateMatchingResultRepository { id, project_id, template_id, binding_id, result_name, description, total_segments, matched_segments, failed_segments, success_rate, used_materials, used_models, matching_duration_ms, quality_score, - status, metadata, created_at, updated_at, is_active - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", + status, metadata, export_count, created_at, updated_at, is_active + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", rusqlite::params![ &final_result.id, &final_result.project_id, @@ -63,6 +63,7 @@ impl TemplateMatchingResultRepository { &final_result.quality_score, &serde_json::to_string(&final_result.status).unwrap(), &final_result.metadata, + &final_result.export_count, &final_result.created_at.to_rfc3339(), &final_result.updated_at.to_rfc3339(), &(final_result.is_active as i32), @@ -81,7 +82,7 @@ impl TemplateMatchingResultRepository { "SELECT id, project_id, template_id, binding_id, result_name, description, total_segments, matched_segments, failed_segments, success_rate, used_materials, used_models, matching_duration_ms, quality_score, - status, metadata, created_at, updated_at, is_active + status, metadata, export_count, created_at, updated_at, is_active FROM template_matching_results WHERE id = ?1" )?; @@ -163,7 +164,7 @@ impl TemplateMatchingResultRepository { let mut query = "SELECT id, project_id, template_id, binding_id, result_name, description, total_segments, matched_segments, failed_segments, success_rate, used_materials, used_models, matching_duration_ms, quality_score, - status, metadata, created_at, updated_at, is_active + status, metadata, export_count, created_at, updated_at, is_active FROM template_matching_results WHERE is_active = 1".to_string(); let mut params: Vec = Vec::new(); @@ -494,6 +495,7 @@ impl TemplateMatchingResultRepository { quality_score, status, metadata: row.get("metadata")?, + export_count: row.get("export_count").unwrap_or(0), created_at, updated_at, is_active, @@ -559,4 +561,19 @@ impl TemplateMatchingResultRepository { updated_at, }) } + + /// 增加导出次数 + pub fn increment_export_count(&self, result_id: &str) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + conn.execute( + "UPDATE template_matching_results + SET export_count = export_count + 1, updated_at = ?2 + WHERE id = ?1", + rusqlite::params![result_id, Utc::now().to_rfc3339()], + )?; + + Ok(()) + } } diff --git a/apps/desktop/src-tauri/src/infrastructure/database.rs b/apps/desktop/src-tauri/src/infrastructure/database.rs index 4cd0647..2a82b3b 100644 --- a/apps/desktop/src-tauri/src/infrastructure/database.rs +++ b/apps/desktop/src-tauri/src/infrastructure/database.rs @@ -690,6 +690,7 @@ impl Database { quality_score REAL, status TEXT NOT NULL DEFAULT 'Success', metadata TEXT, + export_count INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, is_active INTEGER DEFAULT 1, @@ -749,6 +750,31 @@ impl Database { [], )?; + // 创建导出记录表 + conn.execute( + "CREATE TABLE IF NOT EXISTS export_records ( + id TEXT PRIMARY KEY, + matching_result_id TEXT NOT NULL, + project_id TEXT NOT NULL, + template_id TEXT NOT NULL, + export_type TEXT NOT NULL, + export_format TEXT NOT NULL, + file_path TEXT NOT NULL, + file_size INTEGER, + export_status TEXT NOT NULL DEFAULT 'Success', + export_duration_ms INTEGER NOT NULL DEFAULT 0, + error_message TEXT, + metadata TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + is_active INTEGER DEFAULT 1, + FOREIGN KEY (matching_result_id) REFERENCES template_matching_results (id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + FOREIGN KEY (template_id) REFERENCES templates (id) ON DELETE CASCADE + )", + [], + )?; + // 创建素材使用记录表 conn.execute( "CREATE TABLE IF NOT EXISTS material_usage_records ( @@ -894,6 +920,37 @@ impl Database { [], )?; + // 创建导出记录表索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_export_records_matching_result_id ON export_records (matching_result_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_export_records_project_id ON export_records (project_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_export_records_template_id ON export_records (template_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_export_records_export_type ON export_records (export_type)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_export_records_export_status ON export_records (export_status)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_export_records_created_at ON export_records (created_at)", + [], + )?; + // 添加新字段(如果不存在)- 数据库迁移 let _ = conn.execute( "ALTER TABLE template_materials ADD COLUMN file_exists BOOLEAN DEFAULT FALSE", @@ -1410,6 +1467,17 @@ impl Database { println!("Added usage tracking columns to material_segments table"); } + // 添加导出次数字段到模板匹配结果表 + let has_export_count_column = conn.prepare("SELECT export_count FROM template_matching_results LIMIT 1").is_ok(); + if !has_export_count_column { + println!("Adding export_count column to template_matching_results table"); + conn.execute( + "ALTER TABLE template_matching_results ADD COLUMN export_count INTEGER DEFAULT 0", + [], + )?; + println!("Added export_count column to template_matching_results table"); + } + // 暂时禁用自动清理,避免启动时卡住 // self.cleanup_invalid_projects()?; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 38f8d8b..137b7ae 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -222,6 +222,17 @@ pub fn run() { commands::template_matching_result_commands::get_matching_result_status_options, commands::template_matching_result_commands::export_matching_result_to_jianying, commands::template_matching_result_commands::export_matching_result_to_jianying_v2, + // 导出记录命令 + commands::export_record_commands::get_export_record, + commands::export_record_commands::list_export_records, + commands::export_record_commands::get_project_export_records, + commands::export_record_commands::get_matching_result_export_records, + commands::export_record_commands::delete_export_record, + commands::export_record_commands::get_project_export_statistics, + commands::export_record_commands::get_global_export_statistics, + commands::export_record_commands::validate_export_file, + commands::export_record_commands::cleanup_expired_export_records, + commands::export_record_commands::re_export_record, // MaterialSegment聚合视图命令 commands::material_segment_view_commands::get_project_segment_view, commands::material_segment_view_commands::get_project_segment_view_with_query, diff --git a/apps/desktop/src-tauri/src/presentation/commands/export_record_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/export_record_commands.rs new file mode 100644 index 0000000..61bc820 --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/export_record_commands.rs @@ -0,0 +1,257 @@ +/** + * 导出记录相关的 Tauri 命令 + * 遵循 Tauri 开发规范的 API 设计原则 + */ + +use tauri::{command, State}; +use std::sync::Arc; + +use crate::business::services::export_record_service::ExportRecordService; +use crate::data::models::export_record::{ + ExportRecord, ExportRecordQueryOptions, ExportRecordStatistics, +}; +use crate::data::repositories::export_record_repository::ExportRecordRepository; +use crate::data::repositories::template_matching_result_repository::TemplateMatchingResultRepository; +use crate::infrastructure::database::Database; + +/// 获取导出记录详情 +#[command] +pub async fn get_export_record( + record_id: String, + database: State<'_, Arc>, +) -> Result, String> { + println!("📋 获取导出记录详情: {}", record_id); + + let export_record_repository = Arc::new(ExportRecordRepository::new(database.inner().clone())); + let matching_result_repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + let service = ExportRecordService::new(export_record_repository, matching_result_repository); + + match service.get_export_record(&record_id).await { + Ok(record) => { + println!("✅ 获取导出记录成功"); + Ok(record) + } + Err(e) => { + println!("❌ 获取导出记录失败: {}", e); + Err(e.to_string()) + } + } +} + +/// 查询导出记录列表 +#[command] +pub async fn list_export_records( + options: ExportRecordQueryOptions, + database: State<'_, Arc>, +) -> Result, String> { + println!("📋 查询导出记录列表"); + + let export_record_repository = Arc::new(ExportRecordRepository::new(database.inner().clone())); + let matching_result_repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + let service = ExportRecordService::new(export_record_repository, matching_result_repository); + + match service.list_export_records(options).await { + Ok(records) => { + println!("✅ 查询导出记录列表成功,共 {} 条记录", records.len()); + Ok(records) + } + Err(e) => { + println!("❌ 查询导出记录列表失败: {}", e); + Err(e.to_string()) + } + } +} + +/// 获取项目的导出记录 +#[command] +pub async fn get_project_export_records( + project_id: String, + limit: Option, + offset: Option, + database: State<'_, Arc>, +) -> Result, String> { + println!("📋 获取项目导出记录: {}", project_id); + + let export_record_repository = Arc::new(ExportRecordRepository::new(database.inner().clone())); + let matching_result_repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + let service = ExportRecordService::new(export_record_repository, matching_result_repository); + + match service.get_project_exports(&project_id, limit, offset).await { + Ok(records) => { + println!("✅ 获取项目导出记录成功,共 {} 条记录", records.len()); + Ok(records) + } + Err(e) => { + println!("❌ 获取项目导出记录失败: {}", e); + Err(e.to_string()) + } + } +} + +/// 获取匹配结果的导出记录 +#[command] +pub async fn get_matching_result_export_records( + matching_result_id: String, + database: State<'_, Arc>, +) -> Result, String> { + println!("📋 获取匹配结果导出记录: {}", matching_result_id); + + let export_record_repository = Arc::new(ExportRecordRepository::new(database.inner().clone())); + let matching_result_repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + let service = ExportRecordService::new(export_record_repository, matching_result_repository); + + match service.get_matching_result_exports(&matching_result_id).await { + Ok(records) => { + println!("✅ 获取匹配结果导出记录成功,共 {} 条记录", records.len()); + Ok(records) + } + Err(e) => { + println!("❌ 获取匹配结果导出记录失败: {}", e); + Err(e.to_string()) + } + } +} + +/// 删除导出记录 +#[command] +pub async fn delete_export_record( + record_id: String, + database: State<'_, Arc>, +) -> Result<(), String> { + println!("🗑️ 删除导出记录: {}", record_id); + + let export_record_repository = Arc::new(ExportRecordRepository::new(database.inner().clone())); + let matching_result_repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + let service = ExportRecordService::new(export_record_repository, matching_result_repository); + + match service.delete_export_record(&record_id).await { + Ok(_) => { + println!("✅ 删除导出记录成功"); + Ok(()) + } + Err(e) => { + println!("❌ 删除导出记录失败: {}", e); + Err(e.to_string()) + } + } +} + +/// 获取项目导出统计信息 +#[command] +pub async fn get_project_export_statistics( + project_id: String, + database: State<'_, Arc>, +) -> Result { + println!("📊 获取项目导出统计信息: {}", project_id); + + let export_record_repository = Arc::new(ExportRecordRepository::new(database.inner().clone())); + let matching_result_repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + let service = ExportRecordService::new(export_record_repository, matching_result_repository); + + match service.get_project_export_statistics(&project_id).await { + Ok(statistics) => { + println!("✅ 获取项目导出统计信息成功"); + Ok(statistics) + } + Err(e) => { + println!("❌ 获取项目导出统计信息失败: {}", e); + Err(e.to_string()) + } + } +} + +/// 获取全局导出统计信息 +#[command] +pub async fn get_global_export_statistics( + database: State<'_, Arc>, +) -> Result { + println!("📊 获取全局导出统计信息"); + + let export_record_repository = Arc::new(ExportRecordRepository::new(database.inner().clone())); + let matching_result_repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + let service = ExportRecordService::new(export_record_repository, matching_result_repository); + + match service.get_global_export_statistics().await { + Ok(statistics) => { + println!("✅ 获取全局导出统计信息成功"); + Ok(statistics) + } + Err(e) => { + println!("❌ 获取全局导出统计信息失败: {}", e); + Err(e.to_string()) + } + } +} + +/// 验证导出文件是否存在 +#[command] +pub async fn validate_export_file( + record_id: String, + database: State<'_, Arc>, +) -> Result { + println!("🔍 验证导出文件: {}", record_id); + + let export_record_repository = Arc::new(ExportRecordRepository::new(database.inner().clone())); + let matching_result_repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + let service = ExportRecordService::new(export_record_repository, matching_result_repository); + + match service.validate_export_file(&record_id).await { + Ok(exists) => { + println!("✅ 验证导出文件完成: {}", if exists { "存在" } else { "不存在" }); + Ok(exists) + } + Err(e) => { + println!("❌ 验证导出文件失败: {}", e); + Err(e.to_string()) + } + } +} + +/// 清理过期的导出记录 +#[command] +pub async fn cleanup_expired_export_records( + days: u32, + database: State<'_, Arc>, +) -> Result { + println!("🧹 清理过期导出记录(超过 {} 天)", days); + + let export_record_repository = Arc::new(ExportRecordRepository::new(database.inner().clone())); + let matching_result_repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + let service = ExportRecordService::new(export_record_repository, matching_result_repository); + + match service.cleanup_expired_records(days).await { + Ok(count) => { + println!("✅ 清理过期导出记录完成,共清理 {} 条记录", count); + Ok(count) + } + Err(e) => { + println!("❌ 清理过期导出记录失败: {}", e); + Err(e.to_string()) + } + } +} + +/// 重新导出 +#[command] +pub async fn re_export_record( + record_id: String, + new_file_path: String, + database: State<'_, Arc>, +) -> Result { + println!("🔄 重新导出: {} -> {}", record_id, new_file_path); + + let export_record_repository = Arc::new(ExportRecordRepository::new(database.inner().clone())); + let matching_result_repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + let service = ExportRecordService::new(export_record_repository, matching_result_repository); + + match service.re_export(&record_id, new_file_path).await { + Ok(new_record) => { + println!("✅ 重新导出成功,新记录ID: {}", new_record.id); + Ok(new_record) + } + Err(e) => { + println!("❌ 重新导出失败: {}", e); + Err(e.to_string()) + } + } +} diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index f5c6994..d914261 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -14,4 +14,5 @@ pub mod debug_commands; pub mod project_template_binding_commands; pub mod material_matching_commands; pub mod template_matching_result_commands; +pub mod export_record_commands; pub mod video_generation_commands; diff --git a/apps/desktop/src-tauri/src/presentation/commands/template_matching_result_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/template_matching_result_commands.rs index 42fb3e1..8b27ff5 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/template_matching_result_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/template_matching_result_commands.rs @@ -240,22 +240,70 @@ pub async fn export_matching_result_to_jianying( output_path: String, database: State<'_, Arc>, ) -> Result { + use std::time::Instant; + use crate::data::repositories::export_record_repository::ExportRecordRepository; + use crate::business::services::export_record_service::ExportRecordService; + use crate::data::models::export_record::{ExportType, ExportFormat}; + println!("🎬 开始导出匹配结果到剪映格式 (V1)"); println!("📋 导出参数:"); println!(" - 匹配结果ID: {}", result_id); println!(" - 输出路径: {}", output_path); + let start_time = Instant::now(); + + // 创建服务实例 let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); let material_repository = Arc::new(MaterialRepository::new(database.inner().clone()) .map_err(|e| format!("创建MaterialRepository失败: {}", e))?); + let export_record_repository = Arc::new(ExportRecordRepository::new(database.inner().clone())); + let export_record_service = ExportRecordService::new(export_record_repository, repository.clone()); let service = TemplateMatchingResultService::new(repository); + // 创建导出记录 + let export_record = match export_record_service.create_export_record( + result_id.clone(), + ExportType::JianYingV1, + ExportFormat::Json, + output_path.clone(), + None, + ).await { + Ok(record) => record, + Err(e) => { + println!("❌ 创建导出记录失败: {}", e); + return Err(format!("创建导出记录失败: {}", e)); + } + }; + + // 执行导出 match service.export_to_jianying(&result_id, &output_path, material_repository).await { Ok(file_path) => { + let duration_ms = start_time.elapsed().as_millis() as u64; + + // 标记导出成功 + if let Err(e) = export_record_service.mark_export_success( + export_record.id, + file_path.clone(), + duration_ms, + ).await { + println!("⚠️ 更新导出记录失败: {}", e); + } + println!("✅ 导出成功: {}", file_path); Ok(file_path) } Err(e) => { + let duration_ms = start_time.elapsed().as_millis() as u64; + + // 标记导出失败 + if let Err(err) = export_record_service.mark_export_failed( + export_record.id, + e.to_string(), + duration_ms, + ).await { + println!("⚠️ 更新导出记录失败: {}", err); + } + println!("❌ 导出失败: {}", e); Err(e.to_string()) } @@ -270,23 +318,71 @@ pub async fn export_matching_result_to_jianying_v2( output_path: String, database: State<'_, Arc>, ) -> Result { + use std::time::Instant; + use crate::data::repositories::export_record_repository::ExportRecordRepository; + use crate::business::services::export_record_service::ExportRecordService; + use crate::data::models::export_record::{ExportType, ExportFormat}; + println!("🎬 开始导出匹配结果到剪映格式"); println!("📋 导出参数:"); println!(" - 匹配结果ID: {}", result_id); println!(" - 输出路径: {}", output_path); + let start_time = Instant::now(); + + // 创建服务实例 let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); let material_repository = Arc::new(MaterialRepository::new(database.inner().clone()) .map_err(|e| format!("创建MaterialRepository失败: {}", e))?); let template_service = Arc::new(crate::business::services::template_service::TemplateService::new(database.inner().clone())); + let export_record_repository = Arc::new(ExportRecordRepository::new(database.inner().clone())); + let export_record_service = ExportRecordService::new(export_record_repository, repository.clone()); let service = TemplateMatchingResultService::new(repository); + // 创建导出记录 + let export_record = match export_record_service.create_export_record( + result_id.clone(), + ExportType::JianYingV2, + ExportFormat::Json, + output_path.clone(), + None, + ).await { + Ok(record) => record, + Err(e) => { + println!("❌ 创建导出记录失败: {}", e); + return Err(format!("创建导出记录失败: {}", e)); + } + }; + + // 执行导出 match service.export_to_jianying_v2(&result_id, &output_path, material_repository, template_service).await { Ok(file_path) => { + let duration_ms = start_time.elapsed().as_millis() as u64; + + // 标记导出成功 + if let Err(e) = export_record_service.mark_export_success( + export_record.id, + file_path.clone(), + duration_ms, + ).await { + println!("⚠️ 更新导出记录失败: {}", e); + } + println!("✅ 导出成功: {}", file_path); Ok(file_path) } Err(e) => { + let duration_ms = start_time.elapsed().as_millis() as u64; + + // 标记导出失败 + if let Err(err) = export_record_service.mark_export_failed( + export_record.id, + e.to_string(), + duration_ms, + ).await { + println!("⚠️ 更新导出记录失败: {}", err); + } + eprintln!("❌ 导出失败: {}", e); Err(format!("导出失败: {}", e)) } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 9b20475..da7863e 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -8,6 +8,7 @@ import ModelDetail from './pages/ModelDetail'; import AiClassificationSettings from './pages/AiClassificationSettings'; import TemplateManagement from './pages/TemplateManagement'; import { MaterialModelBinding } from './pages/MaterialModelBinding'; +import ExportRecordsPage from './pages/ExportRecordsPage'; import Navigation from './components/Navigation'; import { NotificationSystem, useNotifications } from './components/NotificationSystem'; import { useProjectStore } from './store/projectStore'; @@ -78,6 +79,7 @@ function App() { } /> } /> } /> + } /> diff --git a/apps/desktop/src/components/ExportRecordManager.tsx b/apps/desktop/src/components/ExportRecordManager.tsx new file mode 100644 index 0000000..614ef52 --- /dev/null +++ b/apps/desktop/src/components/ExportRecordManager.tsx @@ -0,0 +1,387 @@ +import React, { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { + ExportRecord, + ExportRecordQueryOptions, + ExportRecordStatistics, + ExportType, + ExportStatus +} from '../types/exportRecord'; + +interface ExportRecordManagerProps { + projectId?: string; + matchingResultId?: string; +} + +const ExportRecordManager: React.FC = ({ + projectId, + matchingResultId +}) => { + const [records, setRecords] = useState([]); + const [statistics, setStatistics] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // const [selectedRecords] = useState([]); + + // 过滤和分页状态 + const [filters, setFilters] = useState({ + export_type: '' as ExportType | '', + export_status: '' as ExportStatus | '', + search_keyword: '', + }); + const [pagination, setPagination] = useState({ + page: 1, + page_size: 20, + total: 0, + }); + + // 加载导出记录列表 + const loadExportRecords = async () => { + setLoading(true); + setError(null); + + try { + const options: ExportRecordQueryOptions = { + project_id: projectId, + matching_result_id: matchingResultId, + export_type: filters.export_type || undefined, + export_status: filters.export_status || undefined, + search_keyword: filters.search_keyword || undefined, + limit: pagination.page_size, + offset: (pagination.page - 1) * pagination.page_size, + sort_by: 'created_at', + sort_order: 'desc', + }; + + const result = await invoke('list_export_records', { options }); + setRecords(result); + + // 更新总数(这里简化处理,实际应该从后端返回) + setPagination(prev => ({ ...prev, total: result.length })); + } catch (err) { + setError(`加载导出记录失败: ${err}`); + } finally { + setLoading(false); + } + }; + + // 加载统计信息 + const loadStatistics = async () => { + try { + let stats: ExportRecordStatistics; + if (projectId) { + stats = await invoke('get_project_export_statistics', { + projectId + }); + } else { + stats = await invoke('get_global_export_statistics'); + } + setStatistics(stats); + } catch (err) { + console.error('加载统计信息失败:', err); + } + }; + + // 删除导出记录 + const handleDeleteRecord = async (recordId: string) => { + if (!window.confirm('确定要删除这条导出记录吗?')) { + return; + } + + try { + await invoke('delete_export_record', { recordId }); + await loadExportRecords(); + await loadStatistics(); + } catch (err) { + setError(`删除导出记录失败: ${err}`); + } + }; + + // 验证文件是否存在 + const handleValidateFile = async (recordId: string) => { + try { + const exists = await invoke('validate_export_file', { recordId }); + alert(exists ? '文件存在' : '文件不存在'); + } catch (err) { + setError(`验证文件失败: ${err}`); + } + }; + + // 重新导出 + const handleReExport = async (recordId: string) => { + // 这里应该打开文件选择对话框,简化处理 + const newFilePath = prompt('请输入新的文件路径:'); + if (!newFilePath) return; + + try { + await invoke('re_export_record', { recordId, newFilePath }); + await loadExportRecords(); + } catch (err) { + setError(`重新导出失败: ${err}`); + } + }; + + // 清理过期记录 + const handleCleanupExpired = async () => { + const days = prompt('请输入要清理多少天前的记录:', '30'); + if (!days) return; + + try { + const count = await invoke('cleanup_expired_export_records', { + days: parseInt(days) + }); + alert(`已清理 ${count} 条过期记录`); + await loadExportRecords(); + await loadStatistics(); + } catch (err) { + setError(`清理过期记录失败: ${err}`); + } + }; + + // 格式化文件大小 + const formatFileSize = (bytes?: number): string => { + if (!bytes) return '-'; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; + }; + + // 格式化持续时间 + const formatDuration = (ms: number): string => { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + return `${minutes}m ${seconds % 60}s`; + }; + + // 获取状态颜色 + const getStatusColor = (status: ExportStatus): string => { + switch (status) { + case ExportStatus.Success: return 'text-green-600'; + case ExportStatus.Failed: return 'text-red-600'; + case ExportStatus.InProgress: return 'text-blue-600'; + case ExportStatus.Cancelled: return 'text-gray-600'; + default: return 'text-gray-600'; + } + }; + + // 获取类型图标 + const getTypeIcon = (type: ExportType): string => { + switch (type) { + case ExportType.JianYingV1: return '🎬'; + case ExportType.JianYingV2: return '🎭'; + case ExportType.Json: return '📄'; + case ExportType.Csv: return '📊'; + case ExportType.Excel: return '📈'; + default: return '📁'; + } + }; + + useEffect(() => { + loadExportRecords(); + loadStatistics(); + }, [projectId, matchingResultId, filters, pagination.page]); + + return ( +
+
+

+ 导出记录管理 + {projectId && (项目范围)} + {matchingResultId && (匹配结果范围)} +

+ + {/* 统计信息 */} + {statistics && ( +
+
+
{statistics.total_exports}
+
总导出次数
+
+
+
{statistics.successful_exports}
+
成功导出
+
+
+
{statistics.failed_exports}
+
失败导出
+
+
+
+ {formatFileSize(statistics.total_file_size)} +
+
总文件大小
+
+
+ )} + + {/* 过滤器 */} +
+ + + + + setFilters(prev => ({ ...prev, search_keyword: e.target.value }))} + className="px-3 py-2 border border-gray-300 rounded-md flex-1 min-w-64" + /> + + +
+
+ + {/* 错误信息 */} + {error && ( +
+ {error} +
+ )} + + {/* 加载状态 */} + {loading && ( +
+
加载中...
+
+ )} + + {/* 导出记录列表 */} + {!loading && ( +
+
+ + + + + + + + + + + + + + {records.map((record) => ( + + + + + + + + + + ))} + +
+ 类型 + + 文件路径 + + 状态 + + 文件大小 + + 耗时 + + 创建时间 + + 操作 +
+
+ {getTypeIcon(record.export_type)} + + {record.export_type.replace(/([A-Z])/g, ' $1').trim()} + +
+
+
+ {record.file_path} +
+
+ + {record.export_status === ExportStatus.Success && '成功'} + {record.export_status === ExportStatus.Failed && '失败'} + {record.export_status === ExportStatus.InProgress && '进行中'} + {record.export_status === ExportStatus.Cancelled && '已取消'} + + {record.error_message && ( +
+ {record.error_message.substring(0, 50)}... +
+ )} +
+ {formatFileSize(record.file_size)} + + {formatDuration(record.export_duration_ms)} + + {new Date(record.created_at).toLocaleString()} + +
+ + + +
+
+
+ + {records.length === 0 && !loading && ( +
+ 暂无导出记录 +
+ )} +
+ )} +
+ ); +}; + +export default ExportRecordManager; diff --git a/apps/desktop/src/components/Navigation.tsx b/apps/desktop/src/components/Navigation.tsx index 945b573..c4475f4 100644 --- a/apps/desktop/src/components/Navigation.tsx +++ b/apps/desktop/src/components/Navigation.tsx @@ -5,7 +5,8 @@ import { UserGroupIcon, CpuChipIcon, DocumentDuplicateIcon, - LinkIcon + LinkIcon, + DocumentArrowDownIcon } from '@heroicons/react/24/outline'; const Navigation: React.FC = () => { @@ -41,6 +42,12 @@ const Navigation: React.FC = () => { href: '/ai-classification-settings', icon: CpuChipIcon, description: '管理AI视频分类规则' + }, + { + name: '导出记录', + href: '/export-records', + icon: DocumentArrowDownIcon, + description: '查看和管理导出记录' } ]; diff --git a/apps/desktop/src/pages/ExportRecordsPage.tsx b/apps/desktop/src/pages/ExportRecordsPage.tsx new file mode 100644 index 0000000..50de048 --- /dev/null +++ b/apps/desktop/src/pages/ExportRecordsPage.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import ExportRecordManager from '../components/ExportRecordManager'; + +const ExportRecordsPage: React.FC = () => { + return ( +
+
+
+ +
+
+
+ ); +}; + +export default ExportRecordsPage; diff --git a/apps/desktop/src/types/exportRecord.ts b/apps/desktop/src/types/exportRecord.ts new file mode 100644 index 0000000..a7a55f4 --- /dev/null +++ b/apps/desktop/src/types/exportRecord.ts @@ -0,0 +1,294 @@ +/** + * 导出记录相关的类型定义 + * 遵循前端开发规范的类型设计原则 + */ + +// 导出类型枚举 +export enum ExportType { + JianYingV1 = 'JianYingV1', + JianYingV2 = 'JianYingV2', + Json = 'Json', + Csv = 'Csv', + Excel = 'Excel' +} + +// 导出格式枚举 +export enum ExportFormat { + Json = 'Json', + Csv = 'Csv', + Excel = 'Excel', + Other = 'Other' +} + +// 导出状态枚举 +export enum ExportStatus { + Success = 'Success', + Failed = 'Failed', + InProgress = 'InProgress', + Cancelled = 'Cancelled' +} + +// 导出记录 +export interface ExportRecord { + id: string; + matching_result_id: string; + project_id: string; + template_id: string; + export_type: ExportType; + export_format: ExportFormat; + file_path: string; + file_size?: number; + export_status: ExportStatus; + export_duration_ms: number; + error_message?: string; + metadata?: string; + created_at: string; + updated_at: string; + is_active: boolean; +} + +// 创建导出记录请求 +export interface CreateExportRecordRequest { + matching_result_id: string; + export_type: ExportType; + export_format: ExportFormat; + file_path: string; + metadata?: string; +} + +// 日期范围 +export interface DateRange { + start_date: string; + end_date: string; +} + +// 导出记录查询选项 +export interface ExportRecordQueryOptions { + project_id?: string; + matching_result_id?: string; + template_id?: string; + export_type?: ExportType; + export_status?: ExportStatus; + limit?: number; + offset?: number; + search_keyword?: string; + sort_by?: string; // "created_at", "export_duration_ms", "file_size" + sort_order?: string; // "asc", "desc" + date_range?: DateRange; +} + +// 导出记录统计信息 +export interface ExportRecordStatistics { + total_exports: number; + successful_exports: number; + failed_exports: number; + total_file_size: number; + average_export_duration_ms: number; + export_type_counts: Record; +} + +// 导出记录过滤选项 +export interface ExportRecordFilterOptions { + project_id?: string; + template_id?: string; + export_type?: ExportType; + export_status?: ExportStatus; + date_range?: DateRange; +} + +// 导出记录排序选项 +export interface ExportRecordSortOptions { + field: 'created_at' | 'updated_at' | 'export_duration_ms' | 'file_size' | 'export_status'; + order: 'asc' | 'desc'; +} + +// 导出记录分页选项 +export interface ExportRecordPaginationOptions { + page: number; + page_size: number; + total?: number; +} + +// 导出记录搜索选项 +export interface ExportRecordSearchOptions { + keyword?: string; + filter?: ExportRecordFilterOptions; + sort?: ExportRecordSortOptions; + pagination?: ExportRecordPaginationOptions; +} + +// 导出记录列表响应 +export interface ExportRecordListResponse { + records: ExportRecord[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +// 导出记录详情响应 +export interface ExportRecordDetailResponse { + record: ExportRecord; + matching_result?: { + id: string; + result_name: string; + project_id: string; + template_id: string; + }; + project?: { + id: string; + name: string; + }; + template?: { + id: string; + name: string; + }; +} + +// 导出记录操作选项 +export interface ExportRecordActionOptions { + // 重新导出 + re_export?: { + new_file_path: string; + }; + // 验证文件 + validate_file?: boolean; + // 删除记录 + delete?: boolean; +} + +// 导出记录批量操作请求 +export interface ExportRecordBatchActionRequest { + record_ids: string[]; + action: 'delete' | 'validate' | 'cleanup'; + options?: { + force?: boolean; + backup?: boolean; + }; +} + +// 导出记录批量操作响应 +export interface ExportRecordBatchActionResponse { + success_count: number; + failed_count: number; + errors: Array<{ + record_id: string; + error: string; + }>; +} + +// 导出记录清理选项 +export interface ExportRecordCleanupOptions { + days_old: number; + include_failed?: boolean; + include_successful?: boolean; + dry_run?: boolean; +} + +// 导出记录清理响应 +export interface ExportRecordCleanupResponse { + cleaned_count: number; + total_size_freed: number; + records_cleaned: Array<{ + id: string; + file_path: string; + file_size?: number; + }>; +} + +// 导出记录表格列定义 +export interface ExportRecordTableColumn { + key: keyof ExportRecord | 'actions'; + title: string; + sortable?: boolean; + filterable?: boolean; + width?: number; + render?: (record: ExportRecord) => React.ReactNode; +} + +// 导出记录表格配置 +export interface ExportRecordTableConfig { + columns: ExportRecordTableColumn[]; + pagination: ExportRecordPaginationOptions; + sort: ExportRecordSortOptions; + filter: ExportRecordFilterOptions; + selection?: { + enabled: boolean; + selected_ids: string[]; + }; +} + +// 导出记录图表数据 +export interface ExportRecordChartData { + // 按日期统计 + daily_stats: Array<{ + date: string; + total_exports: number; + successful_exports: number; + failed_exports: number; + }>; + // 按类型统计 + type_stats: Array<{ + export_type: ExportType; + count: number; + percentage: number; + }>; + // 按状态统计 + status_stats: Array<{ + export_status: ExportStatus; + count: number; + percentage: number; + }>; +} + +// 导出记录仪表板数据 +export interface ExportRecordDashboardData { + statistics: ExportRecordStatistics; + recent_records: ExportRecord[]; + chart_data: ExportRecordChartData; + trends: { + export_count_trend: number; // 相比上周的变化百分比 + success_rate_trend: number; + average_duration_trend: number; + }; +} + +// 导出记录表单数据 +export interface ExportRecordFormData { + matching_result_id: string; + export_type: ExportType; + export_format: ExportFormat; + file_path: string; + metadata?: string; +} + +// 导出记录验证规则 +export interface ExportRecordValidationRules { + matching_result_id: { + required: boolean; + message: string; + }; + export_type: { + required: boolean; + message: string; + }; + export_format: { + required: boolean; + message: string; + }; + file_path: { + required: boolean; + pattern?: RegExp; + message: string; + }; +} + +// 导出记录工具函数类型 +export type ExportRecordUtils = { + formatFileSize: (bytes?: number) => string; + formatDuration: (ms: number) => string; + getStatusColor: (status: ExportStatus) => string; + getTypeIcon: (type: ExportType) => string; + validateFilePath: (path: string) => boolean; + generateFileName: (record: ExportRecord) => string; +}; diff --git a/apps/desktop/src/types/templateMatchingResult.ts b/apps/desktop/src/types/templateMatchingResult.ts index 6593b99..f6d4759 100644 --- a/apps/desktop/src/types/templateMatchingResult.ts +++ b/apps/desktop/src/types/templateMatchingResult.ts @@ -30,6 +30,7 @@ export interface TemplateMatchingResult { quality_score?: number; status: MatchingResultStatus; metadata?: string; + export_count: number; created_at: string; updated_at: string; is_active: boolean;