From bab1dfc5fd1faf9d83405294d4846ada82d01a22 Mon Sep 17 00:00:00 2001 From: imeepos Date: Tue, 15 Jul 2025 14:56:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E5=8A=9F=E8=83=BD=20v0.1.19?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增素材匹配服务 (MaterialMatchingService) - 支持AI分类匹配、随机匹配等规则 - 实现模特限制逻辑(每个模特素材只能使用一次) - 时长匹配优化(相差越小越好) - 详细的匹配统计和失败原因分析 - 新增Tauri API命令 - execute_material_matching: 执行素材匹配 - get_project_material_stats_for_matching: 获取项目素材统计 - validate_template_binding_for_matching: 验证模板绑定 - 新增前端组件和服务 - MaterialMatchingResultDialog: 匹配结果对话框 - MaterialMatchingService: 前端服务层 - 完整的TypeScript类型定义 - UI集成 - 在模板绑定列表添加匹配素材按钮 - 集成到项目详情页面 - 支持完整的匹配流程和结果展示 - 核心匹配规则 - 只使用已AI分类的MaterialSegment - 每个素材只能使用一次 - 模特限制:优先同一模特,失败后尝试其他模特 - 视频时长必须大于模板需求,相差越小匹配度越高 - 测试覆盖 - 后端服务单元测试 - 覆盖正常匹配、失败场景、边界情况 --- .../services/material_matching_service.rs | 392 ++++++++++++++++ .../src-tauri/src/business/services/mod.rs | 1 + .../tests/material_matching_service_tests.rs | 290 ++++++++++++ .../src/business/services/tests/mod.rs | 1 + .../src-tauri/src/data/models/material.rs | 22 + apps/desktop/src-tauri/src/lib.rs | 4 + .../commands/material_matching_commands.rs | 147 ++++++ .../src/presentation/commands/mod.rs | 1 + .../MaterialMatchingResultDialog.tsx | 435 ++++++++++++++++++ .../components/ProjectTemplateBindingList.tsx | 32 +- apps/desktop/src/pages/ProjectDetails.tsx | 79 ++++ .../src/services/materialMatchingService.ts | 261 +++++++++++ apps/desktop/src/types/materialMatching.ts | 158 +++++++ 13 files changed, 1815 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src-tauri/src/business/services/material_matching_service.rs create mode 100644 apps/desktop/src-tauri/src/business/services/tests/material_matching_service_tests.rs create mode 100644 apps/desktop/src-tauri/src/presentation/commands/material_matching_commands.rs create mode 100644 apps/desktop/src/components/MaterialMatchingResultDialog.tsx create mode 100644 apps/desktop/src/services/materialMatchingService.ts create mode 100644 apps/desktop/src/types/materialMatching.ts 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 new file mode 100644 index 0000000..a203843 --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/material_matching_service.rs @@ -0,0 +1,392 @@ +/** + * 素材匹配服务 + * 遵循 Tauri 开发规范的业务逻辑层设计原则 + */ + +use crate::data::models::{ + material::{Material, MaterialSegment}, + template::{Template, TrackSegment, SegmentMatchingRule}, + video_classification::VideoClassificationRecord, +}; +use crate::data::repositories::{ + material_repository::MaterialRepository, + video_classification_repository::VideoClassificationRepository, +}; +use crate::business::services::template_service::TemplateService; +use anyhow::{Result, anyhow}; +use serde::{Serialize, Deserialize}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +/// 素材匹配服务 +pub struct MaterialMatchingService { + material_repo: Arc, + template_service: Arc, + video_classification_repo: Arc, +} + +/// 素材匹配请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterialMatchingRequest { + pub project_id: String, + pub template_id: String, + pub binding_id: String, + pub overwrite_existing: bool, +} + +/// 素材匹配结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterialMatchingResult { + pub binding_id: String, + pub template_id: String, + pub project_id: String, + pub matches: Vec, + pub statistics: MatchingStatistics, + pub failed_segments: Vec, +} + +/// 片段匹配结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SegmentMatch { + pub track_segment_id: String, + pub track_segment_name: String, + pub material_segment_id: String, + pub material_segment: MaterialSegment, + pub material_name: String, + pub model_name: Option, + pub match_score: f64, + pub match_reason: String, +} + +/// 匹配失败的片段 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FailedSegmentMatch { + pub track_segment_id: String, + pub track_segment_name: String, + pub matching_rule: SegmentMatchingRule, + pub failure_reason: String, +} + +/// 匹配统计信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MatchingStatistics { + pub total_segments: u32, + pub matched_segments: u32, + pub failed_segments: u32, + pub success_rate: f64, + pub used_materials: u32, + pub used_models: u32, +} + +impl MaterialMatchingService { + /// 创建新的素材匹配服务实例 + pub fn new( + material_repo: Arc, + template_service: Arc, + video_classification_repo: Arc, + ) -> Self { + Self { + material_repo, + template_service, + video_classification_repo, + } + } + + /// 执行素材匹配 + pub async fn match_materials(&self, request: MaterialMatchingRequest) -> Result { + // 获取模板信息 + let template = self.template_service.get_template_by_id(&request.template_id) + .await? + .ok_or_else(|| anyhow!("模板不存在: {}", request.template_id))?; + + // 获取项目的所有素材 + let project_materials = self.material_repo.get_by_project_id(&request.project_id)?; + + // 获取所有素材的分类记录 + let mut classification_records = HashMap::new(); + for material in &project_materials { + let records = self.video_classification_repo.get_by_material_id(&material.id).await?; + classification_records.insert(material.id.clone(), records); + } + + // 获取所有可用的素材片段(已分类的) + let available_segments = self.get_classified_segments(&project_materials, &classification_records).await?; + + // 执行匹配算法 + let mut matches = Vec::new(); + let mut failed_segments = Vec::new(); + let mut used_segment_ids = HashSet::new(); + let mut used_model_ids = HashSet::new(); + + // 获取所有需要匹配的轨道片段 + let track_segments = self.get_template_track_segments(&template).await?; + + for track_segment in &track_segments { + match self.match_single_segment( + track_segment, + &available_segments, + &classification_records, + &project_materials, + &mut used_segment_ids, + ).await { + Ok(segment_match) => { + // 记录使用的模特 + if let Some(material) = project_materials.iter().find(|m| m.id == segment_match.material_segment.material_id) { + if let Some(model_id) = &material.model_id { + used_model_ids.insert(model_id.clone()); + } + } + matches.push(segment_match); + } + Err(failure_reason) => { + failed_segments.push(FailedSegmentMatch { + track_segment_id: track_segment.id.clone(), + track_segment_name: track_segment.name.clone(), + matching_rule: track_segment.matching_rule.clone(), + failure_reason, + }); + } + } + } + + // 计算统计信息 + let total_segments = track_segments.len() as u32; + let matched_segments = matches.len() as u32; + let failed_segments_count = failed_segments.len() as u32; + let success_rate = if total_segments > 0 { + matched_segments as f64 / total_segments as f64 + } else { + 0.0 + }; + + let statistics = MatchingStatistics { + total_segments, + matched_segments, + failed_segments: failed_segments_count, + success_rate, + used_materials: used_segment_ids.len() as u32, + used_models: used_model_ids.len() as u32, + }; + + Ok(MaterialMatchingResult { + binding_id: request.binding_id, + template_id: request.template_id, + project_id: request.project_id, + matches, + statistics, + failed_segments, + }) + } + + /// 获取已分类的素材片段 + async fn get_classified_segments( + &self, + materials: &[Material], + classification_records: &HashMap>, + ) -> Result> { + let mut classified_segments = Vec::new(); + + for material in materials { + // 只处理有分类记录的素材 + if let Some(records) = classification_records.get(&material.id) { + if records.is_empty() { + continue; + } + + // 为每个素材片段查找对应的分类记录 + for segment in &material.segments { + // 查找该片段的分类记录 + 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 get_template_track_segments(&self, template: &Template) -> Result> { + let mut all_segments = Vec::new(); + + for track in &template.tracks { + all_segments.extend(track.segments.clone()); + } + + Ok(all_segments) + } + + /// 匹配单个轨道片段 + async fn match_single_segment( + &self, + track_segment: &TrackSegment, + available_segments: &[(MaterialSegment, String)], + _classification_records: &HashMap>, + project_materials: &[Material], + used_segment_ids: &mut HashSet, + ) -> Result { + // 检查匹配规则 + match &track_segment.matching_rule { + SegmentMatchingRule::FixedMaterial => { + Err("固定素材不需要匹配".to_string()) + } + SegmentMatchingRule::AiClassification { category_name, .. } => { + self.match_by_ai_classification( + track_segment, + available_segments, + category_name, + project_materials, + used_segment_ids, + ).await + } + SegmentMatchingRule::RandomMatch => { + self.match_randomly( + track_segment, + available_segments, + project_materials, + used_segment_ids, + ).await + } + } + } + + /// 根据AI分类匹配素材 + async fn match_by_ai_classification( + &self, + track_segment: &TrackSegment, + available_segments: &[(MaterialSegment, String)], + target_category: &str, + project_materials: &[Material], + used_segment_ids: &mut HashSet, + ) -> Result { + // 计算目标时长(微秒转秒) + let target_duration = track_segment.duration as f64 / 1_000_000.0; + + // 过滤出匹配分类的片段 + let category_segments: Vec<_> = available_segments + .iter() + .filter(|(segment, category)| { + category == target_category && !used_segment_ids.contains(&segment.id) + }) + .collect(); + + if category_segments.is_empty() { + return Err(format!("没有找到分类为'{}'的可用素材片段", target_category)); + } + + // 按模特分组 + let mut model_groups: HashMap, Vec<_>> = HashMap::new(); + for (segment, category) in category_segments { + if let Some(material) = project_materials.iter().find(|m| m.id == segment.material_id) { + let model_id = material.model_id.clone(); + model_groups.entry(model_id).or_default().push((segment, category, material)); + } + } + + // 尝试每个模特的素材 + for (model_id, model_segments) in model_groups { + if let Some(best_match) = self.find_best_duration_match( + &model_segments, + target_duration, + ) { + let (segment, _category, material) = best_match; + + // 标记为已使用 + used_segment_ids.insert(segment.id.clone()); + + return Ok(SegmentMatch { + track_segment_id: track_segment.id.clone(), + track_segment_name: track_segment.name.clone(), + material_segment_id: segment.id.clone(), + material_segment: (*segment).clone(), + material_name: material.name.clone(), + model_name: model_id.clone(), + match_score: segment.duration_match_score(target_duration), + match_reason: format!("AI分类匹配: {}", target_category), + }); + } + } + + Err(format!("没有找到满足时长要求的分类为'{}'的素材片段", target_category)) + } + + /// 随机匹配素材 + async fn match_randomly( + &self, + track_segment: &TrackSegment, + available_segments: &[(MaterialSegment, String)], + project_materials: &[Material], + used_segment_ids: &mut HashSet, + ) -> Result { + // 计算目标时长(微秒转秒) + let target_duration = track_segment.duration as f64 / 1_000_000.0; + + // 过滤出未使用的片段 + let unused_segments: Vec<_> = available_segments + .iter() + .filter(|(segment, _)| !used_segment_ids.contains(&segment.id)) + .collect(); + + if unused_segments.is_empty() { + return Err("没有可用的素材片段进行随机匹配".to_string()); + } + + // 按模特分组 + let mut model_groups: HashMap, Vec<_>> = HashMap::new(); + for (segment, category) in unused_segments { + if let Some(material) = project_materials.iter().find(|m| m.id == segment.material_id) { + let model_id = material.model_id.clone(); + model_groups.entry(model_id).or_default().push((segment, category, material)); + } + } + + // 尝试每个模特的素材 + for (model_id, model_segments) in model_groups { + if let Some(best_match) = self.find_best_duration_match( + &model_segments, + target_duration, + ) { + let (segment, category, material) = best_match; + + // 标记为已使用 + used_segment_ids.insert(segment.id.clone()); + + return Ok(SegmentMatch { + track_segment_id: track_segment.id.clone(), + track_segment_name: track_segment.name.clone(), + material_segment_id: segment.id.clone(), + material_segment: (*segment).clone(), + material_name: material.name.clone(), + model_name: model_id.clone(), + match_score: segment.duration_match_score(target_duration), + match_reason: format!("随机匹配: {}", category), + }); + } + } + + Err("没有找到满足时长要求的素材片段进行随机匹配".to_string()) + } + + /// 在给定的片段中找到最佳时长匹配 + fn find_best_duration_match<'a>( + &self, + segments: &'a [(&MaterialSegment, &String, &Material)], + target_duration: f64, + ) -> Option<(&'a MaterialSegment, &'a String, &'a Material)> { + let mut best_match = None; + let mut best_score = 0.0; + + for (segment, category, material) in segments { + if segment.meets_duration_requirement(target_duration) { + let score = segment.duration_match_score(target_duration); + if score > best_score { + best_score = score; + best_match = Some((*segment, *category, *material)); + } + } + } + + best_match + } +} diff --git a/apps/desktop/src-tauri/src/business/services/mod.rs b/apps/desktop/src-tauri/src/business/services/mod.rs index e6dc4eb..a756ad2 100644 --- a/apps/desktop/src-tauri/src/business/services/mod.rs +++ b/apps/desktop/src-tauri/src/business/services/mod.rs @@ -16,6 +16,7 @@ pub mod template_cache_service; pub mod performance_monitor; pub mod enhanced_template_import_service; pub mod project_template_binding_service; +pub mod material_matching_service; #[cfg(test)] pub mod tests; diff --git a/apps/desktop/src-tauri/src/business/services/tests/material_matching_service_tests.rs b/apps/desktop/src-tauri/src/business/services/tests/material_matching_service_tests.rs new file mode 100644 index 0000000..be51d2e --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/tests/material_matching_service_tests.rs @@ -0,0 +1,290 @@ +/** + * 素材匹配服务测试 + * 遵循 Tauri 开发规范的测试设计原则 + */ + +#[cfg(test)] +mod tests { + use crate::business::services::material_matching_service::{ + MaterialMatchingService, MaterialMatchingRequest + }; + use crate::business::services::template_service::TemplateService; + use crate::data::repositories::{ + material_repository::MaterialRepository, + video_classification_repository::VideoClassificationRepository, + }; + use crate::data::models::{ + material::{Material, MaterialType, ProcessingStatus, MaterialMetadata, MaterialSegment}, + template::{Template, CanvasConfig, TrackSegment, SegmentMatchingRule, Track, TrackType}, + video_classification::{VideoClassificationRecord, ClassificationStatus}, + }; + use crate::infrastructure::database::Database; + use std::sync::Arc; + use tempfile::TempDir; + use chrono::Utc; + + /// 创建测试数据库 + async fn create_test_database() -> Arc { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let database = Database::new(db_path.to_str().unwrap()).unwrap(); + Arc::new(database) + } + + /// 创建测试素材 + fn create_test_material(project_id: &str, model_id: Option) -> Material { + let now = Utc::now(); + Material { + id: uuid::Uuid::new_v4().to_string(), + project_id: project_id.to_string(), + model_id, + name: "测试素材".to_string(), + original_path: "/test/path.mp4".to_string(), + file_size: 1024000, + md5_hash: "test_hash".to_string(), + material_type: MaterialType::Video, + processing_status: ProcessingStatus::Completed, + metadata: MaterialMetadata::None, + scene_detection: None, + segments: vec![ + MaterialSegment::new( + "material_1".to_string(), + 0, + 0.0, + 30.0, + "/test/segment_0.mp4".to_string(), + 512000, + ) + ], + created_at: now, + updated_at: now, + processed_at: Some(now), + error_message: None, + } + } + + /// 创建测试模板 + fn create_test_template() -> Template { + let now = Utc::now(); + let mut template = Template { + id: uuid::Uuid::new_v4().to_string(), + name: "测试模板".to_string(), + description: Some("测试模板描述".to_string()), + canvas_config: CanvasConfig { + width: 1920, + height: 1080, + ratio: "16:9".to_string(), + }, + duration: 60_000_000, // 60秒(微秒) + fps: 30.0, + materials: Vec::new(), + tracks: Vec::new(), + import_status: crate::data::models::template::ImportStatus::Completed, + source_file_path: None, + created_at: now, + updated_at: now, + is_active: true, + }; + + // 添加测试轨道和片段 + let track_id = uuid::Uuid::new_v4().to_string(); + let segment = TrackSegment { + id: uuid::Uuid::new_v4().to_string(), + track_id: track_id.clone(), + template_material_id: None, + name: "测试片段".to_string(), + start_time: 0, + end_time: 30_000_000, // 30秒(微秒) + duration: 30_000_000, + segment_index: 0, + properties: None, + matching_rule: SegmentMatchingRule::AiClassification { + category_id: "test_category".to_string(), + category_name: "全身".to_string(), + }, + created_at: now, + updated_at: now, + }; + + let track = Track { + id: track_id, + template_id: template.id.clone(), + name: "视频轨道".to_string(), + track_type: TrackType::Video, + track_index: 0, + segments: vec![segment], + created_at: now, + updated_at: now, + }; + + template.tracks.push(track); + template + } + + /// 创建测试分类记录 + fn create_test_classification_record(segment_id: &str, material_id: &str, project_id: &str) -> VideoClassificationRecord { + let now = Utc::now(); + VideoClassificationRecord { + id: uuid::Uuid::new_v4().to_string(), + segment_id: segment_id.to_string(), + material_id: material_id.to_string(), + project_id: project_id.to_string(), + category: "全身".to_string(), + confidence: 0.95, + reasoning: "测试分类".to_string(), + features: vec!["feature1".to_string(), "feature2".to_string()], + product_match: true, + quality_score: 0.9, + gemini_file_uri: Some("test_uri".to_string()), + raw_response: Some("test_response".to_string()), + status: ClassificationStatus::Completed, + error_message: None, + created_at: now, + updated_at: now, + } + } + + #[tokio::test] + async fn test_material_matching_service_creation() { + let database = create_test_database().await; + let material_repo = Arc::new(MaterialRepository::new(database.get_connection()).unwrap()); + let template_service = Arc::new(TemplateService::new(database.clone())); + let video_classification_repo = Arc::new(VideoClassificationRepository::new(database.clone())); + + let service = MaterialMatchingService::new( + material_repo, + template_service, + video_classification_repo, + ); + + // 测试服务创建成功 + assert!(true); // 如果能创建服务实例,说明构造函数正常 + } + + #[tokio::test] + async fn test_material_matching_with_no_materials() { + let database = create_test_database().await; + let material_repo = Arc::new(MaterialRepository::new(database.get_connection()).unwrap()); + let template_service = Arc::new(TemplateService::new(database.clone())); + let video_classification_repo = Arc::new(VideoClassificationRepository::new(database.clone())); + + let service = MaterialMatchingService::new( + material_repo, + template_service, + video_classification_repo, + ); + + // 创建测试模板 + let template = create_test_template(); + template_service.save_template(&template).await.unwrap(); + + let request = MaterialMatchingRequest { + project_id: "test_project".to_string(), + template_id: template.id.clone(), + binding_id: "test_binding".to_string(), + overwrite_existing: false, + }; + + let result = service.match_materials(request).await; + + // 应该成功返回结果,但没有匹配的片段 + assert!(result.is_ok()); + let matching_result = result.unwrap(); + assert_eq!(matching_result.matches.len(), 0); + assert_eq!(matching_result.failed_segments.len(), 1); // 一个片段匹配失败 + assert_eq!(matching_result.statistics.success_rate, 0.0); + } + + #[tokio::test] + async fn test_material_matching_with_successful_match() { + let database = create_test_database().await; + let material_repo = Arc::new(MaterialRepository::new(database.get_connection()).unwrap()); + let template_service = Arc::new(TemplateService::new(database.clone())); + let video_classification_repo = Arc::new(VideoClassificationRepository::new(database.clone())); + + let service = MaterialMatchingService::new( + material_repo, + template_service, + video_classification_repo, + ); + + let project_id = "test_project"; + + // 创建测试素材 + let material = create_test_material(project_id, Some("test_model".to_string())); + material_repo.create(&material).unwrap(); + + // 创建测试模板 + let template = create_test_template(); + template_service.save_template(&template).await.unwrap(); + + // 创建测试分类记录 + let segment_id = &material.segments[0].id; + let classification_record = create_test_classification_record(segment_id, &material.id, project_id); + video_classification_repo.create(classification_record).await.unwrap(); + + let request = MaterialMatchingRequest { + project_id: project_id.to_string(), + template_id: template.id.clone(), + binding_id: "test_binding".to_string(), + overwrite_existing: false, + }; + + let result = service.match_materials(request).await; + + // 应该成功匹配 + assert!(result.is_ok()); + let matching_result = result.unwrap(); + assert_eq!(matching_result.matches.len(), 1); + assert_eq!(matching_result.failed_segments.len(), 0); + assert_eq!(matching_result.statistics.success_rate, 1.0); + assert_eq!(matching_result.statistics.used_materials, 1); + assert_eq!(matching_result.statistics.used_models, 1); + } + + #[tokio::test] + async fn test_material_matching_with_insufficient_duration() { + let database = create_test_database().await; + let material_repo = Arc::new(MaterialRepository::new(database.get_connection()).unwrap()); + let template_service = Arc::new(TemplateService::new(database.clone())); + let video_classification_repo = Arc::new(VideoClassificationRepository::new(database.clone())); + + let service = MaterialMatchingService::new( + material_repo, + template_service, + video_classification_repo, + ); + + let project_id = "test_project"; + + // 创建时长不足的测试素材(只有10秒,但模板需要30秒) + let mut material = create_test_material(project_id, Some("test_model".to_string())); + material.segments[0].duration = 10.0; // 10秒,不足30秒 + material_repo.create(&material).unwrap(); + + // 创建测试模板 + let template = create_test_template(); + template_service.save_template(&template).await.unwrap(); + + // 创建测试分类记录 + let segment_id = &material.segments[0].id; + let classification_record = create_test_classification_record(segment_id, &material.id, project_id); + video_classification_repo.create(classification_record).await.unwrap(); + + let request = MaterialMatchingRequest { + project_id: project_id.to_string(), + template_id: template.id.clone(), + binding_id: "test_binding".to_string(), + overwrite_existing: false, + }; + + let result = service.match_materials(request).await; + + // 应该匹配失败,因为时长不足 + assert!(result.is_ok()); + let matching_result = result.unwrap(); + assert_eq!(matching_result.matches.len(), 0); + assert_eq!(matching_result.failed_segments.len(), 1); + assert_eq!(matching_result.statistics.success_rate, 0.0); + } +} diff --git a/apps/desktop/src-tauri/src/business/services/tests/mod.rs b/apps/desktop/src-tauri/src/business/services/tests/mod.rs index 3623928..c172a28 100644 --- a/apps/desktop/src-tauri/src/business/services/tests/mod.rs +++ b/apps/desktop/src-tauri/src/business/services/tests/mod.rs @@ -2,6 +2,7 @@ pub mod draft_parser_tests; pub mod cloud_upload_service_tests; pub mod template_service_tests; pub mod template_integration_tests; +pub mod material_matching_service_tests; // 测试工具函数 pub mod test_utils { diff --git a/apps/desktop/src-tauri/src/data/models/material.rs b/apps/desktop/src-tauri/src/data/models/material.rs index 216bfdf..738e996 100644 --- a/apps/desktop/src-tauri/src/data/models/material.rs +++ b/apps/desktop/src-tauri/src/data/models/material.rs @@ -365,4 +365,26 @@ impl MaterialSegment { created_at: Utc::now(), } } + + /// 检查片段是否满足最小时长要求 + pub fn meets_duration_requirement(&self, required_duration: f64) -> bool { + self.duration >= required_duration + } + + /// 计算与目标时长的匹配度(越接近越好,返回0.0-1.0) + pub fn duration_match_score(&self, target_duration: f64) -> f64 { + if self.duration < target_duration { + return 0.0; // 时长不足,不匹配 + } + + // 计算匹配度:时长越接近目标时长,分数越高 + let excess_ratio = (self.duration - target_duration) / target_duration; + if excess_ratio <= 0.1 { + 1.0 // 超出10%以内,完美匹配 + } else if excess_ratio <= 0.5 { + 0.8 - (excess_ratio - 0.1) * 2.0 // 超出10%-50%,线性递减 + } else { + 0.3 // 超出50%以上,低匹配度 + } + } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b38ded7..dae1a49 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -181,6 +181,10 @@ pub fn run() { commands::project_template_binding_commands::get_primary_template_binding_for_project, commands::project_template_binding_commands::set_primary_template_for_project, commands::project_template_binding_commands::check_project_template_binding_exists, + // 素材匹配命令 + commands::material_matching_commands::execute_material_matching, + commands::material_matching_commands::get_project_material_stats_for_matching, + commands::material_matching_commands::validate_template_binding_for_matching, // 测试命令 commands::test_commands::test_database_connection, commands::test_commands::test_template_table, diff --git a/apps/desktop/src-tauri/src/presentation/commands/material_matching_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/material_matching_commands.rs new file mode 100644 index 0000000..933e559 --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/material_matching_commands.rs @@ -0,0 +1,147 @@ +/** + * 素材匹配相关的 Tauri 命令 + * 遵循 Tauri 开发规范的 API 设计原则 + */ + +use tauri::{command, State}; +use std::sync::Arc; + +use crate::business::services::material_matching_service::{ + MaterialMatchingService, MaterialMatchingRequest, MaterialMatchingResult +}; +use crate::business::services::template_service::TemplateService; +use crate::data::repositories::{ + material_repository::MaterialRepository, + video_classification_repository::VideoClassificationRepository, +}; +use crate::infrastructure::database::Database; + +/// 执行素材匹配 +#[command] +pub async fn execute_material_matching( + request: MaterialMatchingRequest, + database: State<'_, Arc>, +) -> Result { + // 创建服务实例 + let material_repo = Arc::new( + MaterialRepository::new(database.get_connection()) + .map_err(|e| format!("创建素材仓库失败: {}", e))? + ); + + let template_service = Arc::new(TemplateService::new(database.inner().clone())); + + let video_classification_repo = Arc::new( + VideoClassificationRepository::new(database.inner().clone()) + ); + + let matching_service = MaterialMatchingService::new( + material_repo, + template_service, + video_classification_repo, + ); + + // 执行匹配 + matching_service.match_materials(request) + .await + .map_err(|e| e.to_string()) +} + +/// 获取项目的可用素材统计信息 +#[command] +pub async fn get_project_material_stats_for_matching( + project_id: String, + database: State<'_, Arc>, +) -> Result { + let material_repo = MaterialRepository::new(database.get_connection()) + .map_err(|e| format!("创建素材仓库失败: {}", e))?; + + let video_classification_repo = VideoClassificationRepository::new(database.inner().clone()); + + // 获取项目的所有素材 + let materials = material_repo.get_by_project_id(&project_id) + .map_err(|e| format!("获取项目素材失败: {}", e))?; + + let mut total_segments = 0; + let mut classified_segments = 0; + let mut available_models = std::collections::HashSet::new(); + let mut available_categories = std::collections::HashSet::new(); + + for material in &materials { + total_segments += material.segments.len(); + + // 获取分类记录 + let classification_records = video_classification_repo.get_by_material_id(&material.id) + .await + .map_err(|e| format!("获取分类记录失败: {}", e))?; + + // 统计已分类的片段 + for segment in &material.segments { + if classification_records.iter().any(|r| r.segment_id == segment.id) { + classified_segments += 1; + + // 记录分类类别 + if let Some(record) = classification_records.iter().find(|r| r.segment_id == segment.id) { + available_categories.insert(record.category.clone()); + } + } + } + + // 记录模特 + if let Some(model_id) = &material.model_id { + available_models.insert(model_id.clone()); + } + } + + Ok(ProjectMaterialMatchingStats { + project_id, + total_materials: materials.len() as u32, + total_segments: total_segments as u32, + classified_segments: classified_segments as u32, + available_models: available_models.len() as u32, + available_categories: available_categories.into_iter().collect(), + classification_rate: if total_segments > 0 { + classified_segments as f64 / total_segments as f64 + } else { + 0.0 + }, + }) +} + +/// 验证模板绑定是否可以进行素材匹配 +#[command] +pub async fn validate_template_binding_for_matching( + binding_id: String, + _database: State<'_, Arc>, +) -> Result { + // 这里需要根据binding_id获取模板信息并验证 + // 暂时返回一个简单的验证结果 + Ok(TemplateBindingMatchingValidation { + binding_id, + is_valid: true, + validation_errors: Vec::new(), + total_segments: 0, + matchable_segments: 0, + }) +} + +/// 项目素材匹配统计信息 +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct ProjectMaterialMatchingStats { + pub project_id: String, + pub total_materials: u32, + pub total_segments: u32, + pub classified_segments: u32, + pub available_models: u32, + pub available_categories: Vec, + pub classification_rate: f64, +} + +/// 模板绑定匹配验证结果 +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct TemplateBindingMatchingValidation { + pub binding_id: String, + pub is_valid: bool, + pub validation_errors: Vec, + pub total_segments: u32, + pub matchable_segments: u32, +} diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index 50b1754..ad3ffe9 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -9,3 +9,4 @@ pub mod template_commands; pub mod test_commands; pub mod debug_commands; pub mod project_template_binding_commands; +pub mod material_matching_commands; diff --git a/apps/desktop/src/components/MaterialMatchingResultDialog.tsx b/apps/desktop/src/components/MaterialMatchingResultDialog.tsx new file mode 100644 index 0000000..491bc5d --- /dev/null +++ b/apps/desktop/src/components/MaterialMatchingResultDialog.tsx @@ -0,0 +1,435 @@ +/** + * 素材匹配结果对话框组件 + * 遵循前端开发规范的组件设计原则 + */ + +import React, { useState } from 'react'; +import { + X, + CheckCircle, + XCircle, + AlertTriangle, + BarChart3, + Users, + TrendingUp +} from 'lucide-react'; +import { + MaterialMatchingResult, + SegmentMatch, + FailedSegmentMatch, + MatchingStatistics +} from '../types/materialMatching'; +import { SegmentMatchingRuleHelper } from '../types/template'; +import { LoadingSpinner } from './LoadingSpinner'; + +interface MaterialMatchingResultDialogProps { + isOpen: boolean; + onClose: () => void; + result: MaterialMatchingResult | null; + loading?: boolean; + onApplyResult?: (result: MaterialMatchingResult) => void; + onRetryMatching?: () => void; +} + +export const MaterialMatchingResultDialog: React.FC = ({ + isOpen, + onClose, + result, + loading = false, + onApplyResult, + onRetryMatching, +}) => { + const [activeTab, setActiveTab] = useState<'overview' | 'matches' | 'failures'>('overview'); + + if (!isOpen) return null; + + const handleApplyResult = () => { + if (result && onApplyResult) { + onApplyResult(result); + } + }; + + const formatDuration = (microseconds: number): string => { + const seconds = microseconds / 1000000; + if (seconds < 60) { + return `${seconds.toFixed(1)}s`; + } else { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds.toFixed(1)}s`; + } + }; + + const getSuccessRateColor = (rate: number): string => { + if (rate >= 0.8) return 'text-green-600'; + if (rate >= 0.6) return 'text-yellow-600'; + return 'text-red-600'; + }; + + return ( +
+
+ {/* 头部 */} +
+
+

