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.
This commit is contained in:
imeepos
2025-07-17 13:03:51 +08:00
parent 823a54525e
commit c190fdae1b
19 changed files with 1992 additions and 5 deletions

View File

@@ -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<ExportRecordRepository>,
matching_result_repository: Arc<TemplateMatchingResultRepository>,
}
impl ExportRecordService {
/// 创建新的导出记录服务实例
pub fn new(
export_record_repository: Arc<ExportRecordRepository>,
matching_result_repository: Arc<TemplateMatchingResultRepository>,
) -> 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<String>,
) -> Result<ExportRecord> {
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<Option<ExportRecord>> {
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<Vec<ExportRecord>> {
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<ExportRecordStatistics> {
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<ExportRecordStatistics> {
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<u32> {
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<bool> {
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<Vec<ExportRecord>> {
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<u32>,
offset: Option<u32>,
) -> Result<Vec<ExportRecord>> {
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<ExportRecord> {
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)
}
}

View File

@@ -19,6 +19,7 @@ pub mod enhanced_template_import_service;
pub mod project_template_binding_service;
pub mod material_matching_service;
pub mod template_matching_result_service;
pub mod export_record_service;
pub mod video_generation_service;
pub mod jianying_export;

View File

@@ -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<u64>, // 文件大小(字节)
pub export_status: ExportStatus, // 导出状态
pub export_duration_ms: u64, // 导出耗时(毫秒)
pub error_message: Option<String>, // 错误信息
pub metadata: Option<String>, // JSON格式的额外元数据
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
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<u64>, 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<String>,
}
/// 导出记录查询选项
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportRecordQueryOptions {
pub project_id: Option<String>,
pub matching_result_id: Option<String>,
pub template_id: Option<String>,
pub export_type: Option<ExportType>,
pub export_status: Option<ExportStatus>,
pub limit: Option<u32>,
pub offset: Option<u32>,
pub search_keyword: Option<String>,
pub sort_by: Option<String>, // "created_at", "export_duration_ms", "file_size"
pub sort_order: Option<String>, // "asc", "desc"
pub date_range: Option<DateRange>,
}
/// 日期范围
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DateRange {
pub start_date: DateTime<Utc>,
pub end_date: DateTime<Utc>,
}
/// 导出记录统计信息
#[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<String, u32>,
}

View File

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

View File

@@ -22,6 +22,7 @@ pub struct TemplateMatchingResult {
pub quality_score: Option<f64>, // 匹配质量评分
pub status: MatchingResultStatus,
pub metadata: Option<String>, // JSON格式的额外元数据
pub export_count: u32, // 导出次数
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
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,

View File

@@ -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<Database>,
}
impl ExportRecordRepository {
/// 创建新的导出记录仓储实例
pub fn new(database: Arc<Database>) -> Self {
Self { database }
}
/// 创建导出记录
pub fn create(&self, request: CreateExportRecordRequest) -> Result<ExportRecord> {
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<Option<ExportRecord>> {
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<Vec<ExportRecord>> {
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<String> = 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<ExportRecordStatistics> {
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<String> = 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::<Vec<_>>().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::<Vec<_>>().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::<Vec<_>>().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::<Vec<_>>().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::<Vec<_>>().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<ExportRecord> {
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,
})
}
}

View File

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

View File

@@ -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<String> = 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(())
}
}

View File

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

View File

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

View File

@@ -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<Database>>,
) -> Result<Option<ExportRecord>, 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<Database>>,
) -> Result<Vec<ExportRecord>, 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<u32>,
offset: Option<u32>,
database: State<'_, Arc<Database>>,
) -> Result<Vec<ExportRecord>, 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<Database>>,
) -> Result<Vec<ExportRecord>, 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<Database>>,
) -> 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<Database>>,
) -> Result<ExportRecordStatistics, 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_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<Database>>,
) -> Result<ExportRecordStatistics, 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.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<Database>>,
) -> Result<bool, 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.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<Database>>,
) -> Result<u32, String> {
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<Database>>,
) -> Result<ExportRecord, String> {
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())
}
}
}

View File

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

View File

@@ -240,22 +240,70 @@ pub async fn export_matching_result_to_jianying(
output_path: String,
database: State<'_, Arc<Database>>,
) -> Result<String, String> {
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<Database>>,
) -> Result<String, String> {
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))
}

View File

