feat: 完成模板匹配功能优化

- 修复重复资源使用问题:
  * 修改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表
  * 保持向后兼容性
This commit is contained in:
imeepos
2025-07-18 12:50:04 +08:00
parent 822bfe6e9c
commit f6041c6eea
11 changed files with 602 additions and 128 deletions

View File

@@ -321,11 +321,22 @@ impl MaterialMatchingService {
materials: &[Material],
classification_records: &HashMap<String, Vec<VideoClassificationRecord>>,
project_id: &str,
) -> Result<Vec<(MaterialSegment, String)>> {
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<String, Vec<VideoClassificationRecord>>,
project_id: &str,
additional_used_segments: &HashSet<String>,
) -> Result<Vec<(MaterialSegment, String)>> {
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<String, u32> = 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<String, Vec<VideoClassificationRecord>>,
project_id: &str,
additional_used_segments: &HashSet<String>,
) -> Result<Vec<(MaterialSegment, String)>> {
// 获取数据库中已使用的素材片段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::<HashSet<String>>()
}
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<String>,
) -> Result<(MaterialMatchingResult, Option<crate::data::models::template_matching_result::TemplateMatchingResult>, HashSet<String>)> {
// 这里需要实现一个修改版的匹配逻辑,考虑额外的已使用素材
// 为了简化,暂时使用现有的匹配方法,但这需要进一步优化
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<String> = 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))
}
/// 计算批量匹配汇总信息

View File

@@ -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<u32> {
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<u32> {
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<crate::data::repositories::material_usage_repository::MaterialUsageRepository>
) -> 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<crate::data::repositories::material_usage_repository::MaterialUsageRepository>
) -> 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)
}

View File

