fix: 修复保存 匹配记录的bug
This commit is contained in:
@@ -33,6 +33,8 @@ impl TemplateMatchingResultService {
|
||||
description: Option<String>,
|
||||
matching_duration_ms: u64,
|
||||
) -> Result<TemplateMatchingResult> {
|
||||
println!("🔧 TemplateMatchingResultService::save_matching_result 开始");
|
||||
|
||||
// 创建主匹配结果记录
|
||||
let create_request = CreateTemplateMatchingResultRequest {
|
||||
project_id: service_result.project_id.clone(),
|
||||
@@ -42,13 +44,21 @@ impl TemplateMatchingResultService {
|
||||
description,
|
||||
};
|
||||
|
||||
println!("📝 创建主匹配结果记录...");
|
||||
let mut matching_result = self.repository.create(create_request)?;
|
||||
println!("✅ 主匹配结果记录创建成功,ID: {}", matching_result.id);
|
||||
|
||||
// 更新统计信息
|
||||
println!("📊 计算统计信息...");
|
||||
let total_segments = service_result.matches.len() + service_result.failed_segments.len();
|
||||
let matched_segments = service_result.matches.len();
|
||||
let failed_segments = service_result.failed_segments.len();
|
||||
|
||||
println!("📈 基础统计:");
|
||||
println!(" - total_segments: {}", total_segments);
|
||||
println!(" - matched_segments: {}", matched_segments);
|
||||
println!(" - failed_segments: {}", failed_segments);
|
||||
|
||||
// 计算使用的素材和模特数量
|
||||
let mut used_material_ids = std::collections::HashSet::new();
|
||||
let mut used_model_names = std::collections::HashSet::new();
|
||||
@@ -60,6 +70,10 @@ impl TemplateMatchingResultService {
|
||||
}
|
||||
}
|
||||
|
||||
println!("📋 素材和模特统计:");
|
||||
println!(" - used_materials: {}", used_material_ids.len());
|
||||
println!(" - used_models: {}", used_model_names.len());
|
||||
|
||||
matching_result.update_statistics(
|
||||
total_segments as u32,
|
||||
matched_segments as u32,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
pub mod draft_parser_tests;
|
||||
pub mod cloud_upload_service_tests;
|
||||
pub mod template_matching_result_service_tests;
|
||||
|
||||
// 测试工具函数
|
||||
pub mod test_utils {
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::template_matching_result_service::{
|
||||
TemplateMatchingResultService, TemplateMatchingResultDetail, MatchingStatistics,
|
||||
};
|
||||
use crate::data::models::template_matching_result::{
|
||||
TemplateMatchingResult, MatchingSegmentResult, MatchingFailedSegmentResult,
|
||||
CreateTemplateMatchingResultRequest, TemplateMatchingResultQueryOptions,
|
||||
MatchingResultStatus,
|
||||
};
|
||||
use crate::data::repositories::template_matching_result_repository::TemplateMatchingResultRepository;
|
||||
use crate::business::services::material_matching_service::{
|
||||
MaterialMatchingResult as ServiceMatchingResult, SegmentMatch, FailedSegmentMatch,
|
||||
MatchingStatistics as ServiceMatchingStatistics,
|
||||
};
|
||||
use crate::data::models::template::SegmentMatchingRule;
|
||||
use crate::data::models::material::MaterialSegment;
|
||||
use crate::infrastructure::database::Database;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use chrono::Utc;
|
||||
|
||||
/// 创建测试数据库
|
||||
async fn create_test_database() -> Arc<Database> {
|
||||
let database = Database::new().expect("Failed to create database");
|
||||
Arc::new(database)
|
||||
}
|
||||
|
||||
/// 创建测试用的素材匹配结果
|
||||
fn create_test_service_matching_result() -> ServiceMatchingResult {
|
||||
let material_segment = MaterialSegment {
|
||||
id: "segment-1".to_string(),
|
||||
material_id: "material-1".to_string(),
|
||||
segment_index: 0,
|
||||
start_time: 0.0,
|
||||
end_time: 10.0,
|
||||
duration: 10.0,
|
||||
file_path: "/test/video.mp4".to_string(),
|
||||
file_size: 1024,
|
||||
thumbnail_path: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let segment_match = SegmentMatch {
|
||||
track_segment_id: "track-segment-1".to_string(),
|
||||
track_segment_name: "测试轨道片段".to_string(),
|
||||
material_segment_id: "segment-1".to_string(),
|
||||
material_segment,
|
||||
material_name: "测试素材".to_string(),
|
||||
model_name: Some("测试模特".to_string()),
|
||||
match_score: 0.95,
|
||||
match_reason: "AI分类匹配".to_string(),
|
||||
};
|
||||
|
||||
let failed_segment = FailedSegmentMatch {
|
||||
track_segment_id: "track-segment-2".to_string(),
|
||||
track_segment_name: "失败轨道片段".to_string(),
|
||||
matching_rule: SegmentMatchingRule::AiClassification {
|
||||
category_id: "category-1".to_string(),
|
||||
category_name: "全身".to_string(),
|
||||
},
|
||||
failure_reason: "没有找到匹配的素材".to_string(),
|
||||
};
|
||||
|
||||
let statistics = ServiceMatchingStatistics {
|
||||
total_segments: 2,
|
||||
matched_segments: 1,
|
||||
failed_segments: 1,
|
||||
success_rate: 50.0,
|
||||
used_materials: 1,
|
||||
used_models: 1,
|
||||
};
|
||||
|
||||
ServiceMatchingResult {
|
||||
binding_id: "binding-1".to_string(),
|
||||
template_id: "template-1".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
matches: vec![segment_match],
|
||||
statistics,
|
||||
failed_segments: vec![failed_segment],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_matching_result() {
|
||||
let database = create_test_database().await;
|
||||
|
||||
// 先创建必要的依赖数据
|
||||
let conn = database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// 创建项目
|
||||
let unique_path = format!("/test/path/{}", uuid::Uuid::new_v4());
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, name, path, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
["project-1", "测试项目", &unique_path, "2024-01-01T00:00:00Z", "2024-01-01T00:00:00Z"],
|
||||
).unwrap();
|
||||
|
||||
// 创建模板
|
||||
conn.execute(
|
||||
"INSERT INTO templates (id, name, description, canvas_width, canvas_height, canvas_ratio, duration, fps, import_status, source_file_path, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
["template-1", "测试模板", "测试模板描述", "1920", "1080", "16:9", "10000000", "30.0", "Completed", "/test/template.json", "2024-01-01T00:00:00Z", "2024-01-01T00:00:00Z"],
|
||||
).unwrap();
|
||||
|
||||
// 创建绑定
|
||||
conn.execute(
|
||||
"INSERT INTO project_template_bindings (id, project_id, template_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
["binding-1", "project-1", "template-1", "2024-01-01T00:00:00Z", "2024-01-01T00:00:00Z"],
|
||||
).unwrap();
|
||||
|
||||
drop(conn);
|
||||
|
||||
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
|
||||
let service = TemplateMatchingResultService::new(repository);
|
||||
|
||||
let service_result = create_test_service_matching_result();
|
||||
let result_name = "测试匹配结果".to_string();
|
||||
let description = Some("这是一个测试匹配结果".to_string());
|
||||
let matching_duration_ms = 1500;
|
||||
|
||||
let result = service.save_matching_result(
|
||||
&service_result,
|
||||
result_name.clone(),
|
||||
description.clone(),
|
||||
matching_duration_ms,
|
||||
).await;
|
||||
|
||||
if let Err(e) = &result {
|
||||
println!("Error saving matching result: {}", e);
|
||||
}
|
||||
assert!(result.is_ok());
|
||||
let saved_result = result.unwrap();
|
||||
|
||||
assert_eq!(saved_result.result_name, result_name);
|
||||
assert_eq!(saved_result.description, description);
|
||||
assert_eq!(saved_result.project_id, service_result.project_id);
|
||||
assert_eq!(saved_result.template_id, service_result.template_id);
|
||||
assert_eq!(saved_result.binding_id, service_result.binding_id);
|
||||
assert_eq!(saved_result.total_segments, 2);
|
||||
assert_eq!(saved_result.matched_segments, 1);
|
||||
assert_eq!(saved_result.failed_segments, 1);
|
||||
assert_eq!(saved_result.success_rate, 50.0);
|
||||
assert_eq!(saved_result.used_materials, 1);
|
||||
assert_eq!(saved_result.used_models, 1);
|
||||
assert_eq!(saved_result.matching_duration_ms, matching_duration_ms);
|
||||
assert_eq!(saved_result.status, MatchingResultStatus::PartialSuccess);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_matching_result_detail() {
|
||||
let database = create_test_database().await;
|
||||
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
|
||||
let service = TemplateMatchingResultService::new(repository);
|
||||
|
||||
// 先保存一个匹配结果
|
||||
let service_result = create_test_service_matching_result();
|
||||
let saved_result = service.save_matching_result(
|
||||
&service_result,
|
||||
"测试匹配结果".to_string(),
|
||||
Some("测试描述".to_string()),
|
||||
1000,
|
||||
).await.unwrap();
|
||||
|
||||
// 获取详情
|
||||
let detail_result = service.get_matching_result_detail(&saved_result.id).await;
|
||||
assert!(detail_result.is_ok());
|
||||
|
||||
let detail = detail_result.unwrap();
|
||||
assert!(detail.is_some());
|
||||
|
||||
let detail = detail.unwrap();
|
||||
assert_eq!(detail.matching_result.id, saved_result.id);
|
||||
assert_eq!(detail.segment_results.len(), 1);
|
||||
assert_eq!(detail.failed_segment_results.len(), 1);
|
||||
|
||||
// 验证成功片段
|
||||
let segment_result = &detail.segment_results[0];
|
||||
assert_eq!(segment_result.track_segment_name, "测试轨道片段");
|
||||
assert_eq!(segment_result.material_name, "测试素材");
|
||||
assert_eq!(segment_result.model_name, Some("测试模特".to_string()));
|
||||
assert_eq!(segment_result.match_score, 0.95);
|
||||
|
||||
// 验证失败片段
|
||||
let failed_segment = &detail.failed_segment_results[0];
|
||||
assert_eq!(failed_segment.track_segment_name, "失败轨道片段");
|
||||
assert_eq!(failed_segment.failure_reason, "没有找到匹配的素材");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_project_matching_results() {
|
||||
let database = create_test_database().await;
|
||||
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
|
||||
let service = TemplateMatchingResultService::new(repository);
|
||||
|
||||
let project_id = "test-project-1";
|
||||
|
||||
// 保存两个匹配结果
|
||||
let mut service_result1 = create_test_service_matching_result();
|
||||
service_result1.project_id = project_id.to_string();
|
||||
|
||||
let mut service_result2 = create_test_service_matching_result();
|
||||
service_result2.project_id = project_id.to_string();
|
||||
service_result2.template_id = "template-2".to_string();
|
||||
|
||||
service.save_matching_result(&service_result1, "结果1".to_string(), None, 1000).await.unwrap();
|
||||
service.save_matching_result(&service_result2, "结果2".to_string(), None, 1500).await.unwrap();
|
||||
|
||||
// 获取项目的匹配结果
|
||||
let results = service.get_project_matching_results(project_id).await;
|
||||
assert!(results.is_ok());
|
||||
|
||||
let results = results.unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results.iter().all(|r| r.project_id == project_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_matching_result() {
|
||||
let database = create_test_database().await;
|
||||
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
|
||||
let service = TemplateMatchingResultService::new(repository);
|
||||
|
||||
// 保存一个匹配结果
|
||||
let service_result = create_test_service_matching_result();
|
||||
let saved_result = service.save_matching_result(
|
||||
&service_result,
|
||||
"待删除结果".to_string(),
|
||||
None,
|
||||
1000,
|
||||
).await.unwrap();
|
||||
|
||||
// 删除结果
|
||||
let delete_result = service.delete_matching_result(&saved_result.id).await;
|
||||
assert!(delete_result.is_ok());
|
||||
assert!(delete_result.unwrap());
|
||||
|
||||
// 验证已删除
|
||||
let detail_result = service.get_matching_result_detail(&saved_result.id).await;
|
||||
assert!(detail_result.is_ok());
|
||||
assert!(detail_result.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_soft_delete_matching_result() {
|
||||
let database = create_test_database().await;
|
||||
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
|
||||
let service = TemplateMatchingResultService::new(repository);
|
||||
|
||||
// 保存一个匹配结果
|
||||
let service_result = create_test_service_matching_result();
|
||||
let saved_result = service.save_matching_result(
|
||||
&service_result,
|
||||
"待软删除结果".to_string(),
|
||||
None,
|
||||
1000,
|
||||
).await.unwrap();
|
||||
|
||||
// 软删除结果
|
||||
let delete_result = service.soft_delete_matching_result(&saved_result.id).await;
|
||||
assert!(delete_result.is_ok());
|
||||
assert!(delete_result.unwrap());
|
||||
|
||||
// 验证在列表中不可见(软删除)
|
||||
let list_result = service.list_matching_results(TemplateMatchingResultQueryOptions::default()).await;
|
||||
assert!(list_result.is_ok());
|
||||
let results = list_result.unwrap();
|
||||
assert!(!results.iter().any(|r| r.id == saved_result.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_matching_result_info() {
|
||||
let database = create_test_database().await;
|
||||
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
|
||||
let service = TemplateMatchingResultService::new(repository);
|
||||
|
||||
// 保存一个匹配结果
|
||||
let service_result = create_test_service_matching_result();
|
||||
let saved_result = service.save_matching_result(
|
||||
&service_result,
|
||||
"原始名称".to_string(),
|
||||
Some("原始描述".to_string()),
|
||||
1000,
|
||||
).await.unwrap();
|
||||
|
||||
// 更新信息
|
||||
let new_name = "更新后的名称".to_string();
|
||||
let new_description = "更新后的描述".to_string();
|
||||
|
||||
let update_result = service.update_matching_result_info(
|
||||
&saved_result.id,
|
||||
Some(new_name.clone()),
|
||||
Some(new_description.clone()),
|
||||
).await;
|
||||
|
||||
assert!(update_result.is_ok());
|
||||
let updated_result = update_result.unwrap();
|
||||
assert!(updated_result.is_some());
|
||||
|
||||
let updated_result = updated_result.unwrap();
|
||||
assert_eq!(updated_result.result_name, new_name);
|
||||
assert_eq!(updated_result.description, Some(new_description));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_quality_score() {
|
||||
let database = create_test_database().await;
|
||||
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
|
||||
let service = TemplateMatchingResultService::new(repository);
|
||||
|
||||
// 保存一个匹配结果
|
||||
let service_result = create_test_service_matching_result();
|
||||
let saved_result = service.save_matching_result(
|
||||
&service_result,
|
||||
"质量评分测试".to_string(),
|
||||
None,
|
||||
1000,
|
||||
).await.unwrap();
|
||||
|
||||
// 设置质量评分
|
||||
let quality_score = 4.5;
|
||||
let score_result = service.set_quality_score(&saved_result.id, quality_score).await;
|
||||
|
||||
assert!(score_result.is_ok());
|
||||
let updated_result = score_result.unwrap();
|
||||
assert!(updated_result.is_some());
|
||||
|
||||
let updated_result = updated_result.unwrap();
|
||||
assert_eq!(updated_result.quality_score, Some(quality_score));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_matching_statistics() {
|
||||
let database = create_test_database().await;
|
||||
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
|
||||
let service = TemplateMatchingResultService::new(repository);
|
||||
|
||||
let project_id = "stats-test-project";
|
||||
|
||||
// 保存多个匹配结果
|
||||
for i in 0..3 {
|
||||
let mut service_result = create_test_service_matching_result();
|
||||
service_result.project_id = project_id.to_string();
|
||||
service_result.template_id = format!("template-{}", i);
|
||||
|
||||
service.save_matching_result(
|
||||
&service_result,
|
||||
format!("结果{}", i),
|
||||
None,
|
||||
1000 + i * 100,
|
||||
).await.unwrap();
|
||||
}
|
||||
|
||||
// 获取统计信息
|
||||
let stats_result = service.get_matching_statistics(Some(project_id)).await;
|
||||
assert!(stats_result.is_ok());
|
||||
|
||||
let stats = stats_result.unwrap();
|
||||
assert_eq!(stats.total_results, 3);
|
||||
assert_eq!(stats.successful_results, 3); // 所有结果都是部分成功
|
||||
assert_eq!(stats.total_segments, 6); // 每个结果2个片段
|
||||
assert_eq!(stats.matched_segments, 3); // 每个结果1个匹配片段
|
||||
assert_eq!(stats.total_materials, 3); // 每个结果1个素材
|
||||
assert_eq!(stats.total_models, 3); // 每个结果1个模特
|
||||
assert_eq!(stats.average_success_rate, 50.0); // 平均成功率50%
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_matching_results_with_filters() {
|
||||
let database = create_test_database().await;
|
||||
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
|
||||
let service = TemplateMatchingResultService::new(repository);
|
||||
|
||||
// 保存不同状态的匹配结果
|
||||
let mut service_result1 = create_test_service_matching_result();
|
||||
service_result1.statistics.success_rate = 100.0; // 成功
|
||||
service_result1.statistics.matched_segments = 2;
|
||||
service_result1.statistics.failed_segments = 0;
|
||||
|
||||
let service_result2 = create_test_service_matching_result(); // 部分成功
|
||||
|
||||
service.save_matching_result(&service_result1, "成功结果".to_string(), None, 1000).await.unwrap();
|
||||
service.save_matching_result(&service_result2, "部分成功结果".to_string(), None, 1500).await.unwrap();
|
||||
|
||||
// 测试状态过滤
|
||||
let options = TemplateMatchingResultQueryOptions {
|
||||
status: Some(MatchingResultStatus::Success),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let results = service.list_matching_results(options).await.unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].status, MatchingResultStatus::Success);
|
||||
|
||||
// 测试搜索关键词
|
||||
let options = TemplateMatchingResultQueryOptions {
|
||||
search_keyword: Some("成功".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let results = service.list_matching_results(options).await.unwrap();
|
||||
assert_eq!(results.len(), 2); // 两个结果都包含"成功"
|
||||
|
||||
// 测试分页
|
||||
let options = TemplateMatchingResultQueryOptions {
|
||||
limit: Some(1),
|
||||
offset: Some(0),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let results = service.list_matching_results(options).await.unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -242,7 +242,8 @@ impl MaterialRepository {
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, material_id, segment_index, start_time, end_time,
|
||||
duration, file_path, file_size, thumbnail_path, created_at
|
||||
duration, file_path, file_size, thumbnail_path,
|
||||
usage_count, is_used, last_used_at, created_at
|
||||
FROM material_segments WHERE material_id = ?1 ORDER BY segment_index"
|
||||
)?;
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use tauri::State;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::data::models::material_usage::{
|
||||
CreateMaterialUsageRecordRequest, MaterialUsageRecord, MaterialUsageStats,
|
||||
ProjectMaterialUsageOverview, MaterialUsageType
|
||||
CreateMaterialUsageRecordRequest, MaterialUsageRecord, MaterialUsageStats, MaterialUsageType,
|
||||
ProjectMaterialUsageOverview,
|
||||
};
|
||||
use crate::data::repositories::material_usage_repository::MaterialUsageRepository;
|
||||
use crate::app_state::AppState;
|
||||
|
||||
/// 创建素材使用记录
|
||||
#[tauri::command]
|
||||
@@ -18,7 +18,9 @@ pub async fn create_material_usage_record(
|
||||
let repo = MaterialUsageRepository::new(database);
|
||||
|
||||
// 验证请求
|
||||
request.validate().map_err(|e| format!("请求验证失败: {}", e))?;
|
||||
request
|
||||
.validate()
|
||||
.map_err(|e| format!("请求验证失败: {}", e))?;
|
||||
|
||||
repo.create_usage_record(request)
|
||||
.map_err(|e| format!("创建素材使用记录失败: {}", e))
|
||||
@@ -35,7 +37,9 @@ pub async fn create_material_usage_records_batch(
|
||||
|
||||
// 验证所有请求
|
||||
for request in &requests {
|
||||
request.validate().map_err(|e| format!("请求验证失败: {}", e))?;
|
||||
request
|
||||
.validate()
|
||||
.map_err(|e| format!("请求验证失败: {}", e))?;
|
||||
}
|
||||
|
||||
repo.create_usage_records_batch(requests)
|
||||
@@ -105,61 +109,73 @@ pub async fn create_usage_records_from_matching_result(
|
||||
template_matching_result_id: String,
|
||||
matches: Vec<serde_json::Value>, // 匹配结果的JSON格式
|
||||
) -> Result<Vec<MaterialUsageRecord>, String> {
|
||||
println!("🔍 开始创建素材使用记录");
|
||||
println!("📋 参数信息:");
|
||||
println!(" - project_id: {}", project_id);
|
||||
println!(" - template_id: {}", template_id);
|
||||
println!(" - binding_id: {}", binding_id);
|
||||
println!(" - template_matching_result_id: {}", template_matching_result_id);
|
||||
println!(" - matches 数量: {}", matches.len());
|
||||
|
||||
let database = state.get_database();
|
||||
let repo = MaterialUsageRepository::new(database);
|
||||
|
||||
let mut requests = Vec::new();
|
||||
|
||||
// 解析匹配结果并创建使用记录请求
|
||||
for match_value in matches {
|
||||
// 尝试解析匹配结果
|
||||
let segment_result = match_value.get("segment_result");
|
||||
let track_segment = match_value.get("track_segment");
|
||||
for (index, match_value) in matches.iter().enumerate() {
|
||||
println!("🔄 处理第 {} 个匹配结果", index + 1);
|
||||
println!("📄 原始匹配数据: {}", serde_json::to_string_pretty(match_value).unwrap_or_else(|_| "无法序列化".to_string()));
|
||||
// 直接从SegmentMatch结构中提取字段
|
||||
let material_segment_id = match_value
|
||||
.get("material_segment_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少material_segment_id字段")?;
|
||||
|
||||
if let (Some(segment_result), Some(track_segment)) = (segment_result, track_segment) {
|
||||
// 提取必要的字段
|
||||
let material_segment_id = segment_result.get("material_segment_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少material_segment_id字段")?;
|
||||
// 从material_segment对象中获取material_id
|
||||
let material_id = match_value
|
||||
.get("material_segment")
|
||||
.and_then(|segment| segment.get("material_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少material_id字段")?;
|
||||
|
||||
let material_id = segment_result.get("material_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少material_id字段")?;
|
||||
let track_segment_id = match_value
|
||||
.get("track_segment_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少track_segment_id字段")?;
|
||||
|
||||
let track_segment_id = track_segment.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少track_segment_id字段")?;
|
||||
let match_score = match_value
|
||||
.get("match_score")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let match_score = segment_result.get("match_score")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let match_reason = match_value
|
||||
.get("match_reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("模板匹配");
|
||||
|
||||
let match_reason = segment_result.get("match_reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("模板匹配");
|
||||
// 创建使用上下文
|
||||
let usage_context = serde_json::json!({
|
||||
"match_score": match_score,
|
||||
"match_reason": match_reason,
|
||||
"usage_timestamp": chrono::Utc::now().to_rfc3339()
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// 创建使用上下文
|
||||
let usage_context = serde_json::json!({
|
||||
"match_score": match_score,
|
||||
"match_reason": match_reason,
|
||||
"usage_timestamp": chrono::Utc::now().to_rfc3339()
|
||||
}).to_string();
|
||||
// 创建使用记录请求
|
||||
let request = CreateMaterialUsageRecordRequest {
|
||||
material_segment_id: material_segment_id.to_string(),
|
||||
material_id: material_id.to_string(),
|
||||
project_id: project_id.clone(),
|
||||
template_matching_result_id: template_matching_result_id.clone(),
|
||||
template_id: template_id.clone(),
|
||||
binding_id: binding_id.clone(),
|
||||
track_segment_id: track_segment_id.to_string(),
|
||||
usage_type: MaterialUsageType::TemplateMatching,
|
||||
usage_context: Some(usage_context),
|
||||
};
|
||||
|
||||
// 创建使用记录请求
|
||||
let request = CreateMaterialUsageRecordRequest {
|
||||
material_segment_id: material_segment_id.to_string(),
|
||||
material_id: material_id.to_string(),
|
||||
project_id: project_id.clone(),
|
||||
template_matching_result_id: template_matching_result_id.clone(),
|
||||
template_id: template_id.clone(),
|
||||
binding_id: binding_id.clone(),
|
||||
track_segment_id: track_segment_id.to_string(),
|
||||
usage_type: MaterialUsageType::TemplateMatching,
|
||||
usage_context: Some(usage_context),
|
||||
};
|
||||
|
||||
requests.push(request);
|
||||
}
|
||||
requests.push(request);
|
||||
}
|
||||
|
||||
if requests.is_empty() {
|
||||
@@ -182,7 +198,8 @@ pub async fn reset_material_segment_usage(
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// 开始事务
|
||||
let tx = conn.unchecked_transaction()
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| format!("开始事务失败: {}", e))?;
|
||||
|
||||
// 删除使用记录
|
||||
@@ -190,7 +207,8 @@ pub async fn reset_material_segment_usage(
|
||||
tx.execute(
|
||||
"DELETE FROM material_usage_records WHERE material_segment_id = ?1",
|
||||
[segment_id],
|
||||
).map_err(|e| format!("删除使用记录失败: {}", e))?;
|
||||
)
|
||||
.map_err(|e| format!("删除使用记录失败: {}", e))?;
|
||||
|
||||
// 重置素材片段状态
|
||||
tx.execute(
|
||||
@@ -202,7 +220,10 @@ pub async fn reset_material_segment_usage(
|
||||
// 提交事务
|
||||
tx.commit().map_err(|e| format!("提交事务失败: {}", e))?;
|
||||
|
||||
Ok(format!("成功重置 {} 个素材片段的使用状态", segment_ids.len()))
|
||||
Ok(format!(
|
||||
"成功重置 {} 个素材片段的使用状态",
|
||||
segment_ids.len()
|
||||
))
|
||||
}
|
||||
|
||||
/// 重置项目所有素材的使用状态
|
||||
@@ -216,25 +237,32 @@ pub async fn reset_project_material_usage(
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// 开始事务
|
||||
let tx = conn.unchecked_transaction()
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| format!("开始事务失败: {}", e))?;
|
||||
|
||||
// 删除项目的所有使用记录
|
||||
let deleted_records = tx.execute(
|
||||
"DELETE FROM material_usage_records WHERE project_id = ?1",
|
||||
[&project_id],
|
||||
).map_err(|e| format!("删除使用记录失败: {}", e))?;
|
||||
let deleted_records = tx
|
||||
.execute(
|
||||
"DELETE FROM material_usage_records WHERE project_id = ?1",
|
||||
[&project_id],
|
||||
)
|
||||
.map_err(|e| format!("删除使用记录失败: {}", e))?;
|
||||
|
||||
// 重置项目所有素材片段状态
|
||||
let updated_segments = tx.execute(
|
||||
"UPDATE material_segments SET usage_count = 0, is_used = 0, last_used_at = NULL
|
||||
let updated_segments = tx
|
||||
.execute(
|
||||
"UPDATE material_segments SET usage_count = 0, is_used = 0, last_used_at = NULL
|
||||
WHERE material_id IN (SELECT id FROM materials WHERE project_id = ?1)",
|
||||
[&project_id],
|
||||
).map_err(|e| format!("重置片段状态失败: {}", e))?;
|
||||
[&project_id],
|
||||
)
|
||||
.map_err(|e| format!("重置片段状态失败: {}", e))?;
|
||||
|
||||
// 提交事务
|
||||
tx.commit().map_err(|e| format!("提交事务失败: {}", e))?;
|
||||
|
||||
Ok(format!("成功重置项目素材使用状态:删除 {} 条使用记录,重置 {} 个素材片段",
|
||||
deleted_records, updated_segments))
|
||||
Ok(format!(
|
||||
"成功重置项目素材使用状态:删除 {} 条使用记录,重置 {} 个素材片段",
|
||||
deleted_records, updated_segments
|
||||
))
|
||||
}
|
||||
|
||||
@@ -26,14 +26,32 @@ pub async fn save_matching_result(
|
||||
matching_duration_ms: u64,
|
||||
database: State<'_, Arc<Database>>,
|
||||
) -> Result<TemplateMatchingResult, String> {
|
||||
println!("🚀 开始保存匹配结果");
|
||||
println!("📋 保存参数:");
|
||||
println!(" - result_name: {}", result_name);
|
||||
println!(" - description: {:?}", description);
|
||||
println!(" - matching_duration_ms: {}", matching_duration_ms);
|
||||
println!(" - project_id: {}", service_result.project_id);
|
||||
println!(" - template_id: {}", service_result.template_id);
|
||||
println!(" - binding_id: {}", service_result.binding_id);
|
||||
println!(" - matches 数量: {}", service_result.matches.len());
|
||||
println!(" - failed_segments 数量: {}", service_result.failed_segments.len());
|
||||
|
||||
// 创建服务实例
|
||||
let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
|
||||
let service = TemplateMatchingResultService::new(repository);
|
||||
|
||||
// 保存匹配结果
|
||||
service.save_matching_result(&service_result, result_name, description, matching_duration_ms)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
match service.save_matching_result(&service_result, result_name, description, matching_duration_ms).await {
|
||||
Ok(result) => {
|
||||
println!("✅ 匹配结果保存成功,ID: {}", result.id);
|
||||
Ok(result)
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ 匹配结果保存失败: {}", e);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取匹配结果详情
|
||||
|
||||
@@ -10,12 +10,15 @@ import { Model } from '../types/model';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
|
||||
import { MaterialThumbnail } from './MaterialThumbnail';
|
||||
import { MaterialUsageBadge } from './MaterialUsageStatus';
|
||||
import { useMaterialUsage } from '../hooks/useMaterialUsage';
|
||||
|
||||
interface MaterialCardProps {
|
||||
material: Material;
|
||||
onEdit?: (material: Material) => void;
|
||||
onDelete?: (materialId: string, materialName: string) => void;
|
||||
onReprocess?: (materialId: string) => void;
|
||||
onUsageReset?: () => void; // 使用状态重置后的回调
|
||||
}
|
||||
|
||||
// 格式化时间(秒转为 mm:ss 格式)
|
||||
@@ -64,9 +67,10 @@ const formatDate = (dateString: string): string => {
|
||||
* 素材卡片组件
|
||||
* 显示素材信息和切分片段
|
||||
*/
|
||||
export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit, onDelete, onReprocess }) => {
|
||||
export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit, onDelete, onReprocess, onUsageReset }) => {
|
||||
const { getMaterialSegments } = useMaterialStore();
|
||||
const { startClassification, isLoading: classificationLoading } = useVideoClassificationStore();
|
||||
const { usageStats } = useMaterialUsage();
|
||||
const [segments, setSegments] = useState<MaterialSegment[]>([]);
|
||||
const [showSegments, setShowSegments] = useState(false);
|
||||
const [loadingSegments, setLoadingSegments] = useState(false);
|
||||
@@ -78,6 +82,10 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit, on
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isReprocessing, setIsReprocessing] = useState(false);
|
||||
const [isResettingUsage, setIsResettingUsage] = useState(false);
|
||||
|
||||
// 获取素材的使用统计
|
||||
const materialUsageStats = usageStats.find(stats => stats.material_id === material.id);
|
||||
|
||||
|
||||
|
||||
@@ -262,6 +270,47 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit, on
|
||||
}
|
||||
};
|
||||
|
||||
// 处理重置使用状态
|
||||
const handleResetUsage = async () => {
|
||||
if (!materialUsageStats || materialUsageStats.used_segments === 0) return;
|
||||
|
||||
setIsResettingUsage(true);
|
||||
try {
|
||||
// 获取素材的所有片段ID
|
||||
const segmentIds = segments.map(segment => segment.id);
|
||||
|
||||
// 如果没有加载片段,先加载
|
||||
if (segmentIds.length === 0) {
|
||||
await loadSegments();
|
||||
// 重新获取片段ID
|
||||
const updatedSegments = await getMaterialSegments(material.id);
|
||||
const updatedSegmentIds = updatedSegments.map(segment => segment.id);
|
||||
|
||||
// 调用重置API
|
||||
await invoke('reset_material_segment_usage', {
|
||||
segmentIds: updatedSegmentIds
|
||||
});
|
||||
} else {
|
||||
// 调用重置API
|
||||
await invoke('reset_material_segment_usage', {
|
||||
segmentIds
|
||||
});
|
||||
}
|
||||
|
||||
console.log('素材使用状态重置成功');
|
||||
|
||||
// 通知父组件刷新使用状态数据
|
||||
if (onUsageReset) {
|
||||
onUsageReset();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('重置素材使用状态失败:', error);
|
||||
} finally {
|
||||
setIsResettingUsage(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-3 hover:shadow-md transition-shadow">
|
||||
{/* 素材基本信息 */}
|
||||
@@ -288,6 +337,14 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit, on
|
||||
<Calendar className="w-3 h-3" />
|
||||
{formatDate(material.created_at)}
|
||||
</span>
|
||||
{/* 使用状态显示 */}
|
||||
{materialUsageStats && (
|
||||
<MaterialUsageBadge
|
||||
usageCount={materialUsageStats.total_usage_count}
|
||||
isUsed={materialUsageStats.used_segments > 0}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -355,6 +412,23 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit, on
|
||||
AI分类
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 重置使用状态按钮 - 仅在有使用记录时显示 */}
|
||||
{materialUsageStats && materialUsageStats.used_segments > 0 && (
|
||||
<button
|
||||
onClick={handleResetUsage}
|
||||
disabled={isResettingUsage}
|
||||
className="text-xs px-2 py-1 bg-orange-50 text-orange-600 rounded hover:bg-orange-100 disabled:opacity-50 flex items-center gap-1"
|
||||
title="重置使用状态"
|
||||
>
|
||||
{isResettingUsage ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
)}
|
||||
重置使用
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 展开/折叠详细信息按钮 */}
|
||||
|
||||
214
apps/desktop/src/components/MaterialUsageStatus.tsx
Normal file
214
apps/desktop/src/components/MaterialUsageStatus.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
MaterialUsageStatusProps,
|
||||
defaultUsageStatusTheme,
|
||||
usageStatusLabels
|
||||
} from '../types/materialUsage';
|
||||
|
||||
/**
|
||||
* 素材使用状态显示组件
|
||||
* 显示素材片段的使用状态、使用次数等信息
|
||||
*/
|
||||
export const MaterialUsageStatus: React.FC<MaterialUsageStatusProps> = ({
|
||||
segment,
|
||||
showDetails = false,
|
||||
size = 'medium'
|
||||
}) => {
|
||||
// 确定使用状态
|
||||
const getUsageStatus = () => {
|
||||
if (!segment.is_used || segment.usage_count === 0) {
|
||||
return 'unused';
|
||||
} else if (segment.usage_count === 1) {
|
||||
return 'used';
|
||||
} else {
|
||||
return 'multiple';
|
||||
}
|
||||
};
|
||||
|
||||
const status = getUsageStatus();
|
||||
const theme = defaultUsageStatusTheme[status];
|
||||
|
||||
// 根据尺寸设置样式
|
||||
const sizeClasses = {
|
||||
small: 'px-2 py-1 text-xs',
|
||||
medium: 'px-3 py-1.5 text-sm',
|
||||
large: 'px-4 py-2 text-base'
|
||||
};
|
||||
|
||||
const iconSizeClasses = {
|
||||
small: 'w-3 h-3',
|
||||
medium: 'w-4 h-4',
|
||||
large: 'w-5 h-5'
|
||||
};
|
||||
|
||||
// 格式化最后使用时间
|
||||
const formatLastUsedTime = (timestamp?: string) => {
|
||||
if (!timestamp) return null;
|
||||
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态图标
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case 'unused':
|
||||
return (
|
||||
<svg className={`${iconSizeClasses[size]} ${theme.icon}`} fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
case 'used':
|
||||
return (
|
||||
<svg className={`${iconSizeClasses[size]} ${theme.icon}`} fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
case 'multiple':
|
||||
return (
|
||||
<svg className={`${iconSizeClasses[size]} ${theme.icon}`} fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* 主要状态标签 */}
|
||||
<div className={`
|
||||
inline-flex items-center gap-1.5 rounded-full border
|
||||
${theme.bg} ${theme.text} ${theme.border} ${sizeClasses[size]}
|
||||
font-medium
|
||||
`}>
|
||||
{getStatusIcon()}
|
||||
<span>{usageStatusLabels[status]}</span>
|
||||
{segment.usage_count > 0 && (
|
||||
<span className="ml-1 font-semibold">
|
||||
({segment.usage_count})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 详细信息 */}
|
||||
{showDetails && (
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
{segment.usage_count > 0 && segment.last_used_at && (
|
||||
<div>
|
||||
最后使用: {formatLastUsedTime(segment.last_used_at)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
片段时长: {segment.duration.toFixed(1)}秒
|
||||
</div>
|
||||
<div>
|
||||
文件大小: {(segment.file_size / 1024 / 1024).toFixed(1)}MB
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 简化版的使用状态徽章
|
||||
*/
|
||||
export const MaterialUsageBadge: React.FC<{
|
||||
usageCount: number;
|
||||
isUsed: boolean;
|
||||
size?: 'small' | 'medium';
|
||||
}> = ({ usageCount, isUsed, size = 'small' }) => {
|
||||
const status = !isUsed || usageCount === 0 ? 'unused' :
|
||||
usageCount === 1 ? 'used' : 'multiple';
|
||||
|
||||
const theme = defaultUsageStatusTheme[status];
|
||||
|
||||
const sizeClasses = size === 'small' ? 'px-2 py-0.5 text-xs' : 'px-2 py-1 text-sm';
|
||||
|
||||
return (
|
||||
<span className={`
|
||||
inline-flex items-center rounded-full
|
||||
${theme.bg} ${theme.text} ${theme.border} border
|
||||
${sizeClasses} font-medium
|
||||
`}>
|
||||
{usageCount > 0 ? usageCount : '未使用'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用状态进度条
|
||||
*/
|
||||
export const MaterialUsageProgress: React.FC<{
|
||||
totalSegments: number;
|
||||
usedSegments: number;
|
||||
className?: string;
|
||||
}> = ({ totalSegments, usedSegments, className = '' }) => {
|
||||
const usageRate = totalSegments > 0 ? (usedSegments / totalSegments) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>使用进度</span>
|
||||
<span>{usedSegments}/{totalSegments} ({usageRate.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${usageRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用状态统计卡片
|
||||
*/
|
||||
export const MaterialUsageStatsCard: React.FC<{
|
||||
title: string;
|
||||
value: number;
|
||||
total?: number;
|
||||
icon?: React.ReactNode;
|
||||
color?: 'blue' | 'green' | 'orange' | 'gray';
|
||||
}> = ({ title, value, total, icon, color = 'blue' }) => {
|
||||
const colorClasses = {
|
||||
blue: 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
green: 'bg-green-50 text-green-700 border-green-200',
|
||||
orange: 'bg-orange-50 text-orange-700 border-orange-200',
|
||||
gray: 'bg-gray-50 text-gray-700 border-gray-200'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-lg border ${colorClasses[color]}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium opacity-75">{title}</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{value}
|
||||
{total !== undefined && (
|
||||
<span className="text-sm font-normal opacity-75">/{total}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{icon && (
|
||||
<div className="opacity-75">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
199
apps/desktop/src/components/ProjectMaterialUsageOverview.tsx
Normal file
199
apps/desktop/src/components/ProjectMaterialUsageOverview.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ProjectMaterialUsageOverview, MaterialUsageStats } from '../types/materialUsage';
|
||||
import { MaterialUsageStatsCard, MaterialUsageProgress } from './MaterialUsageStatus';
|
||||
import { useResetUsageDialog, createResetDialog } from './ResetUsageDialog';
|
||||
|
||||
interface ProjectMaterialUsageOverviewProps {
|
||||
overview: ProjectMaterialUsageOverview;
|
||||
onRefresh?: () => void;
|
||||
onResetAll?: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目素材使用概览组件
|
||||
* 显示项目级别的素材使用统计和详细信息
|
||||
*/
|
||||
export const ProjectMaterialUsageOverviewComponent: React.FC<ProjectMaterialUsageOverviewProps> = ({
|
||||
overview,
|
||||
onRefresh,
|
||||
onResetAll,
|
||||
isLoading = false
|
||||
}) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const { openDialog, ResetDialog } = useResetUsageDialog();
|
||||
|
||||
// 处理重置确认
|
||||
const handleResetClick = () => {
|
||||
if (!onResetAll) return;
|
||||
|
||||
openDialog(createResetDialog.project(
|
||||
'当前项目',
|
||||
overview.total_materials,
|
||||
async () => {
|
||||
await onResetAll();
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">素材使用概览</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
{showDetails ? '隐藏详情' : '显示详情'}
|
||||
</button>
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1.5 text-sm text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-md transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
)}
|
||||
{onResetAll && (
|
||||
<button
|
||||
onClick={handleResetClick}
|
||||
className="px-3 py-1.5 text-sm text-red-600 hover:text-red-700 hover:bg-red-50 rounded-md transition-colors"
|
||||
>
|
||||
重置全部
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<MaterialUsageStatsCard
|
||||
title="总素材数"
|
||||
value={overview.total_materials}
|
||||
color="blue"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<MaterialUsageStatsCard
|
||||
title="总片段数"
|
||||
value={overview.total_segments}
|
||||
color="gray"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 111.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 111.414-1.414L15.586 13H14a1 1 0 01-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<MaterialUsageStatsCard
|
||||
title="已使用"
|
||||
value={overview.used_segments}
|
||||
total={overview.total_segments}
|
||||
color="green"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<MaterialUsageStatsCard
|
||||
title="使用次数"
|
||||
value={overview.total_usage_count}
|
||||
color="orange"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 使用进度条 */}
|
||||
<MaterialUsageProgress
|
||||
totalSegments={overview.total_segments}
|
||||
usedSegments={overview.used_segments}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* 详细信息 */}
|
||||
{showDetails && (
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">素材详细统计</h4>
|
||||
|
||||
{overview.materials_stats.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
暂无素材数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{overview.materials_stats.map((stats) => (
|
||||
<MaterialStatsRow key={stats.material_id} stats={stats} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 重置确认对话框 */}
|
||||
{ResetDialog}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 单个素材统计行组件
|
||||
*/
|
||||
const MaterialStatsRow: React.FC<{ stats: MaterialUsageStats }> = ({ stats }) => {
|
||||
const usageRate = stats.usage_rate * 100;
|
||||
|
||||
// 格式化最后使用时间
|
||||
const formatLastUsedTime = (timestamp?: string) => {
|
||||
if (!timestamp) return '从未使用';
|
||||
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return '时间格式错误';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h5 className="font-medium text-gray-900 truncate">{stats.material_name}</h5>
|
||||
<span className="text-sm text-gray-500">
|
||||
{stats.used_segments}/{stats.total_segments} ({usageRate.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||
<span>使用次数: {stats.total_usage_count}</span>
|
||||
<span>最后使用: {formatLastUsedTime(stats.last_used_at)}</span>
|
||||
</div>
|
||||
|
||||
{/* 小型进度条 */}
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-2">
|
||||
<div
|
||||
className="bg-blue-600 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${usageRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
218
apps/desktop/src/components/ResetUsageDialog.tsx
Normal file
218
apps/desktop/src/components/ResetUsageDialog.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AlertTriangle, RefreshCw, Loader2 } from 'lucide-react';
|
||||
|
||||
interface ResetUsageDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
title: string;
|
||||
description: string;
|
||||
resetType: 'material' | 'project' | 'segments';
|
||||
itemCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置使用状态确认对话框
|
||||
* 用于确认重置素材使用状态的操作
|
||||
*/
|
||||
export const ResetUsageDialog: React.FC<ResetUsageDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
resetType,
|
||||
itemCount
|
||||
}) => {
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsResetting(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('重置使用状态失败:', error);
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getResetTypeText = () => {
|
||||
switch (resetType) {
|
||||
case 'material':
|
||||
return '素材';
|
||||
case 'project':
|
||||
return '项目';
|
||||
case 'segments':
|
||||
return '片段';
|
||||
default:
|
||||
return '项目';
|
||||
}
|
||||
};
|
||||
|
||||
const getWarningLevel = () => {
|
||||
switch (resetType) {
|
||||
case 'project':
|
||||
return 'high';
|
||||
case 'material':
|
||||
return 'medium';
|
||||
case 'segments':
|
||||
return 'low';
|
||||
default:
|
||||
return 'medium';
|
||||
}
|
||||
};
|
||||
|
||||
const warningLevel = getWarningLevel();
|
||||
const warningColors = {
|
||||
high: {
|
||||
bg: 'bg-red-50',
|
||||
border: 'border-red-200',
|
||||
icon: 'text-red-500',
|
||||
button: 'bg-red-600 hover:bg-red-700'
|
||||
},
|
||||
medium: {
|
||||
bg: 'bg-orange-50',
|
||||
border: 'border-orange-200',
|
||||
icon: 'text-orange-500',
|
||||
button: 'bg-orange-600 hover:bg-orange-700'
|
||||
},
|
||||
low: {
|
||||
bg: 'bg-yellow-50',
|
||||
border: 'border-yellow-200',
|
||||
icon: 'text-yellow-500',
|
||||
button: 'bg-yellow-600 hover:bg-yellow-700'
|
||||
}
|
||||
};
|
||||
|
||||
const colors = warningColors[warningLevel];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center mb-4">
|
||||
<div className={`w-10 h-10 rounded-full ${colors.bg} ${colors.border} border flex items-center justify-center mr-3`}>
|
||||
<AlertTriangle className={`w-5 h-5 ${colors.icon}`} />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
</div>
|
||||
|
||||
{/* 描述内容 */}
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-600 mb-4">{description}</p>
|
||||
|
||||
{itemCount !== undefined && (
|
||||
<div className={`p-3 rounded-lg ${colors.bg} ${colors.border} border`}>
|
||||
<p className="text-sm font-medium text-gray-800">
|
||||
将要重置 <span className="font-bold">{itemCount}</span> 个{getResetTypeText()}的使用状态
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-gray-800 mb-2">重置后的影响:</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• 所有使用记录将被清除</li>
|
||||
<li>• 素材片段将重新变为可用状态</li>
|
||||
<li>• 模板匹配时可以重新选择这些素材</li>
|
||||
<li>• 此操作无法撤销</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isResetting}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-md transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={isResetting}
|
||||
className={`px-4 py-2 text-white rounded-md transition-colors disabled:opacity-50 flex items-center gap-2 ${colors.button}`}
|
||||
>
|
||||
{isResetting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
重置中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
确认重置
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 快速重置对话框的便捷函数
|
||||
*/
|
||||
export const createResetDialog = {
|
||||
material: (materialName: string, onConfirm: () => Promise<void>) => ({
|
||||
title: '重置素材使用状态',
|
||||
description: `确定要重置素材"${materialName}"的使用状态吗?`,
|
||||
resetType: 'material' as const,
|
||||
onConfirm
|
||||
}),
|
||||
|
||||
project: (projectName: string, materialCount: number, onConfirm: () => Promise<void>) => ({
|
||||
title: '重置项目使用状态',
|
||||
description: `确定要重置项目"${projectName}"中所有素材的使用状态吗?`,
|
||||
resetType: 'project' as const,
|
||||
itemCount: materialCount,
|
||||
onConfirm
|
||||
}),
|
||||
|
||||
segments: (segmentCount: number, onConfirm: () => Promise<void>) => ({
|
||||
title: '重置片段使用状态',
|
||||
description: '确定要重置选中片段的使用状态吗?',
|
||||
resetType: 'segments' as const,
|
||||
itemCount: segmentCount,
|
||||
onConfirm
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用重置对话框的Hook
|
||||
*/
|
||||
export const useResetUsageDialog = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [dialogProps, setDialogProps] = useState<Omit<ResetUsageDialogProps, 'isOpen' | 'onClose'> | null>(null);
|
||||
|
||||
const openDialog = (props: Omit<ResetUsageDialogProps, 'isOpen' | 'onClose'>) => {
|
||||
setDialogProps(props);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsOpen(false);
|
||||
setDialogProps(null);
|
||||
};
|
||||
|
||||
const ResetDialog = dialogProps ? (
|
||||
<ResetUsageDialog
|
||||
{...dialogProps}
|
||||
isOpen={isOpen}
|
||||
onClose={closeDialog}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return {
|
||||
openDialog,
|
||||
closeDialog,
|
||||
ResetDialog,
|
||||
isOpen
|
||||
};
|
||||
};
|
||||
242
apps/desktop/src/hooks/useMaterialUsage.ts
Normal file
242
apps/desktop/src/hooks/useMaterialUsage.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
MaterialUsageStats,
|
||||
ProjectMaterialUsageOverview,
|
||||
MaterialUsageRecord,
|
||||
CreateMaterialUsageRecordRequest,
|
||||
UseMaterialUsageReturn
|
||||
} from '../types/materialUsage';
|
||||
|
||||
/**
|
||||
* 素材使用状态管理的自定义Hook
|
||||
* 提供素材使用状态的数据获取和操作功能
|
||||
*/
|
||||
export const useMaterialUsage = (): UseMaterialUsageReturn => {
|
||||
// 状态管理
|
||||
const [usageStats, setUsageStats] = useState<MaterialUsageStats[]>([]);
|
||||
const [usageOverview, setUsageOverview] = useState<ProjectMaterialUsageOverview | null>(null);
|
||||
const [usageRecords, setUsageRecords] = useState<MaterialUsageRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 清除错误
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// 加载项目的素材使用统计
|
||||
const loadUsageStats = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const stats = await invoke<MaterialUsageStats[]>('get_project_material_usage_stats', {
|
||||
projectId
|
||||
});
|
||||
|
||||
setUsageStats(stats);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '获取素材使用统计失败';
|
||||
setError(errorMessage);
|
||||
console.error('加载素材使用统计失败:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 加载项目的素材使用概览
|
||||
const loadUsageOverview = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const overview = await invoke<ProjectMaterialUsageOverview>('get_project_material_usage_overview', {
|
||||
projectId
|
||||
});
|
||||
|
||||
setUsageOverview(overview);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '获取素材使用概览失败';
|
||||
setError(errorMessage);
|
||||
console.error('加载素材使用概览失败:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 加载项目的素材使用记录
|
||||
const loadUsageRecords = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const records = await invoke<MaterialUsageRecord[]>('get_project_material_usage_records', {
|
||||
projectId
|
||||
});
|
||||
|
||||
setUsageRecords(records);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '获取素材使用记录失败';
|
||||
setError(errorMessage);
|
||||
console.error('加载素材使用记录失败:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 创建素材使用记录
|
||||
const createUsageRecord = useCallback(async (request: CreateMaterialUsageRecordRequest) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
await invoke('create_material_usage_record', {
|
||||
request
|
||||
});
|
||||
|
||||
// 创建成功后,重新加载相关数据
|
||||
if (request.project_id) {
|
||||
await Promise.all([
|
||||
loadUsageStats(request.project_id),
|
||||
loadUsageOverview(request.project_id),
|
||||
loadUsageRecords(request.project_id)
|
||||
]);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '创建素材使用记录失败';
|
||||
setError(errorMessage);
|
||||
console.error('创建素材使用记录失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [loadUsageStats, loadUsageOverview, loadUsageRecords]);
|
||||
|
||||
// 重置素材片段使用状态
|
||||
const resetSegmentUsage = useCallback(async (segmentIds: string[]) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const result = await invoke<string>('reset_material_segment_usage', {
|
||||
segmentIds
|
||||
});
|
||||
|
||||
console.log('重置素材片段使用状态成功:', result);
|
||||
|
||||
// 重置成功后,需要重新加载数据
|
||||
// 注意:这里需要项目ID,但我们没有直接的方式获取
|
||||
// 在实际使用中,调用方应该手动刷新数据
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '重置素材片段使用状态失败';
|
||||
setError(errorMessage);
|
||||
console.error('重置素材片段使用状态失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 重置项目所有素材使用状态
|
||||
const resetProjectUsage = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const result = await invoke<string>('reset_project_material_usage', {
|
||||
projectId
|
||||
});
|
||||
|
||||
console.log('重置项目素材使用状态成功:', result);
|
||||
|
||||
// 重置成功后,重新加载所有相关数据
|
||||
await Promise.all([
|
||||
loadUsageStats(projectId),
|
||||
loadUsageOverview(projectId),
|
||||
loadUsageRecords(projectId)
|
||||
]);
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '重置项目素材使用状态失败';
|
||||
setError(errorMessage);
|
||||
console.error('重置项目素材使用状态失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [loadUsageStats, loadUsageOverview, loadUsageRecords]);
|
||||
|
||||
// 获取素材片段的使用状态
|
||||
const getSegmentUsageStatus = useCallback((segmentId: string): 'unused' | 'used' | 'multiple' => {
|
||||
const segmentRecords = usageRecords.filter(record => record.material_segment_id === segmentId);
|
||||
|
||||
if (segmentRecords.length === 0) {
|
||||
return 'unused';
|
||||
} else if (segmentRecords.length === 1) {
|
||||
return 'used';
|
||||
} else {
|
||||
return 'multiple';
|
||||
}
|
||||
}, [usageRecords]);
|
||||
|
||||
// 获取素材的使用率
|
||||
const getMaterialUsageRate = useCallback((materialId: string): number => {
|
||||
const materialStats = usageStats.find(stats => stats.material_id === materialId);
|
||||
return materialStats?.usage_rate || 0;
|
||||
}, [usageStats]);
|
||||
|
||||
return {
|
||||
// 数据
|
||||
usageStats,
|
||||
usageOverview,
|
||||
usageRecords,
|
||||
|
||||
// 状态
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// 操作
|
||||
loadUsageStats,
|
||||
loadUsageOverview,
|
||||
loadUsageRecords,
|
||||
createUsageRecord,
|
||||
resetSegmentUsage,
|
||||
resetProjectUsage,
|
||||
|
||||
// 工具方法
|
||||
getSegmentUsageStatus,
|
||||
getMaterialUsageRate,
|
||||
clearError
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 简化版的Hook,只获取特定项目的使用概览
|
||||
*/
|
||||
export const useProjectUsageOverview = (projectId: string) => {
|
||||
const [overview, setOverview] = useState<ProjectMaterialUsageOverview | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await invoke<ProjectMaterialUsageOverview>('get_project_material_usage_overview', {
|
||||
projectId
|
||||
});
|
||||
|
||||
setOverview(result);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '获取项目使用概览失败';
|
||||
setError(errorMessage);
|
||||
console.error('加载项目使用概览失败:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
return {
|
||||
overview,
|
||||
isLoading,
|
||||
error,
|
||||
loadOverview,
|
||||
clearError: () => setError(null)
|
||||
};
|
||||
};
|
||||
@@ -35,6 +35,8 @@ import { MaterialSegmentStats } from '../components/MaterialSegmentStats';
|
||||
import { MaterialSegmentViewMode } from '../types/materialSegmentView';
|
||||
import { TemplateMatchingResultManager } from '../components/TemplateMatchingResultManager';
|
||||
import { useNotifications } from '../components/NotificationSystem';
|
||||
import { ProjectMaterialUsageOverviewComponent } from '../components/ProjectMaterialUsageOverview';
|
||||
import { useMaterialUsage } from '../hooks/useMaterialUsage';
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateString: string) => {
|
||||
@@ -80,6 +82,13 @@ export const ProjectDetails: React.FC = () => {
|
||||
getProjectQueueStatus
|
||||
} = useVideoClassificationStore();
|
||||
const { success: addNotification } = useNotifications();
|
||||
const {
|
||||
usageOverview,
|
||||
loadUsageOverview,
|
||||
resetProjectUsage,
|
||||
isLoading: usageLoading,
|
||||
error: usageError
|
||||
} = useMaterialUsage();
|
||||
|
||||
// 模板绑定状态管理
|
||||
const {
|
||||
@@ -110,7 +119,7 @@ export const ProjectDetails: React.FC = () => {
|
||||
const [editingBinding, setEditingBinding] = useState<ProjectTemplateBindingDetail | null>(null);
|
||||
const [showMaterialEditDialog, setShowMaterialEditDialog] = useState(false);
|
||||
const [editingMaterial, setEditingMaterial] = useState<Material | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'materials' | 'segments' | 'templates' | 'matching-results' | 'ai-logs'>('overview');
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'materials' | 'segments' | 'templates' | 'matching-results' | 'usage-stats' | 'ai-logs'>('overview');
|
||||
const [_batchClassificationResult, setBatchClassificationResult] = useState<ProjectBatchClassificationResponse | null>(null);
|
||||
|
||||
// 素材匹配状态
|
||||
@@ -161,6 +170,8 @@ export const ProjectDetails: React.FC = () => {
|
||||
bindingActions.fetchTemplatesByProject(foundProject.id);
|
||||
// 加载片段统计数据
|
||||
loadSegmentStats(foundProject.id);
|
||||
// 加载素材使用状态概览
|
||||
loadUsageOverview(foundProject.id);
|
||||
}
|
||||
}
|
||||
}, [id, projects, loadMaterials, loadMaterialStats, bindingActions.fetchTemplatesByProject, loadSegmentStats]);
|
||||
@@ -388,6 +399,7 @@ export const ProjectDetails: React.FC = () => {
|
||||
matchingDurationMs: 0 // 使用默认值
|
||||
});
|
||||
|
||||
console.log({savedResult})
|
||||
// 创建素材使用记录
|
||||
if (savedResult && typeof savedResult === 'object' && 'id' in savedResult) {
|
||||
try {
|
||||
@@ -491,6 +503,22 @@ export const ProjectDetails: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 重置项目使用状态函数
|
||||
const handleResetProjectUsage = async () => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
await resetProjectUsage(project.id);
|
||||
addNotification('项目素材使用状态已重置', 'success');
|
||||
|
||||
// 重新加载使用状态数据
|
||||
loadUsageOverview(project.id);
|
||||
} catch (error) {
|
||||
console.error('重置项目使用状态失败:', error);
|
||||
addNotification('重置项目使用状态失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -676,6 +704,22 @@ export const ProjectDetails: React.FC = () => {
|
||||
<span className="sm:hidden">匹配</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('usage-stats')}
|
||||
className={`py-3 px-4 font-medium text-sm transition-all duration-200 whitespace-nowrap rounded-t-lg relative ${
|
||||
activeTab === 'usage-stats'
|
||||
? 'text-primary-600 border-b-2 border-primary-500'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">使用状态</span>
|
||||
<span className="sm:hidden">使用</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('ai-logs')}
|
||||
className={`py-3 px-4 font-medium text-sm transition-all duration-200 whitespace-nowrap rounded-t-lg relative ${
|
||||
@@ -838,6 +882,7 @@ export const ProjectDetails: React.FC = () => {
|
||||
onEdit={handleEditMaterial}
|
||||
onDelete={handleDeleteMaterial}
|
||||
onReprocess={handleReprocessMaterial}
|
||||
onUsageReset={() => project && loadUsageOverview(project.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -932,6 +977,63 @@ export const ProjectDetails: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 素材使用状态选项卡 */}
|
||||
{activeTab === 'usage-stats' && project && (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">素材使用状态</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
查看项目中素材的使用情况,包括使用统计、使用历史和状态管理。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{usageError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">加载使用状态失败</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p>{usageError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usageOverview ? (
|
||||
<ProjectMaterialUsageOverviewComponent
|
||||
overview={usageOverview}
|
||||
onRefresh={() => loadUsageOverview(project.id)}
|
||||
onResetAll={() => handleResetProjectUsage()}
|
||||
isLoading={usageLoading}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-20 h-20 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-10 h-10 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-xl font-medium text-gray-900 mb-2">暂无使用数据</h4>
|
||||
<p className="text-gray-500 mb-6 max-w-sm mx-auto">
|
||||
开始进行模板匹配并应用结果,系统将自动记录素材使用状态
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('templates')}
|
||||
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
前往模板绑定
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI分析日志选项卡 */}
|
||||
{activeTab === 'ai-logs' && project && (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
|
||||
219
apps/desktop/src/types/materialUsage.ts
Normal file
219
apps/desktop/src/types/materialUsage.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 素材使用状态管理相关类型定义
|
||||
* 对应后端的MaterialUsage数据模型
|
||||
*/
|
||||
|
||||
export enum MaterialUsageType {
|
||||
TemplateMatching = 'TemplateMatching',
|
||||
ManualEdit = 'ManualEdit',
|
||||
Other = 'Other'
|
||||
}
|
||||
|
||||
export interface MaterialUsageRecord {
|
||||
id: string;
|
||||
material_segment_id: string;
|
||||
material_id: string;
|
||||
project_id: string;
|
||||
template_matching_result_id: string;
|
||||
template_id: string;
|
||||
binding_id: string;
|
||||
track_segment_id: string;
|
||||
usage_type: MaterialUsageType;
|
||||
usage_context?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MaterialUsageStats {
|
||||
material_id: string;
|
||||
material_name: string;
|
||||
total_segments: number;
|
||||
used_segments: number;
|
||||
unused_segments: number;
|
||||
total_usage_count: number;
|
||||
last_used_at?: string;
|
||||
usage_rate: number; // 使用率 (used_segments / total_segments)
|
||||
}
|
||||
|
||||
export interface ProjectMaterialUsageOverview {
|
||||
project_id: string;
|
||||
total_materials: number;
|
||||
total_segments: number;
|
||||
used_segments: number;
|
||||
unused_segments: number;
|
||||
total_usage_count: number;
|
||||
overall_usage_rate: number;
|
||||
materials_stats: MaterialUsageStats[];
|
||||
}
|
||||
|
||||
export interface CreateMaterialUsageRecordRequest {
|
||||
material_segment_id: string;
|
||||
material_id: string;
|
||||
project_id: string;
|
||||
template_matching_result_id: string;
|
||||
template_id: string;
|
||||
binding_id: string;
|
||||
track_segment_id: string;
|
||||
usage_type: MaterialUsageType;
|
||||
usage_context?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材片段使用状态(扩展原有的MaterialSegment类型)
|
||||
*/
|
||||
export interface MaterialSegmentWithUsage {
|
||||
id: string;
|
||||
material_id: string;
|
||||
segment_index: number;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
duration: number;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
thumbnail_path?: string;
|
||||
usage_count: number;
|
||||
is_used: boolean;
|
||||
last_used_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材使用状态显示组件的Props
|
||||
*/
|
||||
export interface MaterialUsageStatusProps {
|
||||
segment: MaterialSegmentWithUsage;
|
||||
showDetails?: boolean;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材使用统计卡片组件的Props
|
||||
*/
|
||||
export interface MaterialUsageStatsCardProps {
|
||||
stats: MaterialUsageStats;
|
||||
onViewDetails?: (materialId: string) => void;
|
||||
onResetUsage?: (materialId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目素材使用概览组件的Props
|
||||
*/
|
||||
export interface ProjectUsageOverviewProps {
|
||||
overview: ProjectMaterialUsageOverview;
|
||||
onRefresh?: () => void;
|
||||
onResetAll?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材使用历史记录组件的Props
|
||||
*/
|
||||
export interface MaterialUsageHistoryProps {
|
||||
materialId?: string;
|
||||
projectId: string;
|
||||
records: MaterialUsageRecord[];
|
||||
isLoading?: boolean;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用状态过滤选项
|
||||
*/
|
||||
export interface UsageStatusFilter {
|
||||
showAll: boolean;
|
||||
showUsed: boolean;
|
||||
showUnused: boolean;
|
||||
minUsageCount?: number;
|
||||
maxUsageCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材使用状态管理的Hook返回类型
|
||||
*/
|
||||
export interface UseMaterialUsageReturn {
|
||||
// 数据
|
||||
usageStats: MaterialUsageStats[];
|
||||
usageOverview: ProjectMaterialUsageOverview | null;
|
||||
usageRecords: MaterialUsageRecord[];
|
||||
|
||||
// 状态
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// 操作
|
||||
loadUsageStats: (projectId: string) => Promise<void>;
|
||||
loadUsageOverview: (projectId: string) => Promise<void>;
|
||||
loadUsageRecords: (projectId: string) => Promise<void>;
|
||||
createUsageRecord: (request: CreateMaterialUsageRecordRequest) => Promise<void>;
|
||||
resetSegmentUsage: (segmentIds: string[]) => Promise<void>;
|
||||
resetProjectUsage: (projectId: string) => Promise<void>;
|
||||
|
||||
// 工具方法
|
||||
getSegmentUsageStatus: (segmentId: string) => 'unused' | 'used' | 'multiple';
|
||||
getMaterialUsageRate: (materialId: string) => number;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材使用状态的颜色主题
|
||||
*/
|
||||
export interface UsageStatusTheme {
|
||||
unused: {
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
icon: string;
|
||||
};
|
||||
used: {
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
icon: string;
|
||||
};
|
||||
multiple: {
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
icon: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认的使用状态主题
|
||||
*/
|
||||
export const defaultUsageStatusTheme: UsageStatusTheme = {
|
||||
unused: {
|
||||
bg: 'bg-green-50',
|
||||
text: 'text-green-700',
|
||||
border: 'border-green-200',
|
||||
icon: 'text-green-500'
|
||||
},
|
||||
used: {
|
||||
bg: 'bg-gray-50',
|
||||
text: 'text-gray-700',
|
||||
border: 'border-gray-200',
|
||||
icon: 'text-gray-500'
|
||||
},
|
||||
multiple: {
|
||||
bg: 'bg-orange-50',
|
||||
text: 'text-orange-700',
|
||||
border: 'border-orange-200',
|
||||
icon: 'text-orange-500'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用状态标签的文本映射
|
||||
*/
|
||||
export const usageStatusLabels = {
|
||||
unused: '未使用',
|
||||
used: '已使用',
|
||||
multiple: '多次使用'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 使用类型的中文标签映射
|
||||
*/
|
||||
export const usageTypeLabels = {
|
||||
[MaterialUsageType.TemplateMatching]: '模板匹配',
|
||||
[MaterialUsageType.ManualEdit]: '手动编辑',
|
||||
[MaterialUsageType.Other]: '其他'
|
||||
} as const;
|
||||
Reference in New Issue
Block a user