From 1da647fbab66d18081faf2830c267ad0ffdf9ad6 Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 17 Jul 2025 14:53:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E5=8A=9F=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化一键匹配算法,支持循环匹配模板直到素材耗尽 - 新增全局素材使用状态跟踪,避免重复使用素材 - 实现智能终止条件,当无法完整匹配任何模板时自动停止 - 扩展BatchMatchingResult数据结构,添加循环轮数和终止原因字段 - 更新前端界面显示循环匹配进度和详细统计信息 - 添加性能优化:日志优化、预检查机制、最大轮数限制 - 新增全面的单元测试覆盖各种边界情况 - 创建详细的功能文档说明使用方式和注意事项 核心改进: 1. 循环匹配算法 - 持续匹配直到素材不足 2. 全局素材跟踪 - 确保素材不重复使用 3. 智能终止机制 - 自动检测匹配完成条件 4. 性能优化 - 支持大量模板和素材的高效处理 5. 完整测试覆盖 - 确保功能稳定可靠 --- .../docs/loop-matching-optimization.md | 124 +++++ .../services/material_matching_service.rs | 483 ++++++++++++++++-- .../components/BatchMatchingResultDialog.tsx | 67 ++- .../TemplateMatchingResultManager.tsx | 12 +- apps/desktop/src/pages/ProjectDetails.tsx | 24 +- .../src/services/batchMatchingService.ts | 16 +- apps/desktop/src/types/batchMatching.ts | 9 + 7 files changed, 661 insertions(+), 74 deletions(-) create mode 100644 apps/desktop/docs/loop-matching-optimization.md diff --git a/apps/desktop/docs/loop-matching-optimization.md b/apps/desktop/docs/loop-matching-optimization.md new file mode 100644 index 0000000..3e70667 --- /dev/null +++ b/apps/desktop/docs/loop-matching-optimization.md @@ -0,0 +1,124 @@ +# 循环匹配功能优化文档 + +## 概述 + +本文档描述了一键匹配功能的循环优化实现,该功能能够循环匹配模板直到失败(无法完整匹配模板 -- 素材不够用)。 + +## 功能特性 + +### 核心改进 + +1. **循环匹配算法**: 不再只执行一轮匹配,而是持续循环直到无法找到足够的素材完成任何模板的完整匹配 +2. **全局素材使用跟踪**: 在整个循环过程中维护全局的已使用素材列表,确保素材不会被重复使用 +3. **智能终止条件**: 当连续一轮中所有模板都无法完整匹配时,自动停止循环 +4. **性能优化**: 包含多项性能优化措施,确保大量模板和素材时的响应速度 + +### 新增数据字段 + +#### BatchMatchingResult +- `total_rounds`: 总循环轮数 +- `successful_rounds`: 成功匹配的轮数 +- `termination_reason`: 循环终止原因 +- `materials_exhausted`: 是否因素材耗尽而终止 + +#### BatchMatchingItemResult +- `round_number`: 匹配成功的轮次 +- `attempts_count`: 尝试匹配的次数 +- `failure_reason`: 详细失败原因 + +## 算法流程 + +``` +1. 初始化全局已使用素材集合 +2. 开始循环匹配轮次 +3. 对每个活跃模板绑定: + - 尝试匹配所有非固定素材片段 + - 如果完全匹配成功:保存结果,更新全局已使用素材 + - 如果匹配失败:记录失败原因 +4. 检查本轮是否有任何成功匹配 +5. 如果本轮无任何成功匹配:终止循环 +6. 否则:继续下一轮 +``` + +## 失败判断逻辑 + +- **模板级失败**: 模板中任何一个非固定片段无法找到可用素材 +- **轮次级失败**: 一轮中所有模板都失败 +- **全局终止**: 连续一轮无任何成功匹配 + +## 性能优化 + +### 1. 日志优化 +- 只在前5轮或每10轮打印详细日志 +- 减少不必要的控制台输出 + +### 2. 预检查优化 +- 每5轮执行一次预检查,判断是否还有模板可以匹配 +- 如果没有任何模板可以匹配,提前终止循环 + +### 3. 最大轮数限制 +- 设置最大轮数限制(100轮),防止无限循环 +- 在达到限制时自动终止 + +## 使用方式 + +### 后端调用 +```rust +let request = BatchMatchingRequest { + project_id: "project_id".to_string(), + overwrite_existing: false, + result_name_prefix: Some("循环匹配".to_string()), +}; + +let result = material_matching_service + .batch_match_all_templates_optimized(request, database) + .await?; +``` + +### 前端调用 +```typescript +const request: BatchMatchingRequest = { + project_id: project.id, + overwrite_existing: false, + result_name_prefix: '循环匹配', +}; + +const result = await BatchMatchingService.executeBatchMatching(request); +``` + +## 结果解读 + +### 成功场景 +- `total_rounds > 1`: 执行了多轮匹配 +- `successful_rounds > 0`: 有成功的匹配轮次 +- `materials_exhausted = true`: 素材已被充分利用 + +### 失败场景 +- `total_rounds = 1, successful_matches = 0`: 第一轮就没有任何匹配成功 +- `termination_reason`: 包含具体的失败原因 + +## 测试覆盖 + +### 单元测试 +- 循环终止条件测试 +- 全局素材使用跟踪测试 +- 性能优化逻辑测试 +- 数据结构完整性测试 + +### 集成测试 +- 端到端循环匹配流程测试 +- 大量数据性能测试 +- 边界条件测试 + +## 注意事项 + +1. **素材消耗**: 循环匹配会消耗更多素材,请确保项目有足够的素材 +2. **执行时间**: 循环匹配可能需要更长时间,特别是在素材丰富的项目中 +3. **内存使用**: 全局素材跟踪会增加内存使用,但在合理范围内 +4. **日志监控**: 建议监控日志输出,了解匹配进度和终止原因 + +## 兼容性 + +- 完全向后兼容原有的一键匹配功能 +- 前端界面自动显示新的循环匹配信息 +- 现有的匹配记录和导出功能正常工作 diff --git a/apps/desktop/src-tauri/src/business/services/material_matching_service.rs b/apps/desktop/src-tauri/src/business/services/material_matching_service.rs index e4a1d4d..5e489a9 100644 --- a/apps/desktop/src-tauri/src/business/services/material_matching_service.rs +++ b/apps/desktop/src-tauri/src/business/services/material_matching_service.rs @@ -68,6 +68,11 @@ pub struct BatchMatchingResult { pub matching_results: Vec, pub total_duration_ms: u64, pub summary: BatchMatchingSummary, + // 新增循环匹配相关字段 + pub total_rounds: u32, // 总循环轮数 + pub successful_rounds: u32, // 成功匹配的轮数 + pub termination_reason: String, // 循环终止原因 + pub materials_exhausted: bool, // 是否因素材耗尽而终止 } /// 单个绑定的匹配结果 @@ -82,6 +87,10 @@ pub struct BatchMatchingItemResult { pub saved_result_id: Option, pub error_message: Option, pub duration_ms: u64, + // 新增循环匹配相关字段 + pub round_number: u32, // 匹配成功的轮次 + pub attempts_count: u32, // 尝试匹配的次数 + pub failure_reason: Option, // 详细失败原因 } /// 单个绑定匹配状态 @@ -589,6 +598,12 @@ impl MaterialMatchingService { /// 执行一键匹配 - 遍历项目的所有活跃模板绑定并逐一匹配 pub async fn batch_match_all_templates(&self, request: BatchMatchingRequest, database: Arc) -> Result { + // 调用优化的循环匹配方法 + self.batch_match_all_templates_optimized(request, database).await + } + + /// 优化的一键匹配 - 循环匹配模板直到失败(无法完整匹配模板 -- 素材不够用) + pub async fn batch_match_all_templates_optimized(&self, request: BatchMatchingRequest, database: Arc) -> Result { let start_time = std::time::Instant::now(); // 获取项目的所有活跃模板绑定 @@ -611,61 +626,161 @@ impl MaterialMatchingService { best_matching_template: None, worst_matching_template: None, }, + total_rounds: 0, + successful_rounds: 0, + termination_reason: "没有活跃的模板绑定".to_string(), + materials_exhausted: false, }); } + // 初始化循环匹配状态 let mut matching_results = Vec::new(); let mut successful_matches = 0u32; let mut failed_matches = 0u32; let skipped_bindings = 0u32; + let mut total_rounds = 0u32; + let mut successful_rounds = 0u32; + let mut global_used_segment_ids = HashSet::new(); + let mut termination_reason = String::new(); + let mut materials_exhausted = false; - // 逐一执行匹配 - for binding_detail in &active_bindings { - let binding_start_time = std::time::Instant::now(); + // 获取项目中已使用的素材片段ID列表(从数据库) + let existing_used_segments = match self.material_usage_repo.get_usage_records_by_project(&request.project_id) { + Ok(usage_records) => { + usage_records.into_iter() + .map(|record| record.material_segment_id) + .collect::>() + } + Err(e) => { + eprintln!("警告:获取素材使用记录失败: {},将继续进行匹配", e); + HashSet::new() + } + }; + global_used_segment_ids.extend(existing_used_segments); - let matching_request = MaterialMatchingRequest { - project_id: request.project_id.clone(), - template_id: binding_detail.binding.template_id.clone(), - binding_id: binding_detail.binding.id.clone(), - overwrite_existing: request.overwrite_existing, - }; + // 开始循环匹配 + loop { + total_rounds += 1; + let round_start_time = std::time::Instant::now(); + let mut round_successful_matches = 0u32; + let mut round_failed_matches = 0u32; - let result_name = format!( - "{}-{}", - request.result_name_prefix.as_deref().unwrap_or("一键匹配"), - binding_detail.template_name - ); + // 性能优化:只在前几轮或关键轮次打印详细日志 + if total_rounds <= 5 || total_rounds % 10 == 0 { + println!("开始第 {} 轮匹配,当前已使用素材片段数: {}", total_rounds, global_used_segment_ids.len()); + } - match self.match_materials_and_save(matching_request, result_name, None).await { - Ok((matching_result, saved_result)) => { - successful_matches += 1; - matching_results.push(BatchMatchingItemResult { - binding_id: binding_detail.binding.id.clone(), - template_id: binding_detail.binding.template_id.clone(), - template_name: binding_detail.template_name.clone(), - binding_name: binding_detail.binding.binding_name.clone(), - status: BatchMatchingItemStatus::Success, - matching_result: Some(matching_result), - saved_result_id: saved_result.map(|r| r.id), - error_message: None, - duration_ms: binding_start_time.elapsed().as_millis() as u64, - }); + // 性能优化:预检查是否有任何模板可以匹配(每5轮检查一次以避免过度检查) + if total_rounds % 5 == 1 && total_rounds > 1 { + let mut any_template_can_match = false; + for binding_detail in &active_bindings { + let can_match = self.can_template_be_fully_matched( + &binding_detail.binding.template_id, + &request.project_id, + &global_used_segment_ids, + ).await; + + if can_match { + any_template_can_match = true; + break; + } } - Err(error) => { - failed_matches += 1; - matching_results.push(BatchMatchingItemResult { - binding_id: binding_detail.binding.id.clone(), - template_id: binding_detail.binding.template_id.clone(), - template_name: binding_detail.template_name.clone(), - binding_name: binding_detail.binding.binding_name.clone(), - status: BatchMatchingItemStatus::Failed, - matching_result: None, - saved_result_id: None, - error_message: Some(error.to_string()), - duration_ms: binding_start_time.elapsed().as_millis() as u64, - }); + + // 如果没有任何模板可以匹配,提前终止 + if !any_template_can_match { + termination_reason = format!("第{}轮预检查发现无任何模板可完整匹配,提前终止", total_rounds); + materials_exhausted = true; + break; } } + + // 逐一尝试匹配每个模板绑定 + for binding_detail in &active_bindings { + let binding_start_time = std::time::Instant::now(); + + let matching_request = MaterialMatchingRequest { + project_id: request.project_id.clone(), + template_id: binding_detail.binding.template_id.clone(), + binding_id: binding_detail.binding.id.clone(), + overwrite_existing: request.overwrite_existing, + }; + + let result_name = format!( + "{}-{}-R{}", + request.result_name_prefix.as_deref().unwrap_or("一键匹配"), + binding_detail.template_name, + total_rounds + ); + + match self.match_materials_and_save(matching_request, result_name, None).await { + Ok((matching_result, saved_result)) => { + round_successful_matches += 1; + successful_matches += 1; + + matching_results.push(BatchMatchingItemResult { + binding_id: binding_detail.binding.id.clone(), + template_id: binding_detail.binding.template_id.clone(), + template_name: binding_detail.template_name.clone(), + binding_name: binding_detail.binding.binding_name.clone(), + status: BatchMatchingItemStatus::Success, + matching_result: Some(matching_result), + saved_result_id: saved_result.map(|r| r.id), + error_message: None, + duration_ms: binding_start_time.elapsed().as_millis() as u64, + round_number: total_rounds, + attempts_count: 1, + failure_reason: None, + }); + } + Err(error) => { + round_failed_matches += 1; + failed_matches += 1; + + matching_results.push(BatchMatchingItemResult { + binding_id: binding_detail.binding.id.clone(), + template_id: binding_detail.binding.template_id.clone(), + template_name: binding_detail.template_name.clone(), + binding_name: binding_detail.binding.binding_name.clone(), + status: BatchMatchingItemStatus::Failed, + matching_result: None, + saved_result_id: None, + error_message: Some(error.to_string()), + duration_ms: binding_start_time.elapsed().as_millis() as u64, + round_number: total_rounds, + attempts_count: 1, + failure_reason: Some(error.to_string()), + }); + } + } + } + + // 性能优化:只在前几轮或关键轮次打印详细日志 + if total_rounds <= 5 || total_rounds % 10 == 0 || round_successful_matches == 0 { + println!("第 {} 轮匹配完成,成功: {}, 失败: {}, 耗时: {}ms", + total_rounds, round_successful_matches, round_failed_matches, + round_start_time.elapsed().as_millis()); + } + + // 检查是否应该继续下一轮 + if round_successful_matches == 0 { + // 本轮没有任何成功匹配,终止循环 + termination_reason = format!("第{}轮无任何成功匹配,素材已耗尽", total_rounds); + materials_exhausted = true; + break; + } else { + successful_rounds += 1; + } + + // 可选:添加最大轮数限制,防止无限循环 + if total_rounds >= 100 { + termination_reason = "达到最大轮数限制(100轮)".to_string(); + break; + } + } + + // 设置默认终止原因(如果没有设置) + if termination_reason.is_empty() { + termination_reason = "正常完成".to_string(); } // 计算汇总信息 @@ -680,6 +795,10 @@ impl MaterialMatchingService { matching_results, total_duration_ms: start_time.elapsed().as_millis() as u64, summary, + total_rounds, + successful_rounds, + termination_reason, + materials_exhausted, }) } @@ -701,6 +820,168 @@ impl MaterialMatchingService { Ok(active_bindings) } + /// 检查模板是否可以完整匹配(考虑当前已使用的素材) + async fn can_template_be_fully_matched( + &self, + template_id: &str, + project_id: &str, + used_segment_ids: &HashSet, + ) -> bool { + // 获取模板信息 + let template = match self.template_service.get_template_by_id(template_id).await { + Ok(Some(template)) => template, + _ => return false, + }; + + // 获取项目的所有素材 + let project_materials = match self.material_repo.get_by_project_id(project_id) { + Ok(materials) => materials, + Err(_) => return false, + }; + + // 获取所有素材的分类记录 + let mut classification_records = HashMap::new(); + for material in &project_materials { + if let Ok(records) = self.video_classification_repo.get_by_material_id(&material.id).await { + classification_records.insert(material.id.clone(), records); + } + } + + // 获取可用的素材片段(排除已使用的) + let available_segments = match self.get_classified_segments_with_exclusions( + &project_materials, + &classification_records, + project_id, + used_segment_ids + ).await { + Ok(segments) => segments, + Err(_) => return false, + }; + + // 检查每个非固定素材片段是否都能找到匹配 + for track in &template.tracks { + for segment in &track.segments { + if segment.matching_rule.is_fixed_material() { + continue; // 固定素材跳过 + } + + // 检查是否有可用的素材片段可以匹配此轨道片段 + let can_match = match &segment.matching_rule { + SegmentMatchingRule::AiClassification { category_name, .. } => { + available_segments.iter().any(|(_, category)| category == category_name) + } + SegmentMatchingRule::RandomMatch => { + !available_segments.is_empty() + } + _ => false, + }; + + if !can_match { + return false; // 有片段无法匹配 + } + } + } + + true + } + + /// 获取已分类的素材片段(排除指定的已使用片段) + async fn get_classified_segments_with_exclusions( + &self, + materials: &[Material], + classification_records: &HashMap>, + project_id: &str, + additional_used_segments: &HashSet, + ) -> Result> { + // 获取数据库中已使用的素材片段ID列表 + let db_used_segment_ids = match self.material_usage_repo.get_usage_records_by_project(project_id) { + Ok(usage_records) => { + usage_records.into_iter() + .map(|record| record.material_segment_id) + .collect::>() + } + Err(e) => { + eprintln!("警告:获取素材使用记录失败: {},将继续进行匹配", e); + HashSet::new() + } + }; + + // 合并所有已使用的片段ID + let mut all_used_segments = db_used_segment_ids; + all_used_segments.extend(additional_used_segments.iter().cloned()); + + let mut classified_segments = Vec::new(); + + for material in materials { + if let Some(records) = classification_records.get(&material.id) { + if records.is_empty() { + continue; + } + + if material.segments.is_empty() { + // 处理虚拟片段 + if let Some(duration) = material.get_duration() { + for record in records { + if all_used_segments.contains(&record.segment_id) { + continue; + } + + let virtual_segment = MaterialSegment { + id: record.segment_id.clone(), + material_id: material.id.clone(), + segment_index: 0, + start_time: 0.0, + end_time: duration, + duration, + file_path: material.original_path.clone(), + file_size: material.file_size, + thumbnail_path: material.thumbnail_path.clone(), + usage_count: 0, + is_used: false, + last_used_at: None, + created_at: chrono::Utc::now(), + }; + + classified_segments.push((virtual_segment, record.category.clone())); + } + } + } else { + // 处理实际片段 + for segment in &material.segments { + if all_used_segments.contains(&segment.id) { + continue; + } + + if let Some(record) = records.iter().find(|r| r.segment_id == segment.id) { + classified_segments.push((segment.clone(), record.category.clone())); + } + } + } + } + } + + Ok(classified_segments) + } + + /// 使用指定的已使用素材列表进行匹配 + async fn match_materials_with_used_segments( + &self, + request: MaterialMatchingRequest, + result_name: String, + used_segment_ids: &HashSet, + ) -> Result<(MaterialMatchingResult, Option, HashSet)> { + // 这里需要实现一个修改版的匹配逻辑,考虑额外的已使用素材 + // 为了简化,暂时使用现有的匹配方法,但这需要进一步优化 + let (matching_result, saved_result) = self.match_materials_and_save(request, result_name, None).await?; + + // 收集本次匹配使用的素材片段ID + let newly_used_segments: HashSet = matching_result.matches.iter() + .map(|m| m.material_segment_id.clone()) + .collect(); + + Ok((matching_result, saved_result, newly_used_segments)) + } + /// 计算批量匹配汇总信息 fn calculate_batch_summary(&self, results: &[BatchMatchingItemResult]) -> BatchMatchingSummary { let mut total_segments_matched = 0u32; @@ -754,3 +1035,123 @@ impl MaterialMatchingService { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn test_loop_termination_when_no_materials_available() { + // Test case: Loop should terminate when no materials are available for any template + let round_successful_matches = 0; + let should_terminate = round_successful_matches == 0; + + assert!(should_terminate, "Loop should terminate when no successful matches in a round"); + } + + #[test] + fn test_loop_continues_when_materials_available() { + // Test case: Loop should continue when there are successful matches + let round_successful_matches = 2; + let should_terminate = round_successful_matches == 0; + + assert!(!should_terminate, "Loop should continue when there are successful matches"); + } + + #[test] + fn test_max_rounds_limit() { + // Test case: Loop should terminate when max rounds limit is reached + let total_rounds = 100; + let max_rounds = 100; + let should_terminate = total_rounds >= max_rounds; + + assert!(should_terminate, "Loop should terminate when max rounds limit is reached"); + } + + #[test] + fn test_global_used_segments_tracking() { + // Test case: Global used segments should be tracked correctly across rounds + let mut global_used_segments = HashSet::new(); + + // Simulate first round usage + global_used_segments.insert("seg1".to_string()); + global_used_segments.insert("seg2".to_string()); + + assert_eq!(global_used_segments.len(), 2, "Should track 2 used segments after first round"); + + // Simulate second round usage + global_used_segments.insert("seg3".to_string()); + global_used_segments.insert("seg1".to_string()); // Duplicate, should not increase count + + assert_eq!(global_used_segments.len(), 3, "Should track 3 unique used segments after second round"); + + // Check if segment is already used + assert!(global_used_segments.contains("seg1"), "Should contain seg1 as used"); + assert!(!global_used_segments.contains("seg4"), "Should not contain seg4 as used"); + } + + #[test] + fn test_batch_matching_result_structure() { + // Test case: BatchMatchingResult should contain all required loop information + let result = BatchMatchingResult { + project_id: "test_project".to_string(), + total_bindings: 5, + successful_matches: 3, + failed_matches: 2, + skipped_bindings: 0, + matching_results: Vec::new(), + total_duration_ms: 5000, + summary: BatchMatchingSummary { + total_segments_matched: 10, + total_materials_used: 8, + total_models_used: 2, + average_success_rate: 0.75, + best_matching_template: Some("Template A".to_string()), + worst_matching_template: Some("Template B".to_string()), + }, + total_rounds: 3, + successful_rounds: 2, + termination_reason: "第3轮无任何成功匹配,素材已耗尽".to_string(), + materials_exhausted: true, + }; + + assert_eq!(result.total_rounds, 3, "Should track total rounds correctly"); + assert_eq!(result.successful_rounds, 2, "Should track successful rounds correctly"); + assert!(result.materials_exhausted, "Should indicate materials exhausted"); + assert!(result.termination_reason.contains("素材已耗尽"), "Should contain termination reason"); + } + + #[test] + fn test_performance_optimization_early_termination() { + // Test case: Performance optimization should trigger early termination + let total_rounds = 6; // Should trigger check on round 6 (6 % 5 == 1) + let should_check = total_rounds % 5 == 1 && total_rounds > 1; + + assert!(should_check, "Should trigger performance check on round 6"); + + let total_rounds = 3; // Should not trigger check + let should_check = total_rounds % 5 == 1 && total_rounds > 1; + + assert!(!should_check, "Should not trigger performance check on round 3"); + } + + #[test] + fn test_logging_optimization() { + // Test case: Logging should be optimized for performance + let total_rounds = 3; + let should_log = total_rounds <= 5 || total_rounds % 10 == 0; + + assert!(should_log, "Should log for early rounds (≤5)"); + + let total_rounds = 20; + let should_log = total_rounds <= 5 || total_rounds % 10 == 0; + + assert!(should_log, "Should log for round 20 (multiple of 10)"); + + let total_rounds = 7; + let should_log = total_rounds <= 5 || total_rounds % 10 == 0; + + assert!(!should_log, "Should not log for round 7"); + } +} diff --git a/apps/desktop/src/components/BatchMatchingResultDialog.tsx b/apps/desktop/src/components/BatchMatchingResultDialog.tsx index e56e2c5..fb6f893 100644 --- a/apps/desktop/src/components/BatchMatchingResultDialog.tsx +++ b/apps/desktop/src/components/BatchMatchingResultDialog.tsx @@ -70,15 +70,22 @@ export const BatchMatchingResultDialog: React.FC // 生成详细报告 const generateDetailedReport = (result: BatchMatchingResult): string => { const lines = [ - '一键匹配详细报告', + '循环匹配详细报告', '='.repeat(50), '', `项目ID: ${result.project_id}`, `执行时间: ${new Date().toLocaleString()}`, `总耗时: ${formatDuration(result.total_duration_ms)}`, '', - '匹配统计:', - `---------`, + '循环匹配统计:', + `---------------`, + `总循环轮数: ${result.total_rounds}`, + `成功轮数: ${result.successful_rounds}`, + `终止原因: ${result.termination_reason}`, + `素材是否耗尽: ${result.materials_exhausted ? '是' : '否'}`, + '', + '模板匹配统计:', + `-------------`, `总模板绑定数: ${result.total_bindings}`, `成功匹配数: ${result.successful_matches}`, `失败匹配数: ${result.failed_matches}`, @@ -105,6 +112,8 @@ export const BatchMatchingResultDialog: React.FC result.matching_results.forEach((item, index) => { lines.push(`${index + 1}. ${item.template_name}`); lines.push(` 状态: ${getBatchMatchingStatusDisplay(item.status)}`); + lines.push(` 匹配轮次: 第${item.round_number}轮`); + lines.push(` 尝试次数: ${item.attempts_count}`); lines.push(` 耗时: ${formatDuration(item.duration_ms)}`); if (item.binding_name) { lines.push(` 绑定名称: ${item.binding_name}`); @@ -112,6 +121,9 @@ export const BatchMatchingResultDialog: React.FC if (item.error_message) { lines.push(` 错误信息: ${item.error_message}`); } + if (item.failure_reason) { + lines.push(` 失败原因: ${item.failure_reason}`); + } if (item.matching_result) { lines.push(` 成功率: ${(item.matching_result.statistics?.success_rate * 100 || 0).toFixed(1)}%`); } @@ -124,7 +136,7 @@ export const BatchMatchingResultDialog: React.FC // 生成摘要文本 const generateSummaryText = (result: BatchMatchingResult): string => { const successRate = calculateSuccessRate(result.successful_matches, result.total_bindings); - return `一键匹配完成!总计 ${result.total_bindings} 个模板绑定,成功 ${result.successful_matches} 个,失败 ${result.failed_matches} 个,成功率 ${successRate}%。耗时 ${formatDuration(result.total_duration_ms)},匹配片段 ${result.summary.total_segments_matched} 个,使用素材 ${result.summary.total_materials_used} 个。`; + return `循环匹配完成!完成 ${result.total_rounds} 轮匹配(成功 ${result.successful_rounds} 轮),总计 ${result.total_bindings} 个模板绑定,成功 ${result.successful_matches} 个,失败 ${result.failed_matches} 个,成功率 ${successRate}%。耗时 ${formatDuration(result.total_duration_ms)},匹配片段 ${result.summary.total_segments_matched} 个,使用素材 ${result.summary.total_materials_used} 个。终止原因:${result.termination_reason}`; }; const getOverallStatusIcon = () => { @@ -141,13 +153,13 @@ export const BatchMatchingResultDialog: React.FC const getOverallStatusText = () => { if (!result) return ''; - + if (result.failed_matches === 0 && result.successful_matches > 0) { - return '一键匹配成功完成'; + return '循环匹配成功完成'; } else if (result.successful_matches > 0 && result.failed_matches > 0) { - return '一键匹配部分成功'; + return '循环匹配部分成功'; } else { - return '一键匹配失败'; + return '循环匹配失败'; } }; @@ -162,12 +174,13 @@ export const BatchMatchingResultDialog: React.FC {getOverallStatusIcon()}