@@ -23,6 +23,8 @@ pub struct TemplateMatchingResult {
pub status: MatchingResultStatus,
pub metadata: Option<String>, // JSON格式的额外元数据
pub export_count: u32, // 导出次数
pub is_exported: bool, // 是否已导出
pub last_exported_at: Option<DateTime<Utc>>, // 最后导出时间
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
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 {

View File

@@ -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<u32> {
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<Vec<String>, _> = 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<Vec<MaterialUsageStats>> {
let conn = self.database.get_connection();

View File

@@ -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<String>>("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()],
)?;

View File

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

View File

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

View File

@@ -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<String>,
database: State<'_, Arc<Database>>,
) -> Result<u32, String> {
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<String>,
database: State<'_, Arc<Database>>,
) -> Result<u32, String> {
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<String>,
database: State<'_, Arc<Database>>,
) -> 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<String>,
database: State<'_, Arc<Database>>,
) -> 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<Database>>,
) -> Result<bool, String> {
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(

View File

@@ -8,6 +8,9 @@ interface TemplateMatchingResultCardProps {
onEdit?: () => void;
onExportToJianying?: () => void;
onExportToJianyingV2?: () => void;
isSelected?: boolean;
onToggleSelect?: () => void;
showExportStatus?: boolean;
}
export const TemplateMatchingResultCard: React.FC<TemplateMatchingResultCardProps> = ({
@@ -16,6 +19,9 @@ export const TemplateMatchingResultCard: React.FC<TemplateMatchingResultCardProp
onDelete,
onEdit,
onExportToJianyingV2,
isSelected = false,
onToggleSelect,
showExportStatus = false,
}) => {
// 格式化时长显示
const formatDuration = (ms: number): string => {
@@ -77,14 +83,39 @@ export const TemplateMatchingResultCard: React.FC<TemplateMatchingResultCardProp
};
return (
<div className="card card-interactive group">
<div className={`card card-interactive group ${isSelected ? 'ring-2 ring-blue-500 bg-blue-50' : ''}`}>
{/* 卡片头部 */}
<div className="card-header">
<div className="flex items-start justify-between">
{/* 选择框 */}
{onToggleSelect && (
<div className="flex items-center mr-3">
<input
type="checkbox"
checked={isSelected}
onChange={onToggleSelect}
className="form-checkbox h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate group-hover:text-primary-600 transition-colors">
{result.result_name}
</h3>
<div className="flex items-center space-x-2">
<h3 className="text-lg font-semibold text-gray-900 truncate group-hover:text-primary-600 transition-colors">
{result.result_name}
</h3>
{/* 导出状态标识 */}
{showExportStatus && (
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
result.is_exported
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{result.is_exported ? '✓ 已导出' : '○ 未导出'}
</span>
)}
</div>
{result.description && (
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
{result.description}

View File

@@ -43,6 +43,14 @@ export const TemplateMatchingResultManager: React.FC<TemplateMatchingResultManag
result: TemplateMatchingResult | null;
}>({ show: false, result: null });
// 批量操作状态
const [selectedResults, setSelectedResults] = useState<Set<string>>(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<TemplateMatchingResultManag
}
};
// 批量删除匹配结果
const handleBatchDelete = async (resultIds: string[]) => {
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<TemplateMatchingResultManag
{/* 操作按钮 */}
<div className="flex justify-between items-center mt-6 pt-4 border-t border-gray-100">
<div className="text-sm text-gray-600 font-medium">
{pagination.total}
<div className="flex items-center space-x-4">
<div className="text-sm text-gray-600 font-medium">
{pagination.total}
</div>
{selectedResults.size > 0 && (
<div className="text-sm text-blue-600 font-medium">
{selectedResults.size}
</div>
)}
</div>
<div className="flex items-center space-x-3">
{/* 批量操作按钮 */}
{selectedResults.size > 0 && (
<>
<button
onClick={() => setBatchDeleteConfirm({
show: true,
resultIds: Array.from(selectedResults)
})}
disabled={batchOperationLoading}
className="btn btn-danger btn-sm"
>
{batchOperationLoading ? '删除中...' : `批量删除 (${selectedResults.size})`}
</button>
<button
onClick={() => setSelectedResults(new Set())}
className="btn btn-secondary btn-sm"
>
</button>
</>
)}
<button
onClick={() => {
loadResults();
loadStatistics();
}}
className="btn btn-secondary btn-sm"
>
</button>
</div>
<button
onClick={() => {
loadResults();
loadStatistics();
}}
className="btn btn-secondary btn-sm"
>
</button>
</div>
</div>
@@ -336,17 +419,57 @@ export const TemplateMatchingResultManager: React.FC<TemplateMatchingResultManag
icon="📊"
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{results.map((result) => (
<TemplateMatchingResultCard
key={result.id}
result={result}
onViewDetail={() => handleViewDetail(result)}
onDelete={() => setDeleteConfirm({ show: true, result })}
onExportToJianying={() => handleExportToJianying(result)}
onExportToJianyingV2={() => handleExportToJianyingV2(result)}
/>
))}
<div className="space-y-4">
{/* 列表控制栏 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border border-gray-200/50 p-4">
<div className="flex items-center space-x-3">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={selectedResults.size === results.length && results.length > 0}
onChange={handleToggleSelectAll}
className="form-checkbox h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700">
{selectedResults.size === results.length && results.length > 0 ? '取消全选' : '全选'}
</span>
</label>
{selectedResults.size > 0 && (
<span className="text-sm text-gray-500">
{selectedResults.size} / {results.length}
</span>
)}
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">
</span>
<div className="flex items-center space-x-1">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
</span>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{results.map((result) => (
<TemplateMatchingResultCard
key={result.id}
result={result}
onViewDetail={() => handleViewDetail(result)}
onDelete={() => setDeleteConfirm({ show: true, result })}
onExportToJianying={() => handleExportToJianying(result)}
onExportToJianyingV2={() => handleExportToJianyingV2(result)}
isSelected={selectedResults.has(result.id)}
onToggleSelect={() => handleToggleSelect(result.id)}
showExportStatus={true}
/>
))}
</div>
</div>
)}
@@ -398,6 +521,15 @@ export const TemplateMatchingResultManager: React.FC<TemplateMatchingResultManag
onConfirm={() => deleteConfirm.result && handleDelete(deleteConfirm.result)}
onCancel={() => setDeleteConfirm({ show: false, result: null })}
/>
{/* 批量删除确认对话框 */}
<DeleteConfirmDialog
isOpen={batchDeleteConfirm.show}
title="批量删除匹配结果"
message={`确定要删除选中的 ${batchDeleteConfirm.resultIds.length} 个匹配结果吗?此操作将同时重置相关资源的使用状态,且不可撤销。`}
onConfirm={() => handleBatchDelete(batchDeleteConfirm.resultIds)}
onCancel={() => setBatchDeleteConfirm({ show: false, resultIds: [] })}
/>
</div>
);
};

View File

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