@@ -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() {
<Route path="/ai-classification-settings" element={<AiClassificationSettings />} />
<Route path="/templates" element={<TemplateManagement />} />
<Route path="/material-model-binding" element={<MaterialModelBinding />} />
<Route path="/export-records" element={<ExportRecordsPage />} />
</Routes>
</div>
</main>

View File

@@ -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<ExportRecordManagerProps> = ({
projectId,
matchingResultId
}) => {
const [records, setRecords] = useState<ExportRecord[]>([]);
const [statistics, setStatistics] = useState<ExportRecordStatistics | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// const [selectedRecords] = useState<string[]>([]);
// 过滤和分页状态
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<ExportRecord[]>('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<ExportRecordStatistics>('get_project_export_statistics', {
projectId
});
} else {
stats = await invoke<ExportRecordStatistics>('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<boolean>('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<number>('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 (
<div className="export-record-manager p-6">
<div className="header mb-6">
<h2 className="text-2xl font-bold text-gray-800 mb-4">
{projectId && <span className="text-sm text-gray-600 ml-2">()</span>}
{matchingResultId && <span className="text-sm text-gray-600 ml-2">()</span>}
</h2>
{/* 统计信息 */}
{statistics && (
<div className="stats-grid grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="stat-card bg-blue-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{statistics.total_exports}</div>
<div className="text-sm text-gray-600"></div>
</div>
<div className="stat-card bg-green-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-green-600">{statistics.successful_exports}</div>
<div className="text-sm text-gray-600"></div>
</div>
<div className="stat-card bg-red-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-red-600">{statistics.failed_exports}</div>
<div className="text-sm text-gray-600"></div>
</div>
<div className="stat-card bg-purple-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-purple-600">
{formatFileSize(statistics.total_file_size)}
</div>
<div className="text-sm text-gray-600"></div>
</div>
</div>
)}
{/* 过滤器 */}
<div className="filters flex flex-wrap gap-4 mb-4">
<select
value={filters.export_type}
onChange={(e) => setFilters(prev => ({ ...prev, export_type: e.target.value as ExportType }))}
className="px-3 py-2 border border-gray-300 rounded-md"
>
<option value=""></option>
<option value={ExportType.JianYingV1}> V1</option>
<option value={ExportType.JianYingV2}> V2</option>
<option value={ExportType.Json}>JSON</option>
<option value={ExportType.Csv}>CSV</option>
<option value={ExportType.Excel}>Excel</option>
</select>
<select
value={filters.export_status}
onChange={(e) => setFilters(prev => ({ ...prev, export_status: e.target.value as ExportStatus }))}
className="px-3 py-2 border border-gray-300 rounded-md"
>
<option value=""></option>
<option value={ExportStatus.Success}></option>
<option value={ExportStatus.Failed}></option>
<option value={ExportStatus.InProgress}></option>
<option value={ExportStatus.Cancelled}></option>
</select>
<input
type="text"
placeholder="搜索文件路径或错误信息..."
value={filters.search_keyword}
onChange={(e) => setFilters(prev => ({ ...prev, search_keyword: e.target.value }))}
className="px-3 py-2 border border-gray-300 rounded-md flex-1 min-w-64"
/>
<button
onClick={handleCleanupExpired}
className="px-4 py-2 bg-orange-500 text-white rounded-md hover:bg-orange-600"
>
</button>
</div>
</div>
{/* 错误信息 */}
{error && (
<div className="error-message bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{/* 加载状态 */}
{loading && (
<div className="loading text-center py-8">
<div className="text-gray-600">...</div>
</div>
)}
{/* 导出记录列表 */}
{!loading && (
<div className="records-table">
<div className="overflow-x-auto">
<table className="min-w-full bg-white border border-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{records.map((record) => (
<tr key={record.id} className="hover:bg-gray-50">
<td className="px-4 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="mr-2">{getTypeIcon(record.export_type)}</span>
<span className="text-sm text-gray-900">
{record.export_type.replace(/([A-Z])/g, ' $1').trim()}
</span>
</div>
</td>
<td className="px-4 py-4">
<div className="text-sm text-gray-900 truncate max-w-xs" title={record.file_path}>
{record.file_path}
</div>
</td>
<td className="px-4 py-4 whitespace-nowrap">
<span className={`text-sm font-medium ${getStatusColor(record.export_status)}`}>
{record.export_status === ExportStatus.Success && '成功'}
{record.export_status === ExportStatus.Failed && '失败'}
{record.export_status === ExportStatus.InProgress && '进行中'}
{record.export_status === ExportStatus.Cancelled && '已取消'}
</span>
{record.error_message && (
<div className="text-xs text-red-600 mt-1" title={record.error_message}>
{record.error_message.substring(0, 50)}...
</div>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{formatFileSize(record.file_size)}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDuration(record.export_duration_ms)}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{new Date(record.created_at).toLocaleString()}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<button
onClick={() => handleValidateFile(record.id)}
className="text-blue-600 hover:text-blue-900"
title="验证文件"
>
🔍
</button>
<button
onClick={() => handleReExport(record.id)}
className="text-green-600 hover:text-green-900"
title="重新导出"
>
🔄
</button>
<button
onClick={() => handleDeleteRecord(record.id)}
className="text-red-600 hover:text-red-900"
title="删除记录"
>
🗑
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{records.length === 0 && !loading && (
<div className="text-center py-8 text-gray-500">
</div>
)}
</div>
)}
</div>
);
};
export default ExportRecordManager;

View File

@@ -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: '查看和管理导出记录'
}
];

View File

@@ -0,0 +1,16 @@
import React from 'react';
import ExportRecordManager from '../components/ExportRecordManager';
const ExportRecordsPage: React.FC = () => {
return (
<div className="export-records-page min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<div className="bg-white rounded-lg shadow-sm">
<ExportRecordManager />
</div>
</div>
</div>
);
};
export default ExportRecordsPage;

View File

@@ -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<string, number>;
}
// 导出记录过滤选项
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;
};

View File

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