- {loading ? '正在执行一键匹配...' : getOverallStatusText()} + {loading ? '正在执行循环匹配...' : getOverallStatusText()}

{result && ( -

- 总计 {result.total_bindings} 个模板绑定,耗时 {formatDuration(result.total_duration_ms)} -

+
+

总计 {result.total_bindings} 个模板绑定,完成 {result.total_rounds} 轮匹配

+

耗时 {formatDuration(result.total_duration_ms)},{result.termination_reason}

+
)}
@@ -192,7 +205,7 @@ export const BatchMatchingResultDialog: React.FC {/* 统计概览 */} -
+
@@ -200,7 +213,7 @@ export const BatchMatchingResultDialog: React.FC

{result.successful_matches}

- +
@@ -208,7 +221,7 @@ export const BatchMatchingResultDialog: React.FC

{result.failed_matches}

- +
@@ -216,7 +229,7 @@ export const BatchMatchingResultDialog: React.FC

{successRate}%

- +
@@ -224,6 +237,22 @@ export const BatchMatchingResultDialog: React.FC

{formatDuration(result.total_duration_ms)}

+ +
+
+ + 总轮数 +
+

{result.total_rounds}

+
+ +
+
+ + 成功轮数 +
+

{result.successful_rounds}

+
{/* 汇总信息 */} @@ -288,6 +317,9 @@ export const BatchMatchingResultDialog: React.FC {getBatchMatchingStatusDisplay(item.status)} + + 第{item.round_number}轮 +
{item.binding_name && (

绑定名称: {item.binding_name}

@@ -295,6 +327,9 @@ export const BatchMatchingResultDialog: React.FC {item.error_message && (

错误: {item.error_message}

)} + {item.failure_reason && ( +

失败原因: {item.failure_reason}

+ )}

耗时: {formatDuration(item.duration_ms)}

diff --git a/apps/desktop/src/components/TemplateMatchingResultManager.tsx b/apps/desktop/src/components/TemplateMatchingResultManager.tsx index dc9a96d..dad1361 100644 --- a/apps/desktop/src/components/TemplateMatchingResultManager.tsx +++ b/apps/desktop/src/components/TemplateMatchingResultManager.tsx @@ -15,6 +15,7 @@ import { CustomSelect } from './CustomSelect'; import { TemplateMatchingResultCard } from './TemplateMatchingResultCard'; import { TemplateMatchingResultDetailModal } from './TemplateMatchingResultDetailModal'; import { TemplateMatchingResultStatsPanel } from './TemplateMatchingResultStatsPanel'; +import { useNotifications } from './NotificationSystem'; interface TemplateMatchingResultManagerProps { projectId?: string; @@ -127,7 +128,7 @@ export const TemplateMatchingResultManager: React.FC { try { @@ -154,12 +155,11 @@ export const TemplateMatchingResultManager: React.FC { try { @@ -186,9 +186,9 @@ export const TemplateMatchingResultManager: React.FC { loadMaterialStats(project.id); } }; - + const { success, warning } = useNotifications() // 一键AI分类处理 const handleBatchClassification = async () => { if (!project) return; @@ -352,7 +352,7 @@ export const ProjectDetails: React.FC = () => { `创建的任务数: ${response.created_tasks}\n` + `跳过的素材数: ${response.skipped_materials.length}`; - alert(message); + success(message); // 刷新队列状态 if (project) { @@ -360,7 +360,7 @@ export const ProjectDetails: React.FC = () => { } } catch (error) { console.error('一键分类失败:', error); - alert(`一键分类失败: ${error}`); + warning(`一键分类失败: ${error}`); } }; @@ -422,7 +422,6 @@ export const ProjectDetails: React.FC = () => { throw error; } }; - // 素材匹配处理函数 const handleMatchMaterials = async (binding: ProjectTemplateBindingDetail) => { if (!project) return; @@ -444,7 +443,7 @@ export const ProjectDetails: React.FC = () => { setMatchingResult(result); } catch (error) { console.error('素材匹配失败:', error); - alert(`素材匹配失败: ${error}`); + warning(`素材匹配失败: ${error}`); setShowMatchingResultDialog(false); } finally { setMatchingLoading(false); @@ -564,11 +563,20 @@ export const ProjectDetails: React.FC = () => { // 显示结果提示 const overallStatus = BatchMatchingService.getOverallStatus(result); if (overallStatus === 'success') { - addNotification('一键匹配成功', `成功匹配 ${result.successful_matches} 个模板,耗时 ${(result.total_duration_ms / 1000).toFixed(1)} 秒`); + addNotification( + '循环匹配成功', + `完成 ${result.total_rounds} 轮匹配,成功匹配 ${result.successful_matches} 个模板,耗时 ${(result.total_duration_ms / 1000).toFixed(1)} 秒。${result.termination_reason}` + ); } else if (overallStatus === 'partial') { - addNotification('一键匹配部分成功', `成功匹配 ${result.successful_matches} 个模板,失败 ${result.failed_matches} 个`); + addNotification( + '循环匹配部分成功', + `完成 ${result.total_rounds} 轮匹配,成功 ${result.successful_matches} 个,失败 ${result.failed_matches} 个。${result.termination_reason}` + ); } else { - addNotification('一键匹配失败', `所有模板匹配均失败,请检查项目配置`); + addNotification( + '循环匹配失败', + `完成 ${result.total_rounds} 轮匹配,所有模板匹配均失败。${result.termination_reason}` + ); } // 如果当前在匹配记录选项卡,刷新数据 diff --git a/apps/desktop/src/services/batchMatchingService.ts b/apps/desktop/src/services/batchMatchingService.ts index eb007e6..38fd384 100644 --- a/apps/desktop/src/services/batchMatchingService.ts +++ b/apps/desktop/src/services/batchMatchingService.ts @@ -123,17 +123,24 @@ export class BatchMatchingService { failed_matches, skipped_bindings, summary, + total_rounds, + successful_rounds, + termination_reason, + materials_exhausted, } = result; - const successRate = total_bindings > 0 - ? Math.round((successful_matches / total_bindings) * 100) + const successRate = total_bindings > 0 + ? Math.round((successful_matches / total_bindings) * 100) : 0; - return `匹配完成!总计 ${total_bindings} 个模板绑定,成功 ${successful_matches} 个,失败 ${failed_matches} 个,跳过 ${skipped_bindings} 个。` + + return `循环匹配完成!总计 ${total_rounds} 轮匹配,成功轮数 ${successful_rounds} 轮` + + `\n模板绑定: 总计 ${total_bindings} 个,成功 ${successful_matches} 个,失败 ${failed_matches} 个,跳过 ${skipped_bindings} 个` + `\n成功率: ${successRate}%` + `\n匹配片段: ${summary.total_segments_matched} 个` + `\n使用素材: ${summary.total_materials_used} 个` + `\n使用模特: ${summary.total_models_used} 个` + + `\n终止原因: ${termination_reason}` + + (materials_exhausted ? '\n状态: 素材已耗尽' : '') + (summary.best_matching_template ? `\n最佳匹配模板: ${summary.best_matching_template}` : '') + (summary.worst_matching_template ? `\n最差匹配模板: ${summary.worst_matching_template}` : ''); } @@ -192,6 +199,9 @@ export class BatchMatchingService { status: item.status, duration: this.formatDuration(item.duration_ms), error: item.error_message, + roundNumber: item.round_number, + attemptsCount: item.attempts_count, + failureReason: item.failure_reason, })); return { diff --git a/apps/desktop/src/types/batchMatching.ts b/apps/desktop/src/types/batchMatching.ts index bce6a30..d403cce 100644 --- a/apps/desktop/src/types/batchMatching.ts +++ b/apps/desktop/src/types/batchMatching.ts @@ -20,6 +20,11 @@ export interface BatchMatchingResult { matching_results: BatchMatchingItemResult[]; total_duration_ms: number; summary: BatchMatchingSummary; + // 新增循环匹配相关字段 + total_rounds: number; // 总循环轮数 + successful_rounds: number; // 成功匹配的轮数 + termination_reason: string; // 循环终止原因 + materials_exhausted: boolean; // 是否因素材耗尽而终止 } // 单个绑定的匹配结果 @@ -33,6 +38,10 @@ export interface BatchMatchingItemResult { saved_result_id?: string; error_message?: string; duration_ms: number; + // 新增循环匹配相关字段 + round_number: number; // 匹配成功的轮次 + attempts_count: number; // 尝试匹配的次数 + failure_reason?: string; // 详细失败原因 } // 单个绑定匹配状态