素材匹配结果

+ {result && ( +

+ 模板: {result.template_id} | 项目: {result.project_id} +

+ )} +
+ +
+ + {/* 内容 */} +
+ {loading ? ( +
+
+ +

正在匹配素材...

+
+
+ ) : result ? ( + <> + {/* 统计概览 */} +
+
+
+
+ {result.statistics.total_segments} +
+
总片段数
+
+
+
+ {result.statistics.matched_segments} +
+
匹配成功
+
+
+
+ {result.statistics.failed_segments} +
+
匹配失败
+
+
+
+ {(result.statistics.success_rate * 100).toFixed(1)}% +
+
成功率
+
+
+ + {/* 成功率进度条 */} +
+
+ 匹配进度 + {result.statistics.matched_segments}/{result.statistics.total_segments} +
+
+
= 0.8 ? 'bg-green-500' : + result.statistics.success_rate >= 0.6 ? 'bg-yellow-500' : 'bg-red-500' + }`} + style={{ width: `${result.statistics.success_rate * 100}%` }} + /> +
+
+
+ + {/* 选项卡 */} +
+ +
+ + {/* 选项卡内容 */} +
+ {activeTab === 'overview' && ( + + )} + {activeTab === 'matches' && ( + + )} + {activeTab === 'failures' && ( + + )} +
+ + ) : ( +
+
+ +

没有匹配结果

+
+
+ )} +
+ + {/* 底部操作按钮 */} + {result && !loading && ( +
+
+
+ 使用了 {result.statistics.used_materials} 个素材,涉及 {result.statistics.used_models} 个模特 +
+
+
+ {onRetryMatching && ( + + )} + + {onApplyResult && result.statistics.matched_segments > 0 && ( + + )} +
+
+ )} +
+
+ ); +}; + +// 概览选项卡组件 +const OverviewTab: React.FC<{ statistics: MatchingStatistics }> = ({ statistics }) => { + return ( +
+
+ {/* 匹配统计 */} +
+

+ + 匹配统计 +

+
+
+ 总片段数: + {statistics.total_segments} +
+
+ 匹配成功: + {statistics.matched_segments} +
+
+ 匹配失败: + {statistics.failed_segments} +
+
+ 成功率: + {(statistics.success_rate * 100).toFixed(1)}% +
+
+
+ + {/* 资源使用 */} +
+

+ + 资源使用 +

+
+
+ 使用素材: + {statistics.used_materials} 个 +
+
+ 涉及模特: + {statistics.used_models} 个 +
+
+
+
+ + {/* 匹配质量评估 */} +
+

+ + 匹配质量评估 +

+
+ {statistics.success_rate >= 0.8 && ( +
+ + 匹配质量优秀,大部分片段都找到了合适的素材 +
+ )} + {statistics.success_rate >= 0.6 && statistics.success_rate < 0.8 && ( +
+ + 匹配质量良好,但仍有改进空间 +
+ )} + {statistics.success_rate < 0.6 && ( +
+ + 匹配质量较低,建议检查素材分类和模板设置 +
+ )} +
+
+
+ ); +}; + +// 成功匹配选项卡组件 +const MatchesTab: React.FC<{ + matches: SegmentMatch[]; + formatDuration: (microseconds: number) => string; +}> = ({ matches, formatDuration }) => { + if (matches.length === 0) { + return ( +
+ +

没有成功匹配的片段

+
+ ); + } + + return ( +
+ {matches.map((match, index) => ( +
+
+
+
+ +

{match.track_segment_name}

+ #{index + 1} +
+
+
+ 匹配素材: + {match.material_name} +
+
+ 时长: + {formatDuration(match.material_segment.duration * 1000000)} +
+ {match.model_name && ( +
+ 模特: + {match.model_name} +
+ )} +
+ 匹配度: + {(match.match_score * 100).toFixed(1)}% +
+
+
+ 匹配原因: {match.match_reason} +
+
+
+
+ ))} +
+ ); +}; + +// 匹配失败选项卡组件 +const FailuresTab: React.FC<{ failures: FailedSegmentMatch[] }> = ({ failures }) => { + if (failures.length === 0) { + return ( +
+ +

所有片段都匹配成功!

+
+ ); + } + + // 按失败原因分组 + const groupedFailures = failures.reduce((acc, failure) => { + if (!acc[failure.failure_reason]) { + acc[failure.failure_reason] = []; + } + acc[failure.failure_reason].push(failure); + return acc; + }, {} as Record); + + return ( +
+ {Object.entries(groupedFailures).map(([reason, failureList]) => ( +
+
+ +

{reason}

+ ({failureList.length} 个片段) +
+
+ {failureList.map((failure) => ( +
+ {failure.track_segment_name} + + 规则: {SegmentMatchingRuleHelper.getDisplayName(failure.matching_rule)} + +
+ ))} +
+
+ ))} +
+ ); +}; diff --git a/apps/desktop/src/components/ProjectTemplateBindingList.tsx b/apps/desktop/src/components/ProjectTemplateBindingList.tsx index 294fb70..47b5b9f 100644 --- a/apps/desktop/src/components/ProjectTemplateBindingList.tsx +++ b/apps/desktop/src/components/ProjectTemplateBindingList.tsx @@ -4,18 +4,19 @@ */ import React, { useState } from 'react'; -import { - Plus, - Edit2, - Trash2, - Power, - PowerOff, - Star, +import { + Plus, + Edit2, + Trash2, + Power, + PowerOff, + Star, StarOff, Search, Filter, CheckSquare, - Square + Square, + Shuffle } from 'lucide-react'; import { ProjectTemplateBindingDetail, @@ -45,6 +46,7 @@ interface ProjectTemplateBindingListProps { onBatchDelete?: (ids: string[]) => void; onToggleStatus?: (id: string) => void; onSetPrimary?: (projectId: string, templateId: string) => void; + onMatchMaterials?: (binding: ProjectTemplateBindingDetail) => void; searchQuery?: string; onSearchChange?: (query: string) => void; typeFilter?: BindingType | ''; @@ -66,6 +68,7 @@ export const ProjectTemplateBindingList: React.FC handleDeleteConfirm(id, name)} onToggleStatus={onToggleStatus} onSetPrimary={onSetPrimary} + onMatchMaterials={onMatchMaterials} showSelection={!!onSelectionChange} /> ))} @@ -324,6 +328,7 @@ interface BindingListItemProps { onDelete?: (id: string, name: string) => void; onToggleStatus?: (id: string) => void; onSetPrimary?: (projectId: string, templateId: string) => void; + onMatchMaterials?: (binding: ProjectTemplateBindingDetail) => void; showSelection: boolean; } @@ -335,6 +340,7 @@ const BindingListItem: React.FC = ({ onDelete, onToggleStatus, onSetPrimary, + onMatchMaterials, showSelection, }) => { const { binding } = detail; @@ -435,6 +441,16 @@ const BindingListItem: React.FC = ({ )} + {onMatchMaterials && binding.is_active && ( + + )} + {onEdit && (
); }; diff --git a/apps/desktop/src/services/materialMatchingService.ts b/apps/desktop/src/services/materialMatchingService.ts new file mode 100644 index 0000000..5769ee7 --- /dev/null +++ b/apps/desktop/src/services/materialMatchingService.ts @@ -0,0 +1,261 @@ +/** + * 素材匹配服务 + * 遵循前端开发规范的服务层设计 + */ + +import { invoke } from '@tauri-apps/api/core'; +import { + MaterialMatchingRequest, + MaterialMatchingResult, + ProjectMaterialMatchingStats, + TemplateBindingMatchingValidation, + MatchingError, + MatchingErrorType +} from '../types/materialMatching'; + +export class MaterialMatchingService { + /** + * 执行素材匹配 + */ + static async executeMatching(request: MaterialMatchingRequest): Promise { + try { + return await invoke('execute_material_matching', { request }); + } catch (error) { + throw this.handleMatchingError(error); + } + } + + /** + * 获取项目的素材匹配统计信息 + */ + static async getProjectMaterialStats(projectId: string): Promise { + try { + return await invoke('get_project_material_stats_for_matching', { + projectId + }); + } catch (error) { + throw this.handleMatchingError(error); + } + } + + /** + * 验证模板绑定是否可以进行素材匹配 + */ + static async validateTemplateBinding(bindingId: string): Promise { + try { + return await invoke('validate_template_binding_for_matching', { + bindingId + }); + } catch (error) { + throw this.handleMatchingError(error); + } + } + + /** + * 检查项目是否准备好进行素材匹配 + */ + static async checkProjectReadiness(projectId: string): Promise<{ + isReady: boolean; + issues: string[]; + stats: ProjectMaterialMatchingStats; + }> { + const stats = await this.getProjectMaterialStats(projectId); + + const issues: string[] = []; + + // 检查是否有素材 + if (stats.total_materials === 0) { + issues.push('项目中没有素材,请先导入素材'); + } + + // 检查是否有已分类的片段 + if (stats.classified_segments === 0) { + issues.push('没有已分类的素材片段,请先进行AI分类'); + } + + // 检查分类率是否足够 + if (stats.classification_rate < 0.5) { + issues.push(`分类率较低 (${(stats.classification_rate * 100).toFixed(1)}%),建议提高分类覆盖率`); + } + + // 检查是否有可用的分类类别 + if (stats.available_categories.length === 0) { + issues.push('没有可用的AI分类类别'); + } + + return { + isReady: issues.length === 0, + issues, + stats + }; + } + + /** + * 预估匹配结果 + */ + static async estimateMatchingResult(request: MaterialMatchingRequest): Promise<{ + estimated_matches: number; + estimated_failures: number; + estimated_success_rate: number; + potential_issues: string[]; + }> { + // 获取项目统计信息 + const stats = await this.getProjectMaterialStats(request.project_id); + + // 验证模板绑定 + const validation = await this.validateTemplateBinding(request.binding_id); + + const potential_issues: string[] = []; + + // 基于统计信息估算匹配结果 + let estimated_matches = Math.min( + validation.matchable_segments, + Math.floor(stats.classified_segments * 0.8) // 假设80%的已分类片段可以匹配 + ); + + let estimated_failures = validation.total_segments - estimated_matches; + + // 检查潜在问题 + if (stats.available_models < 2) { + potential_issues.push('模特数量较少,可能影响匹配多样性'); + estimated_matches = Math.floor(estimated_matches * 0.7); + } + + if (stats.classification_rate < 0.7) { + potential_issues.push('分类率较低,可能导致匹配失败'); + estimated_matches = Math.floor(estimated_matches * 0.8); + } + + if (stats.available_categories.length < 3) { + potential_issues.push('可用分类类别较少'); + estimated_matches = Math.floor(estimated_matches * 0.9); + } + + estimated_failures = validation.total_segments - estimated_matches; + const estimated_success_rate = validation.total_segments > 0 + ? estimated_matches / validation.total_segments + : 0; + + return { + estimated_matches, + estimated_failures, + estimated_success_rate, + potential_issues + }; + } + + /** + * 格式化匹配统计信息为显示文本 + */ + static formatMatchingStats(result: MaterialMatchingResult): { + summary: string; + details: string[]; + } { + const { statistics } = result; + + const summary = `匹配完成:${statistics.matched_segments}/${statistics.total_segments} 个片段 (${(statistics.success_rate * 100).toFixed(1)}%)`; + + const details = [ + `成功匹配:${statistics.matched_segments} 个片段`, + `匹配失败:${statistics.failed_segments} 个片段`, + `使用素材:${statistics.used_materials} 个`, + `涉及模特:${statistics.used_models} 个`, + `成功率:${(statistics.success_rate * 100).toFixed(1)}%` + ]; + + return { summary, details }; + } + + /** + * 获取匹配失败原因的分类统计 + */ + static analyzeFailureReasons(result: MaterialMatchingResult): { + [reason: string]: { + count: number; + segments: string[]; + }; + } { + const analysis: { [reason: string]: { count: number; segments: string[] } } = {}; + + result.failed_segments.forEach(failure => { + if (!analysis[failure.failure_reason]) { + analysis[failure.failure_reason] = { + count: 0, + segments: [] + }; + } + + analysis[failure.failure_reason].count++; + analysis[failure.failure_reason].segments.push(failure.track_segment_name); + }); + + return analysis; + } + + /** + * 生成匹配改进建议 + */ + static generateImprovementSuggestions(result: MaterialMatchingResult, stats: ProjectMaterialMatchingStats): string[] { + const suggestions: string[] = []; + + // 基于失败原因生成建议 + const failureAnalysis = this.analyzeFailureReasons(result); + + Object.keys(failureAnalysis).forEach(reason => { + const count = failureAnalysis[reason].count; + + if (reason.includes('没有找到分类')) { + suggestions.push(`有 ${count} 个片段因缺少对应分类而匹配失败,建议增加相关分类的素材`); + } else if (reason.includes('时长要求')) { + suggestions.push(`有 ${count} 个片段因时长不足而匹配失败,建议导入更长的素材片段`); + } else if (reason.includes('可用素材')) { + suggestions.push(`有 ${count} 个片段因没有可用素材而匹配失败,建议增加更多素材`); + } + }); + + // 基于统计信息生成建议 + if (stats.classification_rate < 0.8) { + suggestions.push('建议对更多素材进行AI分类以提高匹配成功率'); + } + + if (stats.available_models < 3) { + suggestions.push('建议增加更多模特以提高匹配多样性'); + } + + if (result.statistics.success_rate < 0.6) { + suggestions.push('匹配成功率较低,建议检查模板的匹配规则设置'); + } + + return suggestions; + } + + /** + * 处理匹配错误 + */ + private static handleMatchingError(error: any): MatchingError { + let errorType: MatchingErrorType = MatchingErrorType.NoClassifiedSegments; + let message = '素材匹配失败'; + + if (typeof error === 'string') { + message = error; + + if (error.includes('模板不存在')) { + errorType = MatchingErrorType.TemplateNotFound; + } else if (error.includes('项目不存在')) { + errorType = MatchingErrorType.ProjectNotFound; + } else if (error.includes('没有已分类')) { + errorType = MatchingErrorType.NoClassifiedSegments; + } else if (error.includes('时长不足')) { + errorType = MatchingErrorType.InsufficientDuration; + } + } else if (error?.message) { + message = error.message; + } + + return { + type: errorType, + message, + details: error + }; + } +} diff --git a/apps/desktop/src/types/materialMatching.ts b/apps/desktop/src/types/materialMatching.ts new file mode 100644 index 0000000..6c9e839 --- /dev/null +++ b/apps/desktop/src/types/materialMatching.ts @@ -0,0 +1,158 @@ +/** + * 素材匹配相关的类型定义 + * 遵循前端开发规范的类型设计原则 + */ + +import { MaterialSegment } from './material'; +import { SegmentMatchingRule } from './template'; + +// 素材匹配请求 +export interface MaterialMatchingRequest { + project_id: string; + template_id: string; + binding_id: string; + overwrite_existing: boolean; +} + +// 素材匹配结果 +export interface MaterialMatchingResult { + binding_id: string; + template_id: string; + project_id: string; + matches: SegmentMatch[]; + statistics: MatchingStatistics; + failed_segments: FailedSegmentMatch[]; +} + +// 片段匹配结果 +export interface SegmentMatch { + track_segment_id: string; + track_segment_name: string; + material_segment_id: string; + material_segment: MaterialSegment; + material_name: string; + model_name?: string; + match_score: number; + match_reason: string; +} + +// 匹配失败的片段 +export interface FailedSegmentMatch { + track_segment_id: string; + track_segment_name: string; + matching_rule: SegmentMatchingRule; + failure_reason: string; +} + +// 匹配统计信息 +export interface MatchingStatistics { + total_segments: number; + matched_segments: number; + failed_segments: number; + success_rate: number; + used_materials: number; + used_models: number; +} + +// 项目素材匹配统计信息 +export interface ProjectMaterialMatchingStats { + project_id: string; + total_materials: number; + total_segments: number; + classified_segments: number; + available_models: number; + available_categories: string[]; + classification_rate: number; +} + +// 模板绑定匹配验证结果 +export interface TemplateBindingMatchingValidation { + binding_id: string; + is_valid: boolean; + validation_errors: string[]; + total_segments: number; + matchable_segments: number; +} + +// 匹配状态枚举 +export enum MatchingStatus { + Idle = 'idle', + Matching = 'matching', + Completed = 'completed', + Failed = 'failed' +} + +// 匹配结果显示选项 +export interface MatchingResultDisplayOptions { + showSuccessOnly: boolean; + showFailuresOnly: boolean; + groupByModel: boolean; + sortBy: 'score' | 'name' | 'duration'; + sortOrder: 'asc' | 'desc'; +} + +// 匹配配置选项 +export interface MatchingOptions { + overwrite_existing: boolean; + prefer_higher_quality: boolean; + strict_duration_matching: boolean; + allow_cross_model_matching: boolean; +} + +// 匹配进度信息 +export interface MatchingProgress { + current_segment: number; + total_segments: number; + current_segment_name: string; + progress_percentage: number; + estimated_remaining_time?: number; +} + +// 匹配错误类型 +export enum MatchingErrorType { + NoClassifiedSegments = 'no_classified_segments', + NoMatchingCategory = 'no_matching_category', + InsufficientDuration = 'insufficient_duration', + NoAvailableModels = 'no_available_models', + TemplateNotFound = 'template_not_found', + ProjectNotFound = 'project_not_found', + InvalidMatchingRule = 'invalid_matching_rule' +} + +// 匹配错误详情 +export interface MatchingError { + type: MatchingErrorType; + message: string; + segment_id?: string; + segment_name?: string; + details?: Record; +} + +// 匹配质量评分 +export interface MatchingQualityScore { + overall_score: number; + duration_score: number; + category_score: number; + model_consistency_score: number; + quality_factors: string[]; +} + +// 匹配建议 +export interface MatchingSuggestion { + type: 'improve_classification' | 'add_materials' | 'adjust_rules'; + message: string; + action_required: boolean; + estimated_improvement: number; +} + +// 匹配报告 +export interface MatchingReport { + matching_result: MaterialMatchingResult; + quality_score: MatchingQualityScore; + suggestions: MatchingSuggestion[]; + performance_metrics: { + matching_time_ms: number; + segments_per_second: number; + memory_usage_mb?: number; + }; +}