From f6041c6eea8855b19447d384007d31ca1e6f5b4c Mon Sep 17 00:00:00 2001 From: imeepos Date: Fri, 18 Jul 2025 12:50:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E6=A8=A1=E6=9D=BF?= =?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 - 修复重复资源使用问题: * 修改MaterialMatchingService中的匹配算法,确保每个素材片段在一次匹配中只能被使用一次 * 添加get_classified_segments_with_exclusions方法支持额外排除片段 * 重构match_materials_with_used_segments方法正确处理全局使用状态 * 更新批量匹配逻辑使用新的匹配方法 - 添加批量删除匹配记录功能: * 在TemplateMatchingResultService中添加批量删除方法 * 在MaterialUsageRepository中添加批量删除使用记录的方法 * 删除匹配记录时自动重置相关资源的使用状态 * 添加相应的Tauri命令和API接口 - 添加匹配记录导出状态标识: * 在TemplateMatchingResult模型中添加is_exported和last_exported_at字段 * 更新数据库schema和仓库层支持新字段 * 在导出功能中自动更新导出状态 * 添加重置导出状态的功能 - 优化一键匹配命名逻辑: * 改进命名规则使用模板名称+序号格式 * 为每个模板维护独立的序号计数器 * 支持自定义前缀的命名方式 - 更新前端组件支持新功能: * 在TemplateMatchingResultManager中添加批量选择和批量删除功能 * 在TemplateMatchingResultCard中添加选择框和导出状态显示 * 添加全选/取消全选功能 * 优化UI显示导出状态标识 - 数据库迁移: * 添加is_exported和last_exported_at字段到template_matching_results表 * 保持向后兼容性 --- .../services/material_matching_service.rs | 236 +++++++++++------- .../template_matching_result_service.rs | 63 +++++ .../data/models/template_matching_result.rs | 19 ++ .../repositories/material_usage_repository.rs | 50 +++- .../template_matching_result_repository.rs | 54 +++- .../src-tauri/src/infrastructure/database.rs | 15 ++ apps/desktop/src-tauri/src/lib.rs | 5 + .../template_matching_result_commands.rs | 71 ++++++ .../components/TemplateMatchingResultCard.tsx | 39 ++- .../TemplateMatchingResultManager.tsx | 176 +++++++++++-- .../src/types/templateMatchingResult.ts | 2 + 11 files changed, 602 insertions(+), 128 deletions(-) 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 5e489a9..a570817 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 @@ -321,11 +321,22 @@ impl MaterialMatchingService { materials: &[Material], classification_records: &HashMap>, project_id: &str, + ) -> Result> { + self.get_classified_segments_with_exclusions(materials, classification_records, project_id, &HashSet::new()).await + } + + /// 获取已分类的素材片段(排除已使用的片段和额外排除的片段) + async fn get_classified_segments_with_exclusions( + &self, + materials: &[Material], + classification_records: &HashMap>, + project_id: &str, + additional_used_segments: &HashSet, ) -> Result> { let mut classified_segments = Vec::new(); - // 获取项目中已使用的素材片段ID列表 - let used_segment_ids = match self.material_usage_repo.get_usage_records_by_project(project_id) { + // 获取项目中已使用的素材片段ID列表(从数据库) + let mut 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) @@ -338,6 +349,9 @@ impl MaterialMatchingService { } }; + // 合并额外的已使用片段ID + used_segment_ids.extend(additional_used_segments.iter().cloned()); + for material in materials { // 只处理有分类记录的素材 @@ -644,6 +658,9 @@ impl MaterialMatchingService { let mut termination_reason = String::new(); let mut materials_exhausted = false; + // 为每个模板维护独立的序号计数器 + let mut template_counters: HashMap = HashMap::new(); + // 获取项目中已使用的素材片段ID列表(从数据库) let existing_used_segments = match self.material_usage_repo.get_usage_records_by_project(&request.project_id) { Ok(usage_records) => { @@ -705,18 +722,24 @@ impl MaterialMatchingService { overwrite_existing: request.overwrite_existing, }; - let result_name = format!( - "{}-{}-R{}", - request.result_name_prefix.as_deref().unwrap_or("一键匹配"), - binding_detail.template_name, - total_rounds - ); + // 改进的命名逻辑:模板名称 + 序号(每个模板独立计数) + let template_counter = template_counters.entry(binding_detail.template_name.clone()).or_insert(0); + *template_counter += 1; - match self.match_materials_and_save(matching_request, result_name, None).await { - Ok((matching_result, saved_result)) => { + let result_name = if let Some(prefix) = &request.result_name_prefix { + format!("{}-{}-{:03}", prefix, binding_detail.template_name, *template_counter) + } else { + format!("{}-{:03}", binding_detail.template_name, *template_counter) + }; + + match self.match_materials_with_used_segments(matching_request, result_name, &global_used_segment_ids).await { + Ok((matching_result, saved_result, newly_used_segments)) => { round_successful_matches += 1; successful_matches += 1; + // 更新全局已使用片段列表 + global_used_segment_ids.extend(newly_used_segments); + matching_results.push(BatchMatchingItemResult { binding_id: binding_detail.binding.id.clone(), template_id: binding_detail.binding.template_id.clone(), @@ -885,83 +908,7 @@ impl MaterialMatchingService { 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( @@ -970,16 +917,117 @@ impl MaterialMatchingService { 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?; + // 获取模板信息 + let template = self.template_service.get_template_by_id(&request.template_id) + .await? + .ok_or_else(|| anyhow!("模板不存在: {}", request.template_id))?; - // 收集本次匹配使用的素材片段ID - let newly_used_segments: HashSet = matching_result.matches.iter() - .map(|m| m.material_segment_id.clone()) - .collect(); + // 获取项目的所有素材 + let project_materials = self.material_repo.get_by_project_id(&request.project_id)?; - Ok((matching_result, saved_result, newly_used_segments)) + // 获取所有素材的分类记录 + 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_with_exclusions( + &project_materials, + &classification_records, + &request.project_id, + used_segment_ids + ).await?; + + // 执行匹配算法 + let mut matches = Vec::new(); + let mut failed_segments = Vec::new(); + let mut fixed_segments = Vec::new(); + let mut local_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 { + // 检查是否为固定素材 + if track_segment.matching_rule.is_fixed_material() { + fixed_segments.push(track_segment.clone()); + continue; // 固定素材跳过匹配,不计入失败 + } + + // 尝试匹配片段 + match self.match_single_segment( + track_segment, + &available_segments, + &classification_records, + &project_materials, + &mut local_used_segment_ids, + ).await { + Ok(segment_match) => { + // 收集使用的模特ID + if let Some(model_name) = &segment_match.model_name { + if !model_name.is_empty() { + used_model_ids.insert(model_name.clone()); + } + } + matches.push(segment_match); + } + Err(error_msg) => { + 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: error_msg, + }); + } + } + } + + // 计算统计信息 + 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 fixed_segments_count = fixed_segments.len() as u32; + let success_rate = if total_segments > 0 { + (matched_segments + fixed_segments_count) as f64 / total_segments as f64 + } else { + 0.0 + }; + + // 创建匹配结果 + let matching_result = MaterialMatchingResult { + binding_id: request.binding_id.clone(), + template_id: request.template_id.clone(), + project_id: request.project_id.clone(), + matches, + statistics: MatchingStatistics { + total_segments, + matched_segments, + failed_segments: failed_segments_count, + success_rate, + used_materials: local_used_segment_ids.len() as u32, + used_models: used_model_ids.len() as u32, + }, + failed_segments, + }; + + // 保存匹配结果到数据库 + let saved_result = if let Some(result_service) = &self.matching_result_service { + let saved = result_service.save_matching_result( + &matching_result, + result_name, + None, + 0, // 匹配耗时,这里简化为0 + ).await?; + + Some(saved) + } else { + None + }; + + Ok((matching_result, saved_result, local_used_segment_ids)) } /// 计算批量匹配汇总信息 diff --git a/apps/desktop/src-tauri/src/business/services/template_matching_result_service.rs b/apps/desktop/src-tauri/src/business/services/template_matching_result_service.rs index 3025764..579e643 100644 --- a/apps/desktop/src-tauri/src/business/services/template_matching_result_service.rs +++ b/apps/desktop/src-tauri/src/business/services/template_matching_result_service.rs @@ -200,6 +200,62 @@ impl TemplateMatchingResultService { Ok(self.repository.soft_delete(result_id)?) } + /// 批量删除匹配结果 + pub async fn batch_delete_matching_results(&self, result_ids: &[String]) -> Result { + let mut deleted_count = 0; + + for result_id in result_ids { + if self.repository.delete(result_id)? { + deleted_count += 1; + } + } + + Ok(deleted_count) + } + + /// 批量软删除匹配结果 + pub async fn batch_soft_delete_matching_results(&self, result_ids: &[String]) -> Result { + let mut deleted_count = 0; + + for result_id in result_ids { + if self.repository.soft_delete(result_id)? { + deleted_count += 1; + } + } + + Ok(deleted_count) + } + + /// 批量删除匹配结果并重置资源使用状态 + pub async fn batch_delete_matching_results_with_usage_reset( + &self, + result_ids: &[String], + material_usage_repo: Arc + ) -> Result<(u32, u32)> { + // 先删除使用记录并重置资源状态 + let deleted_usage_records = material_usage_repo.delete_usage_records_by_matching_results(result_ids)?; + + // 再删除匹配结果 + let deleted_results = self.batch_delete_matching_results(result_ids).await?; + + Ok((deleted_results, deleted_usage_records)) + } + + /// 批量软删除匹配结果并重置资源使用状态 + pub async fn batch_soft_delete_matching_results_with_usage_reset( + &self, + result_ids: &[String], + material_usage_repo: Arc + ) -> Result<(u32, u32)> { + // 先删除使用记录并重置资源状态 + let deleted_usage_records = material_usage_repo.delete_usage_records_by_matching_results(result_ids)?; + + // 再软删除匹配结果 + let deleted_results = self.batch_soft_delete_matching_results(result_ids).await?; + + Ok((deleted_results, deleted_usage_records)) + } + /// 更新匹配结果名称和描述 pub async fn update_matching_result_info( &self, @@ -349,6 +405,10 @@ impl TemplateMatchingResultService { std::fs::write(&output_file_path, json_content) .map_err(|e| anyhow!("写入文件失败: {}", e))?; + // 更新导出状态 + self.repository.increment_export_count(result_id)?; + + println!("✅ 导出成功: {}", output_file_path); Ok(output_file_path) } @@ -396,6 +456,9 @@ impl TemplateMatchingResultService { std::fs::write(&output_file_path, json_content) .map_err(|e| anyhow!("写入文件失败 {}: {}", output_file_path, e))?; + // 更新导出状态 + self.repository.increment_export_count(result_id)?; + println!("✅ 导出成功: {}", output_file_path); Ok(output_file_path) } diff --git a/apps/desktop/src-tauri/src/data/models/template_matching_result.rs b/apps/desktop/src-tauri/src/data/models/template_matching_result.rs index e34b1a5..0e9eff9 100644 --- a/apps/desktop/src-tauri/src/data/models/template_matching_result.rs +++ b/apps/desktop/src-tauri/src/data/models/template_matching_result.rs @@ -23,6 +23,8 @@ pub struct TemplateMatchingResult { pub status: MatchingResultStatus, pub metadata: Option, // JSON格式的额外元数据 pub export_count: u32, // 导出次数 + pub is_exported: bool, // 是否已导出 + pub last_exported_at: Option>, // 最后导出时间 pub created_at: DateTime, pub updated_at: DateTime, pub is_active: bool, @@ -138,6 +140,8 @@ impl TemplateMatchingResult { status: MatchingResultStatus::default(), metadata: None, export_count: 0, + is_exported: false, + last_exported_at: None, created_at: now, updated_at: now, is_active: true, @@ -209,6 +213,21 @@ impl TemplateMatchingResult { MatchingResultStatus::Cancelled => "已取消".to_string(), } } + + /// 标记为已导出 + pub fn mark_as_exported(&mut self) { + self.is_exported = true; + self.export_count += 1; + self.last_exported_at = Some(Utc::now()); + self.updated_at = Utc::now(); + } + + /// 重置导出状态 + pub fn reset_export_status(&mut self) { + self.is_exported = false; + self.last_exported_at = None; + self.updated_at = Utc::now(); + } } impl MatchingSegmentResult { diff --git a/apps/desktop/src-tauri/src/data/repositories/material_usage_repository.rs b/apps/desktop/src-tauri/src/data/repositories/material_usage_repository.rs index 65fc018..875990a 100644 --- a/apps/desktop/src-tauri/src/data/repositories/material_usage_repository.rs +++ b/apps/desktop/src-tauri/src/data/repositories/material_usage_repository.rs @@ -185,8 +185,8 @@ impl MaterialUsageRepository { "SELECT id, material_segment_id, material_id, project_id, template_matching_result_id, template_id, binding_id, track_segment_id, usage_type, usage_context, created_at - FROM material_usage_records - WHERE template_matching_result_id = ?1 + FROM material_usage_records + WHERE template_matching_result_id = ?1 ORDER BY created_at DESC" )?; @@ -202,6 +202,52 @@ impl MaterialUsageRepository { Ok(records) } + /// 批量删除使用记录(按模板匹配结果ID) + pub fn delete_usage_records_by_matching_results(&self, matching_result_ids: &[String]) -> Result { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + // 开始事务 + let tx = conn.unchecked_transaction()?; + + let mut total_deleted = 0; + let mut affected_segment_ids = std::collections::HashSet::new(); + + for matching_result_id in matching_result_ids { + // 获取要删除的记录的片段ID + let mut stmt = tx.prepare( + "SELECT material_segment_id FROM material_usage_records WHERE template_matching_result_id = ?1" + )?; + + let segment_ids: Result, _> = stmt.query_map([matching_result_id], |row| { + Ok(row.get::<_, String>(0)?) + })?.collect(); + + let segment_ids = segment_ids?; + for segment_id in segment_ids { + affected_segment_ids.insert(segment_id); + } + + // 删除使用记录 + let deleted = tx.execute( + "DELETE FROM material_usage_records WHERE template_matching_result_id = ?1", + [matching_result_id], + )?; + + total_deleted += deleted as u32; + } + + // 重置所有受影响片段的使用状态 + for segment_id in &affected_segment_ids { + self.update_segment_usage_status_in_tx(&tx, segment_id)?; + } + + // 提交事务 + tx.commit()?; + + Ok(total_deleted) + } + /// 获取素材使用统计信息 pub fn get_material_usage_stats(&self, project_id: &str) -> Result> { let conn = self.database.get_connection(); diff --git a/apps/desktop/src-tauri/src/data/repositories/template_matching_result_repository.rs b/apps/desktop/src-tauri/src/data/repositories/template_matching_result_repository.rs index b2b04d9..60bf9ac 100644 --- a/apps/desktop/src-tauri/src/data/repositories/template_matching_result_repository.rs +++ b/apps/desktop/src-tauri/src/data/repositories/template_matching_result_repository.rs @@ -44,8 +44,9 @@ impl TemplateMatchingResultRepository { id, project_id, template_id, binding_id, result_name, description, total_segments, matched_segments, failed_segments, success_rate, used_materials, used_models, matching_duration_ms, quality_score, - status, metadata, export_count, created_at, updated_at, is_active - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", + status, metadata, export_count, is_exported, last_exported_at, + created_at, updated_at, is_active + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)", rusqlite::params![ &final_result.id, &final_result.project_id, @@ -64,6 +65,8 @@ impl TemplateMatchingResultRepository { &serde_json::to_string(&final_result.status).unwrap(), &final_result.metadata, &final_result.export_count, + &(final_result.is_exported as i32), + &final_result.last_exported_at.map(|dt| dt.to_rfc3339()), &final_result.created_at.to_rfc3339(), &final_result.updated_at.to_rfc3339(), &(final_result.is_active as i32), @@ -107,8 +110,9 @@ impl TemplateMatchingResultRepository { result_name = ?1, description = ?2, total_segments = ?3, matched_segments = ?4, failed_segments = ?5, success_rate = ?6, used_materials = ?7, used_models = ?8, matching_duration_ms = ?9, - quality_score = ?10, status = ?11, metadata = ?12, updated_at = ?13 - WHERE id = ?14", + quality_score = ?10, status = ?11, metadata = ?12, export_count = ?13, + is_exported = ?14, last_exported_at = ?15, updated_at = ?16 + WHERE id = ?17", rusqlite::params![ &result.result_name, &result.description, @@ -122,6 +126,9 @@ impl TemplateMatchingResultRepository { &result.quality_score.map(|s| s.to_string()), &serde_json::to_string(&result.status).unwrap(), &result.metadata, + &result.export_count.to_string(), + &(result.is_exported as i32), + &result.last_exported_at.map(|dt| dt.to_rfc3339()), &result.updated_at.to_rfc3339(), &result.id, ], @@ -478,6 +485,22 @@ impl TemplateMatchingResultRepository { _ => true, // 默认为 true }; + // 处理 is_exported 字段 + let is_exported = match row.get::<_, rusqlite::types::Value>("is_exported") { + Ok(rusqlite::types::Value::Integer(i)) => i != 0, + Ok(rusqlite::types::Value::Text(s)) => s == "1" || s.to_lowercase() == "true", + Ok(rusqlite::types::Value::Real(f)) => f != 0.0, + _ => false, // 默认为 false + }; + + // 处理 last_exported_at 字段 + let last_exported_at = match row.get::<_, Option>("last_exported_at") { + Ok(Some(date_str)) => DateTime::parse_from_rfc3339(&date_str) + .ok() + .map(|dt| dt.with_timezone(&Utc)), + _ => None, + }; + Ok(TemplateMatchingResult { id: row.get("id")?, project_id: row.get("project_id")?, @@ -496,6 +519,8 @@ impl TemplateMatchingResultRepository { status, metadata: row.get("metadata")?, export_count: row.get("export_count").unwrap_or(0), + is_exported, + last_exported_at, created_at, updated_at, is_active, @@ -562,14 +587,31 @@ impl TemplateMatchingResultRepository { }) } - /// 增加导出次数 + /// 增加导出次数并标记为已导出 pub fn increment_export_count(&self, result_id: &str) -> Result<()> { let conn = self.database.get_connection(); let conn = conn.lock().unwrap(); + let now = Utc::now(); + + conn.execute( + "UPDATE template_matching_results + SET export_count = export_count + 1, is_exported = 1, + last_exported_at = ?2, updated_at = ?3 + WHERE id = ?1", + rusqlite::params![result_id, now.to_rfc3339(), now.to_rfc3339()], + )?; + + Ok(()) + } + + /// 重置导出状态 + pub fn reset_export_status(&self, result_id: &str) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); conn.execute( "UPDATE template_matching_results - SET export_count = export_count + 1, updated_at = ?2 + SET is_exported = 0, last_exported_at = NULL, updated_at = ?2 WHERE id = ?1", rusqlite::params![result_id, Utc::now().to_rfc3339()], )?; diff --git a/apps/desktop/src-tauri/src/infrastructure/database.rs b/apps/desktop/src-tauri/src/infrastructure/database.rs index d40393e..827e735 100644 --- a/apps/desktop/src-tauri/src/infrastructure/database.rs +++ b/apps/desktop/src-tauri/src/infrastructure/database.rs @@ -1672,6 +1672,21 @@ impl Database { println!("Added export_count column to template_matching_results table"); } + // 添加导出状态字段到模板匹配结果表 + let has_is_exported_column = conn.prepare("SELECT is_exported FROM template_matching_results LIMIT 1").is_ok(); + if !has_is_exported_column { + println!("Adding export status columns to template_matching_results table"); + conn.execute( + "ALTER TABLE template_matching_results ADD COLUMN is_exported BOOLEAN DEFAULT 0", + [], + )?; + conn.execute( + "ALTER TABLE template_matching_results ADD COLUMN last_exported_at DATETIME", + [], + )?; + println!("Added export status columns to template_matching_results table"); + } + // 暂时禁用自动清理,避免启动时卡住 // self.cleanup_invalid_projects()?; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6ede251..fb0a309 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -215,6 +215,11 @@ pub fn run() { commands::template_matching_result_commands::list_matching_results, commands::template_matching_result_commands::delete_matching_result, commands::template_matching_result_commands::soft_delete_matching_result, + commands::template_matching_result_commands::batch_delete_matching_results, + commands::template_matching_result_commands::batch_soft_delete_matching_results, + commands::template_matching_result_commands::batch_delete_matching_results_with_usage_reset, + commands::template_matching_result_commands::batch_soft_delete_matching_results_with_usage_reset, + commands::template_matching_result_commands::reset_matching_result_export_status, commands::template_matching_result_commands::update_matching_result_info, commands::template_matching_result_commands::set_matching_result_quality_score, commands::template_matching_result_commands::get_matching_statistics, diff --git a/apps/desktop/src-tauri/src/presentation/commands/template_matching_result_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/template_matching_result_commands.rs index 8b27ff5..d4baf96 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/template_matching_result_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/template_matching_result_commands.rs @@ -153,6 +153,77 @@ pub async fn soft_delete_matching_result( .map_err(|e| e.to_string()) } +/// 批量删除匹配结果 +#[command] +pub async fn batch_delete_matching_results( + result_ids: Vec, + database: State<'_, Arc>, +) -> Result { + let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + let service = TemplateMatchingResultService::new(repository); + + service.batch_delete_matching_results(&result_ids) + .await + .map_err(|e| e.to_string()) +} + +/// 批量软删除匹配结果 +#[command] +pub async fn batch_soft_delete_matching_results( + result_ids: Vec, + database: State<'_, Arc>, +) -> Result { + let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + let service = TemplateMatchingResultService::new(repository); + + service.batch_soft_delete_matching_results(&result_ids) + .await + .map_err(|e| e.to_string()) +} + +/// 批量删除匹配结果并重置资源使用状态 +#[command] +pub async fn batch_delete_matching_results_with_usage_reset( + result_ids: Vec, + database: State<'_, Arc>, +) -> Result<(u32, u32), String> { + let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + let material_usage_repo = Arc::new(crate::data::repositories::material_usage_repository::MaterialUsageRepository::new(database.inner().clone())); + let service = TemplateMatchingResultService::new(repository); + + service.batch_delete_matching_results_with_usage_reset(&result_ids, material_usage_repo) + .await + .map_err(|e| e.to_string()) +} + +/// 批量软删除匹配结果并重置资源使用状态 +#[command] +pub async fn batch_soft_delete_matching_results_with_usage_reset( + result_ids: Vec, + database: State<'_, Arc>, +) -> Result<(u32, u32), String> { + let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + let material_usage_repo = Arc::new(crate::data::repositories::material_usage_repository::MaterialUsageRepository::new(database.inner().clone())); + let service = TemplateMatchingResultService::new(repository); + + service.batch_soft_delete_matching_results_with_usage_reset(&result_ids, material_usage_repo) + .await + .map_err(|e| e.to_string()) +} + +/// 重置匹配结果的导出状态 +#[command] +pub async fn reset_matching_result_export_status( + result_id: String, + database: State<'_, Arc>, +) -> Result { + let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); + + repository.reset_export_status(&result_id) + .map(|_| true) + .map_err(|e| e.to_string()) +} + /// 更新匹配结果信息 #[command] pub async fn update_matching_result_info( diff --git a/apps/desktop/src/components/TemplateMatchingResultCard.tsx b/apps/desktop/src/components/TemplateMatchingResultCard.tsx index 5b506e8..eb6170e 100644 --- a/apps/desktop/src/components/TemplateMatchingResultCard.tsx +++ b/apps/desktop/src/components/TemplateMatchingResultCard.tsx @@ -8,6 +8,9 @@ interface TemplateMatchingResultCardProps { onEdit?: () => void; onExportToJianying?: () => void; onExportToJianyingV2?: () => void; + isSelected?: boolean; + onToggleSelect?: () => void; + showExportStatus?: boolean; } export const TemplateMatchingResultCard: React.FC = ({ @@ -16,6 +19,9 @@ export const TemplateMatchingResultCard: React.FC { // 格式化时长显示 const formatDuration = (ms: number): string => { @@ -77,14 +83,39 @@ export const TemplateMatchingResultCard: React.FC +
{/* 卡片头部 */}
+ {/* 选择框 */} + {onToggleSelect && ( +
+ e.stopPropagation()} + /> +
+ )} +
-

