fix: 修复保存 匹配记录的bug

This commit is contained in:
imeepos
2025-07-16 16:09:55 +08:00
parent a325b3ccc8
commit 5d39ddea80
13 changed files with 1420 additions and 505 deletions

View File

@@ -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,17 +44,25 @@ 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();
for segment_match in &service_result.matches {
used_material_ids.insert(segment_match.material_segment.material_id.clone());
if let Some(model_name) = &segment_match.model_name {
@@ -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,

View File

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

View File

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

View File

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

View File

@@ -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,67 +109,79 @@ 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");
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字段")?;
let material_id = segment_result.get("material_id")
.and_then(|v| v.as_str())
.ok_or("缺少material_id字段")?;
let track_segment_id = track_segment.get("id")
.and_then(|v| v.as_str())
.ok_or("缺少track_segment_id字段")?;
let match_score = segment_result.get("match_score")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
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 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);
}
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字段")?;
// 从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 track_segment_id = match_value
.get("track_segment_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_reason = match_value
.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 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);
}
if requests.is_empty() {
return Ok(Vec::new());
}
// 批量创建使用记录
repo.create_usage_records_batch(requests)
.map_err(|e| format!("从匹配结果创建使用记录失败: {}", e))
@@ -180,29 +196,34 @@ pub async fn reset_material_segment_usage(
let database = state.get_database();
let conn = database.get_connection();
let conn = conn.lock().unwrap();
// 开始事务
let tx = conn.unchecked_transaction()
let tx = conn
.unchecked_transaction()
.map_err(|e| format!("开始事务失败: {}", e))?;
// 删除使用记录
for segment_id in &segment_ids {
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(
"UPDATE material_segments SET usage_count = 0, is_used = 0, last_used_at = NULL WHERE id = ?1",
[segment_id],
).map_err(|e| format!("重置片段状态失败: {}", e))?;
}
// 提交事务
tx.commit().map_err(|e| format!("提交事务失败: {}", e))?;
Ok(format!("成功重置 {} 个素材片段的使用状态", segment_ids.len()))
Ok(format!(
"成功重置 {} 个素材片段的使用状态",
segment_ids.len()
))
}
/// 重置项目所有素材的使用状态
@@ -214,27 +235,34 @@ pub async fn reset_project_material_usage(
let database = state.get_database();
let conn = database.get_connection();
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
))
}

View File

@@ -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())
}
}
}
/// 获取匹配结果详情

View File

@@ -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>
{/* 展开/折叠详细信息按钮 */}

View 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>
);
};

View 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>
);
};

View 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
};
};

View 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)
};
};

View File

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

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