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:
@@ -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))
|
||||
}
|
||||
|
||||
/// 计算批量匹配汇总信息
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()],
|
||||
)?;
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user