- {result.result_name} -

+
+

+ {result.result_name} +

+ {/* 导出状态标识 */} + {showExportStatus && ( + + {result.is_exported ? '✓ 已导出' : '○ 未导出'} + + )} +
{result.description && (

{result.description} diff --git a/apps/desktop/src/components/TemplateMatchingResultManager.tsx b/apps/desktop/src/components/TemplateMatchingResultManager.tsx index dad1361..5ba3c23 100644 --- a/apps/desktop/src/components/TemplateMatchingResultManager.tsx +++ b/apps/desktop/src/components/TemplateMatchingResultManager.tsx @@ -43,6 +43,14 @@ export const TemplateMatchingResultManager: React.FC({ show: false, result: null }); + // 批量操作状态 + const [selectedResults, setSelectedResults] = useState>(new Set()); + const [batchDeleteConfirm, setBatchDeleteConfirm] = useState<{ + show: boolean; + resultIds: string[]; + }>({ show: false, resultIds: [] }); + const [batchOperationLoading, setBatchOperationLoading] = useState(false); + // 过滤和排序状态 const [filters, setFilters] = useState<{ status?: MatchingResultStatus; @@ -122,6 +130,51 @@ export const TemplateMatchingResultManager: React.FC { + setBatchOperationLoading(true); + try { + const [deletedResults, deletedUsageRecords] = await invoke<[number, number]>( + 'batch_soft_delete_matching_results_with_usage_reset', + { resultIds } + ); + + success(`成功删除 ${deletedResults} 个匹配结果,重置 ${deletedUsageRecords} 条使用记录`); + + // 重新加载列表 + await loadResults(); + await loadStatistics(); + + // 清空选择 + setSelectedResults(new Set()); + setBatchDeleteConfirm({ show: false, resultIds: [] }); + } catch (err) { + setError(`批量删除失败: ${err}`); + } finally { + setBatchOperationLoading(false); + } + }; + + // 切换选择状态 + const handleToggleSelect = (resultId: string) => { + const newSelected = new Set(selectedResults); + if (newSelected.has(resultId)) { + newSelected.delete(resultId); + } else { + newSelected.add(resultId); + } + setSelectedResults(newSelected); + }; + + // 全选/取消全选 + const handleToggleSelectAll = () => { + if (selectedResults.size === results.length) { + setSelectedResults(new Set()); + } else { + setSelectedResults(new Set(results.map(r => r.id))); + } + }; + // 查看详情 const handleViewDetail = (result: TemplateMatchingResult) => { setSelectedResult(result); @@ -305,18 +358,48 @@ export const TemplateMatchingResultManager: React.FC -

- 共 {pagination.total} 个匹配结果 +
+
+ 共 {pagination.total} 个匹配结果 +
+ {selectedResults.size > 0 && ( +
+ 已选择 {selectedResults.size} 个 +
+ )} +
+
+ {/* 批量操作按钮 */} + {selectedResults.size > 0 && ( + <> + + + + )} +
-
@@ -336,17 +419,57 @@ export const TemplateMatchingResultManager: React.FC ) : ( -
- {results.map((result) => ( - handleViewDetail(result)} - onDelete={() => setDeleteConfirm({ show: true, result })} - onExportToJianying={() => handleExportToJianying(result)} - onExportToJianyingV2={() => handleExportToJianyingV2(result)} - /> - ))} +
+ {/* 列表控制栏 */} +
+
+ + {selectedResults.size > 0 && ( + + 已选择 {selectedResults.size} / {results.length} 个结果 + + )} +
+
+ + 显示导出状态 + +
+ + ✓ 已导出 + + + ○ 未导出 + +
+
+
+ +
+ {results.map((result) => ( + handleViewDetail(result)} + onDelete={() => setDeleteConfirm({ show: true, result })} + onExportToJianying={() => handleExportToJianying(result)} + onExportToJianyingV2={() => handleExportToJianyingV2(result)} + isSelected={selectedResults.has(result.id)} + onToggleSelect={() => handleToggleSelect(result.id)} + showExportStatus={true} + /> + ))} +
)} @@ -398,6 +521,15 @@ export const TemplateMatchingResultManager: React.FC deleteConfirm.result && handleDelete(deleteConfirm.result)} onCancel={() => setDeleteConfirm({ show: false, result: null })} /> + + {/* 批量删除确认对话框 */} + handleBatchDelete(batchDeleteConfirm.resultIds)} + onCancel={() => setBatchDeleteConfirm({ show: false, resultIds: [] })} + />
); }; diff --git a/apps/desktop/src/types/templateMatchingResult.ts b/apps/desktop/src/types/templateMatchingResult.ts index f6d4759..122bbb0 100644 --- a/apps/desktop/src/types/templateMatchingResult.ts +++ b/apps/desktop/src/types/templateMatchingResult.ts @@ -31,6 +31,8 @@ export interface TemplateMatchingResult { status: MatchingResultStatus; metadata?: string; export_count: number; + is_exported: boolean; + last_exported_at?: string; created_at: string; updated_at: string; is_active: boolean;