feat: 实现模板匹配结果保存和管理功能

- 新增模板匹配结果数据模型和数据库表结构
- 实现匹配结果Repository层,支持CRUD操作和查询
- 实现匹配结果Service层,提供业务逻辑和统计功能
- 新增Tauri命令接口,支持前端调用
- 实现前端TypeScript类型定义
- 更新MaterialMatchingService,支持自动保存匹配结果
- 新增前端管理界面组件:
  - TemplateMatchingResultManager: 主管理界面
  - TemplateMatchingResultCard: 结果卡片组件
  - TemplateMatchingResultDetailModal: 详情模态框
  - TemplateMatchingResultStatsPanel: 统计面板
- 编写完整的单元测试
- 新增API文档

功能特性:
- 保存匹配结果到数据库,包含成功和失败片段详情
- 支持匹配结果的查询、过滤、排序和分页
- 提供匹配统计信息和质量评分
- 支持软删除和批量操作
- 完整的前端管理界面,支持查看、编辑、删除操作
This commit is contained in:
imeepos
2025-07-16 13:34:32 +08:00
parent 1eebc98853
commit 1d6de409ed
21 changed files with 3895 additions and 1 deletions

View File

@@ -11,8 +11,10 @@ use crate::data::models::{
use crate::data::repositories::{
material_repository::MaterialRepository,
video_classification_repository::VideoClassificationRepository,
template_matching_result_repository::TemplateMatchingResultRepository,
};
use crate::business::services::template_service::TemplateService;
use crate::business::services::template_matching_result_service::TemplateMatchingResultService;
use anyhow::{Result, anyhow};
use serde::{Serialize, Deserialize};
use std::collections::{HashMap, HashSet};
@@ -23,6 +25,7 @@ pub struct MaterialMatchingService {
material_repo: Arc<MaterialRepository>,
template_service: Arc<TemplateService>,
video_classification_repo: Arc<VideoClassificationRepository>,
matching_result_service: Option<Arc<TemplateMatchingResultService>>,
}
/// 素材匹配请求
@@ -89,6 +92,22 @@ impl MaterialMatchingService {
material_repo,
template_service,
video_classification_repo,
matching_result_service: None,
}
}
/// 创建新的素材匹配服务实例(带结果保存功能)
pub fn new_with_result_service(
material_repo: Arc<MaterialRepository>,
template_service: Arc<TemplateService>,
video_classification_repo: Arc<VideoClassificationRepository>,
matching_result_service: Arc<TemplateMatchingResultService>,
) -> Self {
Self {
material_repo,
template_service,
video_classification_repo,
matching_result_service: Some(matching_result_service),
}
}
@@ -193,6 +212,41 @@ impl MaterialMatchingService {
})
}
/// 执行素材匹配并自动保存结果到数据库
pub async fn match_materials_and_save(
&self,
request: MaterialMatchingRequest,
result_name: String,
description: Option<String>,
) -> Result<(MaterialMatchingResult, Option<crate::data::models::template_matching_result::TemplateMatchingResult>)> {
let start_time = std::time::Instant::now();
// 执行匹配
let matching_result = self.match_materials(request).await?;
let matching_duration_ms = start_time.elapsed().as_millis() as u64;
// 如果配置了结果保存服务,则自动保存结果
if let Some(result_service) = &self.matching_result_service {
match result_service.save_matching_result(
&matching_result,
result_name,
description,
matching_duration_ms,
).await {
Ok(saved_result) => {
return Ok((matching_result, Some(saved_result)));
}
Err(e) => {
// 保存失败时记录错误但不影响匹配结果的返回
eprintln!("保存匹配结果失败: {}", e);
}
}
}
Ok((matching_result, None))
}
/// 获取已分类的素材片段
async fn get_classified_segments(
&self,

View File

@@ -18,6 +18,7 @@ pub mod performance_monitor;
pub mod enhanced_template_import_service;
pub mod project_template_binding_service;
pub mod material_matching_service;
pub mod template_matching_result_service;
#[cfg(test)]
pub mod tests;

View File

@@ -0,0 +1,284 @@
use anyhow::{Result, anyhow};
use std::sync::Arc;
use chrono::Utc;
use crate::data::models::template_matching_result::{
TemplateMatchingResult, MatchingSegmentResult, MatchingFailedSegmentResult,
CreateTemplateMatchingResultRequest, TemplateMatchingResultQueryOptions,
MatchingResultStatus,
};
use crate::data::repositories::template_matching_result_repository::TemplateMatchingResultRepository;
use crate::business::services::material_matching_service::{
MaterialMatchingResult as ServiceMatchingResult, SegmentMatch, FailedSegmentMatch,
};
/// 模板匹配结果服务
/// 遵循 Tauri 开发规范的业务逻辑设计原则
pub struct TemplateMatchingResultService {
repository: Arc<TemplateMatchingResultRepository>,
}
impl TemplateMatchingResultService {
/// 创建新的模板匹配结果服务实例
pub fn new(repository: Arc<TemplateMatchingResultRepository>) -> Self {
Self { repository }
}
/// 保存匹配结果到数据库
/// 这是一个完整的事务操作,保存主结果和所有相关的片段信息
pub async fn save_matching_result(
&self,
service_result: &ServiceMatchingResult,
result_name: String,
description: Option<String>,
matching_duration_ms: u64,
) -> Result<TemplateMatchingResult> {
// 创建主匹配结果记录
let create_request = CreateTemplateMatchingResultRequest {
project_id: service_result.project_id.clone(),
template_id: service_result.template_id.clone(),
binding_id: service_result.binding_id.clone(),
result_name,
description,
};
let mut matching_result = self.repository.create(create_request)?;
// 更新统计信息
let total_segments = service_result.matches.len() + service_result.failed_segments.len();
let matched_segments = service_result.matches.len();
let failed_segments = service_result.failed_segments.len();
// 计算使用的素材和模特数量
let mut used_material_ids = std::collections::HashSet::new();
let mut used_model_names = std::collections::HashSet::new();
for segment_match in &service_result.matches {
used_material_ids.insert(segment_match.material_segment.material_id.clone());
if let Some(model_name) = &segment_match.model_name {
used_model_names.insert(model_name.clone());
}
}
matching_result.update_statistics(
total_segments as u32,
matched_segments as u32,
failed_segments as u32,
used_material_ids.len() as u32,
used_model_names.len() as u32,
matching_duration_ms,
);
// 更新主记录
self.repository.update(&matching_result)?;
// 保存成功匹配的片段
let segment_results: Vec<MatchingSegmentResult> = service_result.matches
.iter()
.map(|segment_match| {
let mut segment_result = MatchingSegmentResult::new(
uuid::Uuid::new_v4().to_string(),
matching_result.id.clone(),
segment_match.track_segment_id.clone(),
segment_match.track_segment_name.clone(),
segment_match.material_segment_id.clone(),
segment_match.material_segment.material_id.clone(),
segment_match.material_name.clone(),
segment_match.match_score,
segment_match.match_reason.clone(),
(segment_match.material_segment.duration * 1_000_000.0) as u64,
(segment_match.material_segment.start_time * 1_000_000.0) as u64,
(segment_match.material_segment.end_time * 1_000_000.0) as u64,
);
// 设置模特信息
if let Some(model_name) = &segment_match.model_name {
// 这里可以通过模特名称查找模特ID暂时使用名称作为ID
segment_result.set_model_info(model_name.clone(), model_name.clone());
}
segment_result
})
.collect();
if !segment_results.is_empty() {
self.repository.create_segment_results(&segment_results)?;
}
// 保存失败的片段
let failed_segment_results: Vec<MatchingFailedSegmentResult> = service_result.failed_segments
.iter()
.map(|failed_segment| {
let mut failed_result = MatchingFailedSegmentResult::new(
uuid::Uuid::new_v4().to_string(),
matching_result.id.clone(),
failed_segment.track_segment_id.clone(),
failed_segment.track_segment_name.clone(),
failed_segment.matching_rule.display_name(),
failed_segment.failure_reason.clone(),
0, // 这里需要从模板中获取片段时长
0, // 这里需要从模板中获取开始时间
0, // 这里需要从模板中获取结束时间
);
// 设置匹配规则数据
if let Ok(rule_json) = serde_json::to_string(&failed_segment.matching_rule) {
failed_result.set_matching_rule_data(rule_json);
}
failed_result
})
.collect();
if !failed_segment_results.is_empty() {
self.repository.create_failed_segment_results(&failed_segment_results)?;
}
Ok(matching_result)
}
/// 获取匹配结果详情(包含所有片段信息)
pub async fn get_matching_result_detail(&self, result_id: &str) -> Result<Option<TemplateMatchingResultDetail>> {
let matching_result = match self.repository.get_by_id(result_id)? {
Some(result) => result,
None => return Ok(None),
};
let segment_results = self.repository.get_segment_results_by_matching_result_id(result_id)?;
let failed_segment_results = self.repository.get_failed_segment_results_by_matching_result_id(result_id)?;
Ok(Some(TemplateMatchingResultDetail {
matching_result,
segment_results,
failed_segment_results,
}))
}
/// 获取项目的匹配结果列表
pub async fn get_project_matching_results(&self, project_id: &str) -> Result<Vec<TemplateMatchingResult>> {
Ok(self.repository.get_by_project_id(project_id)?)
}
/// 获取模板的匹配结果列表
pub async fn get_template_matching_results(&self, template_id: &str) -> Result<Vec<TemplateMatchingResult>> {
Ok(self.repository.get_by_template_id(template_id)?)
}
/// 获取绑定的匹配结果列表
pub async fn get_binding_matching_results(&self, binding_id: &str) -> Result<Vec<TemplateMatchingResult>> {
Ok(self.repository.get_by_binding_id(binding_id)?)
}
/// 查询匹配结果列表
pub async fn list_matching_results(&self, options: TemplateMatchingResultQueryOptions) -> Result<Vec<TemplateMatchingResult>> {
Ok(self.repository.list(options)?)
}
/// 删除匹配结果
pub async fn delete_matching_result(&self, result_id: &str) -> Result<bool> {
Ok(self.repository.delete(result_id)?)
}
/// 软删除匹配结果
pub async fn soft_delete_matching_result(&self, result_id: &str) -> Result<bool> {
Ok(self.repository.soft_delete(result_id)?)
}
/// 更新匹配结果名称和描述
pub async fn update_matching_result_info(
&self,
result_id: &str,
result_name: Option<String>,
description: Option<String>,
) -> Result<Option<TemplateMatchingResult>> {
let mut matching_result = match self.repository.get_by_id(result_id)? {
Some(result) => result,
None => return Ok(None),
};
let mut updated = false;
if let Some(name) = result_name {
matching_result.result_name = name;
updated = true;
}
if let Some(desc) = description {
matching_result.set_description(desc);
updated = true;
}
if updated {
matching_result.updated_at = Utc::now();
self.repository.update(&matching_result)?;
}
Ok(Some(matching_result))
}
/// 设置匹配结果质量评分
pub async fn set_quality_score(&self, result_id: &str, quality_score: f64) -> Result<Option<TemplateMatchingResult>> {
let mut matching_result = match self.repository.get_by_id(result_id)? {
Some(result) => result,
None => return Ok(None),
};
matching_result.set_quality_score(quality_score);
self.repository.update(&matching_result)?;
Ok(Some(matching_result))
}
/// 获取匹配结果统计信息
pub async fn get_matching_statistics(&self, project_id: Option<&str>) -> Result<MatchingStatistics> {
let options = TemplateMatchingResultQueryOptions {
project_id: project_id.map(|id| id.to_string()),
..Default::default()
};
let results = self.repository.list(options)?;
let total_results = results.len();
let successful_results = results.iter().filter(|r| r.is_successful()).count();
let total_segments: u32 = results.iter().map(|r| r.total_segments).sum();
let matched_segments: u32 = results.iter().map(|r| r.matched_segments).sum();
let total_materials: u32 = results.iter().map(|r| r.used_materials).sum();
let total_models: u32 = results.iter().map(|r| r.used_models).sum();
let average_success_rate = if total_results > 0 {
results.iter().map(|r| r.success_rate).sum::<f64>() / total_results as f64
} else {
0.0
};
Ok(MatchingStatistics {
total_results,
successful_results,
total_segments,
matched_segments,
total_materials,
total_models,
average_success_rate,
})
}
}
/// 模板匹配结果详情
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TemplateMatchingResultDetail {
pub matching_result: TemplateMatchingResult,
pub segment_results: Vec<MatchingSegmentResult>,
pub failed_segment_results: Vec<MatchingFailedSegmentResult>,
}
/// 匹配统计信息
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MatchingStatistics {
pub total_results: usize,
pub successful_results: usize,
pub total_segments: u32,
pub matched_segments: u32,
pub total_materials: u32,
pub total_models: u32,
pub average_success_rate: f64,
}

View File

@@ -1,5 +1,6 @@
pub mod draft_parser_tests;
pub mod cloud_upload_service_tests;
pub mod template_matching_result_service_tests;
// 测试工具函数
pub mod test_utils {

View File

@@ -0,0 +1,413 @@
#[cfg(test)]
mod tests {
use super::super::super::template_matching_result_service::{
TemplateMatchingResultService, TemplateMatchingResultDetail, MatchingStatistics,
};
use crate::data::models::template_matching_result::{
TemplateMatchingResult, MatchingSegmentResult, MatchingFailedSegmentResult,
CreateTemplateMatchingResultRequest, TemplateMatchingResultQueryOptions,
MatchingResultStatus,
};
use crate::data::repositories::template_matching_result_repository::TemplateMatchingResultRepository;
use crate::business::services::material_matching_service::{
MaterialMatchingResult as ServiceMatchingResult, SegmentMatch, FailedSegmentMatch,
MatchingStatistics as ServiceMatchingStatistics,
};
use crate::data::models::template::SegmentMatchingRule;
use crate::data::models::material::MaterialSegment;
use crate::infrastructure::database::Database;
use std::sync::Arc;
use tempfile::TempDir;
use chrono::Utc;
/// 创建测试数据库
async fn create_test_database() -> Arc<Database> {
let database = Database::new().expect("Failed to create database");
Arc::new(database)
}
/// 创建测试用的素材匹配结果
fn create_test_service_matching_result() -> ServiceMatchingResult {
let material_segment = MaterialSegment {
id: "segment-1".to_string(),
material_id: "material-1".to_string(),
segment_index: 0,
start_time: 0.0,
end_time: 10.0,
duration: 10.0,
file_path: "/test/video.mp4".to_string(),
file_size: 1024,
thumbnail_path: None,
created_at: Utc::now(),
};
let segment_match = SegmentMatch {
track_segment_id: "track-segment-1".to_string(),
track_segment_name: "测试轨道片段".to_string(),
material_segment_id: "segment-1".to_string(),
material_segment,
material_name: "测试素材".to_string(),
model_name: Some("测试模特".to_string()),
match_score: 0.95,
match_reason: "AI分类匹配".to_string(),
};
let failed_segment = FailedSegmentMatch {
track_segment_id: "track-segment-2".to_string(),
track_segment_name: "失败轨道片段".to_string(),
matching_rule: SegmentMatchingRule::AiClassification {
category_id: "category-1".to_string(),
category_name: "全身".to_string(),
},
failure_reason: "没有找到匹配的素材".to_string(),
};
let statistics = ServiceMatchingStatistics {
total_segments: 2,
matched_segments: 1,
failed_segments: 1,
success_rate: 50.0,
used_materials: 1,
used_models: 1,
};
ServiceMatchingResult {
binding_id: "binding-1".to_string(),
template_id: "template-1".to_string(),
project_id: "project-1".to_string(),
matches: vec![segment_match],
statistics,
failed_segments: vec![failed_segment],
}
}
#[tokio::test]
async fn test_save_matching_result() {
let database = create_test_database().await;
// 先创建必要的依赖数据
let conn = database.get_connection();
let conn = conn.lock().unwrap();
// 创建项目
let unique_path = format!("/test/path/{}", uuid::Uuid::new_v4());
conn.execute(
"INSERT INTO projects (id, name, path, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
["project-1", "测试项目", &unique_path, "2024-01-01T00:00:00Z", "2024-01-01T00:00:00Z"],
).unwrap();
// 创建模板
conn.execute(
"INSERT INTO templates (id, name, description, canvas_width, canvas_height, canvas_ratio, duration, fps, import_status, source_file_path, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
["template-1", "测试模板", "测试模板描述", "1920", "1080", "16:9", "10000000", "30.0", "Completed", "/test/template.json", "2024-01-01T00:00:00Z", "2024-01-01T00:00:00Z"],
).unwrap();
// 创建绑定
conn.execute(
"INSERT INTO project_template_bindings (id, project_id, template_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
["binding-1", "project-1", "template-1", "2024-01-01T00:00:00Z", "2024-01-01T00:00:00Z"],
).unwrap();
drop(conn);
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
let service = TemplateMatchingResultService::new(repository);
let service_result = create_test_service_matching_result();
let result_name = "测试匹配结果".to_string();
let description = Some("这是一个测试匹配结果".to_string());
let matching_duration_ms = 1500;
let result = service.save_matching_result(
&service_result,
result_name.clone(),
description.clone(),
matching_duration_ms,
).await;
if let Err(e) = &result {
println!("Error saving matching result: {}", e);
}
assert!(result.is_ok());
let saved_result = result.unwrap();
assert_eq!(saved_result.result_name, result_name);
assert_eq!(saved_result.description, description);
assert_eq!(saved_result.project_id, service_result.project_id);
assert_eq!(saved_result.template_id, service_result.template_id);
assert_eq!(saved_result.binding_id, service_result.binding_id);
assert_eq!(saved_result.total_segments, 2);
assert_eq!(saved_result.matched_segments, 1);
assert_eq!(saved_result.failed_segments, 1);
assert_eq!(saved_result.success_rate, 50.0);
assert_eq!(saved_result.used_materials, 1);
assert_eq!(saved_result.used_models, 1);
assert_eq!(saved_result.matching_duration_ms, matching_duration_ms);
assert_eq!(saved_result.status, MatchingResultStatus::PartialSuccess);
}
#[tokio::test]
async fn test_get_matching_result_detail() {
let database = create_test_database().await;
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
let service = TemplateMatchingResultService::new(repository);
// 先保存一个匹配结果
let service_result = create_test_service_matching_result();
let saved_result = service.save_matching_result(
&service_result,
"测试匹配结果".to_string(),
Some("测试描述".to_string()),
1000,
).await.unwrap();
// 获取详情
let detail_result = service.get_matching_result_detail(&saved_result.id).await;
assert!(detail_result.is_ok());
let detail = detail_result.unwrap();
assert!(detail.is_some());
let detail = detail.unwrap();
assert_eq!(detail.matching_result.id, saved_result.id);
assert_eq!(detail.segment_results.len(), 1);
assert_eq!(detail.failed_segment_results.len(), 1);
// 验证成功片段
let segment_result = &detail.segment_results[0];
assert_eq!(segment_result.track_segment_name, "测试轨道片段");
assert_eq!(segment_result.material_name, "测试素材");
assert_eq!(segment_result.model_name, Some("测试模特".to_string()));
assert_eq!(segment_result.match_score, 0.95);
// 验证失败片段
let failed_segment = &detail.failed_segment_results[0];
assert_eq!(failed_segment.track_segment_name, "失败轨道片段");
assert_eq!(failed_segment.failure_reason, "没有找到匹配的素材");
}
#[tokio::test]
async fn test_get_project_matching_results() {
let database = create_test_database().await;
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
let service = TemplateMatchingResultService::new(repository);
let project_id = "test-project-1";
// 保存两个匹配结果
let mut service_result1 = create_test_service_matching_result();
service_result1.project_id = project_id.to_string();
let mut service_result2 = create_test_service_matching_result();
service_result2.project_id = project_id.to_string();
service_result2.template_id = "template-2".to_string();
service.save_matching_result(&service_result1, "结果1".to_string(), None, 1000).await.unwrap();
service.save_matching_result(&service_result2, "结果2".to_string(), None, 1500).await.unwrap();
// 获取项目的匹配结果
let results = service.get_project_matching_results(project_id).await;
assert!(results.is_ok());
let results = results.unwrap();
assert_eq!(results.len(), 2);
assert!(results.iter().all(|r| r.project_id == project_id));
}
#[tokio::test]
async fn test_delete_matching_result() {
let database = create_test_database().await;
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
let service = TemplateMatchingResultService::new(repository);
// 保存一个匹配结果
let service_result = create_test_service_matching_result();
let saved_result = service.save_matching_result(
&service_result,
"待删除结果".to_string(),
None,
1000,
).await.unwrap();
// 删除结果
let delete_result = service.delete_matching_result(&saved_result.id).await;
assert!(delete_result.is_ok());
assert!(delete_result.unwrap());
// 验证已删除
let detail_result = service.get_matching_result_detail(&saved_result.id).await;
assert!(detail_result.is_ok());
assert!(detail_result.unwrap().is_none());
}
#[tokio::test]
async fn test_soft_delete_matching_result() {
let database = create_test_database().await;
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
let service = TemplateMatchingResultService::new(repository);
// 保存一个匹配结果
let service_result = create_test_service_matching_result();
let saved_result = service.save_matching_result(
&service_result,
"待软删除结果".to_string(),
None,
1000,
).await.unwrap();
// 软删除结果
let delete_result = service.soft_delete_matching_result(&saved_result.id).await;
assert!(delete_result.is_ok());
assert!(delete_result.unwrap());
// 验证在列表中不可见(软删除)
let list_result = service.list_matching_results(TemplateMatchingResultQueryOptions::default()).await;
assert!(list_result.is_ok());
let results = list_result.unwrap();
assert!(!results.iter().any(|r| r.id == saved_result.id));
}
#[tokio::test]
async fn test_update_matching_result_info() {
let database = create_test_database().await;
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
let service = TemplateMatchingResultService::new(repository);
// 保存一个匹配结果
let service_result = create_test_service_matching_result();
let saved_result = service.save_matching_result(
&service_result,
"原始名称".to_string(),
Some("原始描述".to_string()),
1000,
).await.unwrap();
// 更新信息
let new_name = "更新后的名称".to_string();
let new_description = "更新后的描述".to_string();
let update_result = service.update_matching_result_info(
&saved_result.id,
Some(new_name.clone()),
Some(new_description.clone()),
).await;
assert!(update_result.is_ok());
let updated_result = update_result.unwrap();
assert!(updated_result.is_some());
let updated_result = updated_result.unwrap();
assert_eq!(updated_result.result_name, new_name);
assert_eq!(updated_result.description, Some(new_description));
}
#[tokio::test]
async fn test_set_quality_score() {
let database = create_test_database().await;
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
let service = TemplateMatchingResultService::new(repository);
// 保存一个匹配结果
let service_result = create_test_service_matching_result();
let saved_result = service.save_matching_result(
&service_result,
"质量评分测试".to_string(),
None,
1000,
).await.unwrap();
// 设置质量评分
let quality_score = 4.5;
let score_result = service.set_quality_score(&saved_result.id, quality_score).await;
assert!(score_result.is_ok());
let updated_result = score_result.unwrap();
assert!(updated_result.is_some());
let updated_result = updated_result.unwrap();
assert_eq!(updated_result.quality_score, Some(quality_score));
}
#[tokio::test]
async fn test_get_matching_statistics() {
let database = create_test_database().await;
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
let service = TemplateMatchingResultService::new(repository);
let project_id = "stats-test-project";
// 保存多个匹配结果
for i in 0..3 {
let mut service_result = create_test_service_matching_result();
service_result.project_id = project_id.to_string();
service_result.template_id = format!("template-{}", i);
service.save_matching_result(
&service_result,
format!("结果{}", i),
None,
1000 + i * 100,
).await.unwrap();
}
// 获取统计信息
let stats_result = service.get_matching_statistics(Some(project_id)).await;
assert!(stats_result.is_ok());
let stats = stats_result.unwrap();
assert_eq!(stats.total_results, 3);
assert_eq!(stats.successful_results, 3); // 所有结果都是部分成功
assert_eq!(stats.total_segments, 6); // 每个结果2个片段
assert_eq!(stats.matched_segments, 3); // 每个结果1个匹配片段
assert_eq!(stats.total_materials, 3); // 每个结果1个素材
assert_eq!(stats.total_models, 3); // 每个结果1个模特
assert_eq!(stats.average_success_rate, 50.0); // 平均成功率50%
}
#[tokio::test]
async fn test_list_matching_results_with_filters() {
let database = create_test_database().await;
let repository = Arc::new(TemplateMatchingResultRepository::new(database));
let service = TemplateMatchingResultService::new(repository);
// 保存不同状态的匹配结果
let mut service_result1 = create_test_service_matching_result();
service_result1.statistics.success_rate = 100.0; // 成功
service_result1.statistics.matched_segments = 2;
service_result1.statistics.failed_segments = 0;
let service_result2 = create_test_service_matching_result(); // 部分成功
service.save_matching_result(&service_result1, "成功结果".to_string(), None, 1000).await.unwrap();
service.save_matching_result(&service_result2, "部分成功结果".to_string(), None, 1500).await.unwrap();
// 测试状态过滤
let options = TemplateMatchingResultQueryOptions {
status: Some(MatchingResultStatus::Success),
..Default::default()
};
let results = service.list_matching_results(options).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, MatchingResultStatus::Success);
// 测试搜索关键词
let options = TemplateMatchingResultQueryOptions {
search_keyword: Some("成功".to_string()),
..Default::default()
};
let results = service.list_matching_results(options).await.unwrap();
assert_eq!(results.len(), 2); // 两个结果都包含"成功"
// 测试分页
let options = TemplateMatchingResultQueryOptions {
limit: Some(1),
offset: Some(0),
..Default::default()
};
let results = service.list_matching_results(options).await.unwrap();
assert_eq!(results.len(), 1);
}
}

View File

@@ -6,3 +6,4 @@ pub mod ai_classification;
pub mod video_classification;
pub mod template;
pub mod project_template_binding;
pub mod template_matching_result;

View File

@@ -0,0 +1,309 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
/// 模板匹配结果实体模型
/// 遵循 Tauri 开发规范的数据模型设计原则
/// 保存一次完整的模板匹配结果,包含所有匹配的片段信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateMatchingResult {
pub id: String,
pub project_id: String,
pub template_id: String,
pub binding_id: String,
pub result_name: String, // 用户自定义的结果名称
pub description: Option<String>,
pub total_segments: u32,
pub matched_segments: u32,
pub failed_segments: u32,
pub success_rate: f64,
pub used_materials: u32,
pub used_models: u32,
pub matching_duration_ms: u64, // 匹配耗时(毫秒)
pub quality_score: Option<f64>, // 匹配质量评分
pub status: MatchingResultStatus,
pub metadata: Option<String>, // JSON格式的额外元数据
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub is_active: bool,
}
/// 匹配结果状态
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum MatchingResultStatus {
/// 匹配成功
Success,
/// 部分匹配成功
PartialSuccess,
/// 匹配失败
Failed,
/// 匹配被取消
Cancelled,
}
impl Default for MatchingResultStatus {
fn default() -> Self {
Self::Success
}
}
/// 匹配片段结果实体模型
/// 保存每个成功匹配的片段详细信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchingSegmentResult {
pub id: String,
pub matching_result_id: String, // 关联的匹配结果ID
pub track_segment_id: String, // 模板轨道片段ID
pub track_segment_name: String,
pub material_segment_id: String, // 匹配到的素材片段ID
pub material_id: String, // 素材ID
pub material_name: String,
pub model_id: Option<String>, // 模特ID
pub model_name: Option<String>,
pub match_score: f64, // 匹配评分
pub match_reason: String, // 匹配原因
pub segment_duration: u64, // 片段时长(微秒)
pub start_time: u64, // 在模板中的开始时间(微秒)
pub end_time: u64, // 在模板中的结束时间(微秒)
pub properties: Option<String>, // JSON格式的片段属性
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// 匹配失败片段结果实体模型
/// 保存匹配失败的片段信息,用于分析和改进
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchingFailedSegmentResult {
pub id: String,
pub matching_result_id: String, // 关联的匹配结果ID
pub track_segment_id: String, // 模板轨道片段ID
pub track_segment_name: String,
pub matching_rule_type: String, // 匹配规则类型
pub matching_rule_data: Option<String>, // 匹配规则数据JSON格式
pub failure_reason: String, // 失败原因
pub failure_details: Option<String>, // 失败详情JSON格式
pub segment_duration: u64, // 片段时长(微秒)
pub start_time: u64, // 在模板中的开始时间(微秒)
pub end_time: u64, // 在模板中的结束时间(微秒)
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl TemplateMatchingResult {
/// 创建新的模板匹配结果实例
pub fn new(
id: String,
project_id: String,
template_id: String,
binding_id: String,
result_name: String,
) -> Self {
let now = Utc::now();
Self {
id,
project_id,
template_id,
binding_id,
result_name,
description: None,
total_segments: 0,
matched_segments: 0,
failed_segments: 0,
success_rate: 0.0,
used_materials: 0,
used_models: 0,
matching_duration_ms: 0,
quality_score: None,
status: MatchingResultStatus::default(),
metadata: None,
created_at: now,
updated_at: now,
is_active: true,
}
}
/// 更新匹配统计信息
pub fn update_statistics(
&mut self,
total_segments: u32,
matched_segments: u32,
failed_segments: u32,
used_materials: u32,
used_models: u32,
matching_duration_ms: u64,
) {
self.total_segments = total_segments;
self.matched_segments = matched_segments;
self.failed_segments = failed_segments;
self.success_rate = if total_segments > 0 {
(matched_segments as f64) / (total_segments as f64) * 100.0
} else {
0.0
};
self.used_materials = used_materials;
self.used_models = used_models;
self.matching_duration_ms = matching_duration_ms;
self.updated_at = Utc::now();
// 根据成功率设置状态
self.status = if self.success_rate >= 100.0 {
MatchingResultStatus::Success
} else if self.success_rate > 0.0 {
MatchingResultStatus::PartialSuccess
} else {
MatchingResultStatus::Failed
};
}
/// 设置质量评分
pub fn set_quality_score(&mut self, score: f64) {
self.quality_score = Some(score);
self.updated_at = Utc::now();
}
/// 设置描述
pub fn set_description(&mut self, description: String) {
self.description = Some(description);
self.updated_at = Utc::now();
}
/// 设置元数据
pub fn set_metadata(&mut self, metadata: String) {
self.metadata = Some(metadata);
self.updated_at = Utc::now();
}
/// 检查是否为成功的匹配结果
pub fn is_successful(&self) -> bool {
matches!(self.status, MatchingResultStatus::Success | MatchingResultStatus::PartialSuccess)
}
/// 获取状态显示名称
pub fn status_display_name(&self) -> String {
match self.status {
MatchingResultStatus::Success => "匹配成功".to_string(),
MatchingResultStatus::PartialSuccess => "部分成功".to_string(),
MatchingResultStatus::Failed => "匹配失败".to_string(),
MatchingResultStatus::Cancelled => "已取消".to_string(),
}
}
}
impl MatchingSegmentResult {
/// 创建新的匹配片段结果实例
pub fn new(
id: String,
matching_result_id: String,
track_segment_id: String,
track_segment_name: String,
material_segment_id: String,
material_id: String,
material_name: String,
match_score: f64,
match_reason: String,
segment_duration: u64,
start_time: u64,
end_time: u64,
) -> Self {
let now = Utc::now();
Self {
id,
matching_result_id,
track_segment_id,
track_segment_name,
material_segment_id,
material_id,
material_name,
model_id: None,
model_name: None,
match_score,
match_reason,
segment_duration,
start_time,
end_time,
properties: None,
created_at: now,
updated_at: now,
}
}
/// 设置模特信息
pub fn set_model_info(&mut self, model_id: String, model_name: String) {
self.model_id = Some(model_id);
self.model_name = Some(model_name);
self.updated_at = Utc::now();
}
/// 设置片段属性
pub fn set_properties(&mut self, properties: String) {
self.properties = Some(properties);
self.updated_at = Utc::now();
}
}
impl MatchingFailedSegmentResult {
/// 创建新的匹配失败片段结果实例
pub fn new(
id: String,
matching_result_id: String,
track_segment_id: String,
track_segment_name: String,
matching_rule_type: String,
failure_reason: String,
segment_duration: u64,
start_time: u64,
end_time: u64,
) -> Self {
let now = Utc::now();
Self {
id,
matching_result_id,
track_segment_id,
track_segment_name,
matching_rule_type,
matching_rule_data: None,
failure_reason,
failure_details: None,
segment_duration,
start_time,
end_time,
created_at: now,
updated_at: now,
}
}
/// 设置匹配规则数据
pub fn set_matching_rule_data(&mut self, rule_data: String) {
self.matching_rule_data = Some(rule_data);
self.updated_at = Utc::now();
}
/// 设置失败详情
pub fn set_failure_details(&mut self, details: String) {
self.failure_details = Some(details);
self.updated_at = Utc::now();
}
}
/// 创建模板匹配结果请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateTemplateMatchingResultRequest {
pub project_id: String,
pub template_id: String,
pub binding_id: String,
pub result_name: String,
pub description: Option<String>,
}
/// 模板匹配结果查询选项
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct TemplateMatchingResultQueryOptions {
pub project_id: Option<String>,
pub template_id: Option<String>,
pub binding_id: Option<String>,
pub status: Option<MatchingResultStatus>,
pub limit: Option<u32>,
pub offset: Option<u32>,
pub search_keyword: Option<String>,
pub sort_by: Option<String>, // "created_at", "success_rate", "result_name"
pub sort_order: Option<String>, // "asc", "desc"
}

View File

@@ -4,3 +4,4 @@ pub mod model_repository;
pub mod ai_classification_repository;
pub mod video_classification_repository;
pub mod project_template_binding_repository;
pub mod template_matching_result_repository;

View File

@@ -0,0 +1,558 @@
use rusqlite::{Result, Row};
use std::sync::Arc;
use chrono::{DateTime, Utc};
use crate::infrastructure::database::Database;
use crate::data::models::template_matching_result::{
TemplateMatchingResult, MatchingSegmentResult, MatchingFailedSegmentResult,
MatchingResultStatus, CreateTemplateMatchingResultRequest,
TemplateMatchingResultQueryOptions,
};
/// 模板匹配结果数据库操作仓储
/// 遵循 Tauri 开发规范的数据访问层设计原则
pub struct TemplateMatchingResultRepository {
database: Arc<Database>,
}
impl TemplateMatchingResultRepository {
/// 创建新的模板匹配结果仓储实例
pub fn new(database: Arc<Database>) -> Self {
Self { database }
}
/// 创建模板匹配结果
pub fn create(&self, request: CreateTemplateMatchingResultRequest) -> Result<TemplateMatchingResult> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let result = TemplateMatchingResult::new(
uuid::Uuid::new_v4().to_string(),
request.project_id,
request.template_id,
request.binding_id,
request.result_name,
);
let mut final_result = result;
if let Some(desc) = request.description {
final_result.set_description(desc);
}
conn.execute(
"INSERT INTO template_matching_results (
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, 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)",
rusqlite::params![
&final_result.id,
&final_result.project_id,
&final_result.template_id,
&final_result.binding_id,
&final_result.result_name,
&final_result.description,
&final_result.total_segments.to_string(),
&final_result.matched_segments.to_string(),
&final_result.failed_segments.to_string(),
&final_result.success_rate.to_string(),
&final_result.used_materials.to_string(),
&final_result.used_models.to_string(),
&final_result.matching_duration_ms.to_string(),
&final_result.quality_score.map(|s| s.to_string()),
&serde_json::to_string(&final_result.status).unwrap(),
&final_result.metadata,
&final_result.created_at.to_rfc3339(),
&final_result.updated_at.to_rfc3339(),
&(final_result.is_active as i32).to_string(),
],
)?;
Ok(final_result)
}
/// 根据ID获取模板匹配结果
pub fn get_by_id(&self, id: &str) -> Result<Option<TemplateMatchingResult>> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT 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, created_at, updated_at, is_active
FROM template_matching_results WHERE id = ?1"
)?;
let result_iter = stmt.query_map([id], |row| {
self.row_to_matching_result(row)
})?;
for result in result_iter {
return Ok(Some(result?));
}
Ok(None)
}
/// 更新模板匹配结果
pub fn update(&self, result: &TemplateMatchingResult) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
conn.execute(
"UPDATE template_matching_results SET
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",
rusqlite::params![
&result.result_name,
&result.description,
&result.total_segments.to_string(),
&result.matched_segments.to_string(),
&result.failed_segments.to_string(),
&result.success_rate.to_string(),
&result.used_materials.to_string(),
&result.used_models.to_string(),
&result.matching_duration_ms.to_string(),
&result.quality_score.map(|s| s.to_string()),
&serde_json::to_string(&result.status).unwrap(),
&result.metadata,
&result.updated_at.to_rfc3339(),
&result.id,
],
)?;
Ok(())
}
/// 删除模板匹配结果
pub fn delete(&self, id: &str) -> Result<bool> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let rows_affected = conn.execute(
"DELETE FROM template_matching_results WHERE id = ?1",
[id],
)?;
Ok(rows_affected > 0)
}
/// 软删除模板匹配结果
pub fn soft_delete(&self, id: &str) -> Result<bool> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let rows_affected = conn.execute(
"UPDATE template_matching_results SET is_active = 0, updated_at = ?1 WHERE id = ?2",
[&Utc::now().to_rfc3339(), id],
)?;
Ok(rows_affected > 0)
}
/// 查询模板匹配结果列表
pub fn list(&self, options: TemplateMatchingResultQueryOptions) -> Result<Vec<TemplateMatchingResult>> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let mut query = "SELECT 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, created_at, updated_at, is_active
FROM template_matching_results WHERE is_active = 1".to_string();
let mut params: Vec<String> = Vec::new();
// 添加查询条件
if let Some(project_id) = &options.project_id {
query.push_str(" AND project_id = ?");
params.push(project_id.clone());
}
if let Some(template_id) = &options.template_id {
query.push_str(" AND template_id = ?");
params.push(template_id.clone());
}
if let Some(binding_id) = &options.binding_id {
query.push_str(" AND binding_id = ?");
params.push(binding_id.clone());
}
if let Some(status) = &options.status {
query.push_str(" AND status = ?");
params.push(serde_json::to_string(status).unwrap());
}
if let Some(keyword) = &options.search_keyword {
query.push_str(" AND (result_name LIKE ? OR description LIKE ?)");
let search_pattern = format!("%{}%", keyword);
params.push(search_pattern.clone());
params.push(search_pattern);
}
// 添加排序
let sort_by = options.sort_by.as_deref().unwrap_or("created_at");
let sort_order = options.sort_order.as_deref().unwrap_or("desc");
query.push_str(&format!(" ORDER BY {} {}", sort_by, sort_order));
// 添加分页
if let Some(limit) = options.limit {
query.push_str(" LIMIT ?");
params.push(limit.to_string());
if let Some(offset) = options.offset {
query.push_str(" OFFSET ?");
params.push(offset.to_string());
}
}
let mut stmt = conn.prepare(&query)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
let result_iter = stmt.query_map(param_refs.as_slice(), |row| {
self.row_to_matching_result(row)
})?;
let mut results = Vec::new();
for result in result_iter {
results.push(result?);
}
Ok(results)
}
/// 根据项目ID获取匹配结果列表
pub fn get_by_project_id(&self, project_id: &str) -> Result<Vec<TemplateMatchingResult>> {
let options = TemplateMatchingResultQueryOptions {
project_id: Some(project_id.to_string()),
..Default::default()
};
self.list(options)
}
/// 根据模板ID获取匹配结果列表
pub fn get_by_template_id(&self, template_id: &str) -> Result<Vec<TemplateMatchingResult>> {
let options = TemplateMatchingResultQueryOptions {
template_id: Some(template_id.to_string()),
..Default::default()
};
self.list(options)
}
/// 根据绑定ID获取匹配结果列表
pub fn get_by_binding_id(&self, binding_id: &str) -> Result<Vec<TemplateMatchingResult>> {
let options = TemplateMatchingResultQueryOptions {
binding_id: Some(binding_id.to_string()),
..Default::default()
};
self.list(options)
}
// ===== 匹配片段结果管理 =====
/// 创建匹配片段结果
pub fn create_segment_result(&self, segment_result: &MatchingSegmentResult) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
conn.execute(
"INSERT INTO matching_segment_results (
id, matching_result_id, track_segment_id, track_segment_name,
material_segment_id, material_id, material_name, model_id, model_name,
match_score, match_reason, segment_duration, start_time, end_time,
properties, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
rusqlite::params![
&segment_result.id,
&segment_result.matching_result_id,
&segment_result.track_segment_id,
&segment_result.track_segment_name,
&segment_result.material_segment_id,
&segment_result.material_id,
&segment_result.material_name,
&segment_result.model_id,
&segment_result.model_name,
&segment_result.match_score.to_string(),
&segment_result.match_reason,
&segment_result.segment_duration.to_string(),
&segment_result.start_time.to_string(),
&segment_result.end_time.to_string(),
&segment_result.properties,
&segment_result.created_at.to_rfc3339(),
&segment_result.updated_at.to_rfc3339(),
],
)?;
Ok(())
}
/// 批量创建匹配片段结果
pub fn create_segment_results(&self, segment_results: &[MatchingSegmentResult]) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let tx = conn.unchecked_transaction()?;
for segment_result in segment_results {
tx.execute(
"INSERT INTO matching_segment_results (
id, matching_result_id, track_segment_id, track_segment_name,
material_segment_id, material_id, material_name, model_id, model_name,
match_score, match_reason, segment_duration, start_time, end_time,
properties, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
rusqlite::params![
&segment_result.id,
&segment_result.matching_result_id,
&segment_result.track_segment_id,
&segment_result.track_segment_name,
&segment_result.material_segment_id,
&segment_result.material_id,
&segment_result.material_name,
&segment_result.model_id,
&segment_result.model_name,
&segment_result.match_score.to_string(),
&segment_result.match_reason,
&segment_result.segment_duration.to_string(),
&segment_result.start_time.to_string(),
&segment_result.end_time.to_string(),
&segment_result.properties,
&segment_result.created_at.to_rfc3339(),
&segment_result.updated_at.to_rfc3339(),
],
)?;
}
tx.commit()?;
Ok(())
}
/// 获取匹配结果的所有成功片段
pub fn get_segment_results_by_matching_result_id(&self, matching_result_id: &str) -> Result<Vec<MatchingSegmentResult>> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, matching_result_id, track_segment_id, track_segment_name,
material_segment_id, material_id, material_name, model_id, model_name,
match_score, match_reason, segment_duration, start_time, end_time,
properties, created_at, updated_at
FROM matching_segment_results WHERE matching_result_id = ?1
ORDER BY start_time ASC"
)?;
let segment_iter = stmt.query_map([matching_result_id], |row| {
self.row_to_segment_result(row)
})?;
let mut segments = Vec::new();
for segment in segment_iter {
segments.push(segment?);
}
Ok(segments)
}
// ===== 匹配失败片段结果管理 =====
/// 创建匹配失败片段结果
pub fn create_failed_segment_result(&self, failed_segment: &MatchingFailedSegmentResult) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
conn.execute(
"INSERT INTO matching_failed_segment_results (
id, matching_result_id, track_segment_id, track_segment_name,
matching_rule_type, matching_rule_data, failure_reason, failure_details,
segment_duration, start_time, end_time, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
rusqlite::params![
&failed_segment.id,
&failed_segment.matching_result_id,
&failed_segment.track_segment_id,
&failed_segment.track_segment_name,
&failed_segment.matching_rule_type,
&failed_segment.matching_rule_data,
&failed_segment.failure_reason,
&failed_segment.failure_details,
&failed_segment.segment_duration.to_string(),
&failed_segment.start_time.to_string(),
&failed_segment.end_time.to_string(),
&failed_segment.created_at.to_rfc3339(),
&failed_segment.updated_at.to_rfc3339(),
],
)?;
Ok(())
}
/// 批量创建匹配失败片段结果
pub fn create_failed_segment_results(&self, failed_segments: &[MatchingFailedSegmentResult]) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let tx = conn.unchecked_transaction()?;
for failed_segment in failed_segments {
tx.execute(
"INSERT INTO matching_failed_segment_results (
id, matching_result_id, track_segment_id, track_segment_name,
matching_rule_type, matching_rule_data, failure_reason, failure_details,
segment_duration, start_time, end_time, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
rusqlite::params![
&failed_segment.id,
&failed_segment.matching_result_id,
&failed_segment.track_segment_id,
&failed_segment.track_segment_name,
&failed_segment.matching_rule_type,
&failed_segment.matching_rule_data,
&failed_segment.failure_reason,
&failed_segment.failure_details,
&failed_segment.segment_duration.to_string(),
&failed_segment.start_time.to_string(),
&failed_segment.end_time.to_string(),
&failed_segment.created_at.to_rfc3339(),
&failed_segment.updated_at.to_rfc3339(),
],
)?;
}
tx.commit()?;
Ok(())
}
/// 获取匹配结果的所有失败片段
pub fn get_failed_segment_results_by_matching_result_id(&self, matching_result_id: &str) -> Result<Vec<MatchingFailedSegmentResult>> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, matching_result_id, track_segment_id, track_segment_name,
matching_rule_type, matching_rule_data, failure_reason, failure_details,
segment_duration, start_time, end_time, created_at, updated_at
FROM matching_failed_segment_results WHERE matching_result_id = ?1
ORDER BY start_time ASC"
)?;
let failed_segment_iter = stmt.query_map([matching_result_id], |row| {
self.row_to_failed_segment_result(row)
})?;
let mut failed_segments = Vec::new();
for failed_segment in failed_segment_iter {
failed_segments.push(failed_segment?);
}
Ok(failed_segments)
}
/// 将数据库行转换为模板匹配结果实体
fn row_to_matching_result(&self, row: &Row) -> Result<TemplateMatchingResult> {
let status_str: String = row.get("status")?;
let status: MatchingResultStatus = serde_json::from_str(&status_str)
.unwrap_or(MatchingResultStatus::Success);
let quality_score_str: Option<String> = row.get("quality_score")?;
let quality_score = quality_score_str.and_then(|s| s.parse::<f64>().ok());
let created_at_str: String = row.get("created_at")?;
let updated_at_str: String = row.get("updated_at")?;
let created_at = DateTime::parse_from_rfc3339(&created_at_str)
.unwrap_or_else(|_| Utc::now().into())
.with_timezone(&Utc);
let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)
.unwrap_or_else(|_| Utc::now().into())
.with_timezone(&Utc);
let is_active_str: String = row.get("is_active")?;
let is_active = is_active_str.parse::<i32>().unwrap_or(1) != 0;
Ok(TemplateMatchingResult {
id: row.get("id")?,
project_id: row.get("project_id")?,
template_id: row.get("template_id")?,
binding_id: row.get("binding_id")?,
result_name: row.get("result_name")?,
description: row.get("description")?,
total_segments: row.get::<_, String>("total_segments")?.parse().unwrap_or(0),
matched_segments: row.get::<_, String>("matched_segments")?.parse().unwrap_or(0),
failed_segments: row.get::<_, String>("failed_segments")?.parse().unwrap_or(0),
success_rate: row.get::<_, String>("success_rate")?.parse().unwrap_or(0.0),
used_materials: row.get::<_, String>("used_materials")?.parse().unwrap_or(0),
used_models: row.get::<_, String>("used_models")?.parse().unwrap_or(0),
matching_duration_ms: row.get::<_, String>("matching_duration_ms")?.parse().unwrap_or(0),
quality_score,
status,
metadata: row.get("metadata")?,
created_at,
updated_at,
is_active,
})
}
/// 将数据库行转换为匹配片段结果实体
fn row_to_segment_result(&self, row: &Row) -> Result<MatchingSegmentResult> {
let created_at_str: String = row.get("created_at")?;
let updated_at_str: String = row.get("updated_at")?;
let created_at = DateTime::parse_from_rfc3339(&created_at_str)
.unwrap_or_else(|_| Utc::now().into())
.with_timezone(&Utc);
let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)
.unwrap_or_else(|_| Utc::now().into())
.with_timezone(&Utc);
Ok(MatchingSegmentResult {
id: row.get("id")?,
matching_result_id: row.get("matching_result_id")?,
track_segment_id: row.get("track_segment_id")?,
track_segment_name: row.get("track_segment_name")?,
material_segment_id: row.get("material_segment_id")?,
material_id: row.get("material_id")?,
material_name: row.get("material_name")?,
model_id: row.get("model_id")?,
model_name: row.get("model_name")?,
match_score: row.get::<_, String>("match_score")?.parse().unwrap_or(0.0),
match_reason: row.get("match_reason")?,
segment_duration: row.get::<_, String>("segment_duration")?.parse().unwrap_or(0),
start_time: row.get::<_, String>("start_time")?.parse().unwrap_or(0),
end_time: row.get::<_, String>("end_time")?.parse().unwrap_or(0),
properties: row.get("properties")?,
created_at,
updated_at,
})
}
/// 将数据库行转换为匹配失败片段结果实体
fn row_to_failed_segment_result(&self, row: &Row) -> Result<MatchingFailedSegmentResult> {
let created_at_str: String = row.get("created_at")?;
let updated_at_str: String = row.get("updated_at")?;
let created_at = DateTime::parse_from_rfc3339(&created_at_str)
.unwrap_or_else(|_| Utc::now().into())
.with_timezone(&Utc);
let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)
.unwrap_or_else(|_| Utc::now().into())
.with_timezone(&Utc);
Ok(MatchingFailedSegmentResult {
id: row.get("id")?,
matching_result_id: row.get("matching_result_id")?,
track_segment_id: row.get("track_segment_id")?,
track_segment_name: row.get("track_segment_name")?,
matching_rule_type: row.get("matching_rule_type")?,
matching_rule_data: row.get("matching_rule_data")?,
failure_reason: row.get("failure_reason")?,
failure_details: row.get("failure_details")?,
segment_duration: row.get::<_, String>("segment_duration")?.parse().unwrap_or(0),
start_time: row.get::<_, String>("start_time")?.parse().unwrap_or(0),
end_time: row.get::<_, String>("end_time")?.parse().unwrap_or(0),
created_at,
updated_at,
})
}
}

View File

@@ -671,7 +671,83 @@ impl Database {
[],
)?;
// 创建模板匹配结果表
conn.execute(
"CREATE TABLE IF NOT EXISTS template_matching_results (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
template_id TEXT NOT NULL,
binding_id TEXT NOT NULL,
result_name TEXT NOT NULL,
description TEXT,
total_segments INTEGER NOT NULL DEFAULT 0,
matched_segments INTEGER NOT NULL DEFAULT 0,
failed_segments INTEGER NOT NULL DEFAULT 0,
success_rate REAL NOT NULL DEFAULT 0.0,
used_materials INTEGER NOT NULL DEFAULT 0,
used_models INTEGER NOT NULL DEFAULT 0,
matching_duration_ms INTEGER NOT NULL DEFAULT 0,
quality_score REAL,
status TEXT NOT NULL DEFAULT 'Success',
metadata TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
is_active INTEGER DEFAULT 1,
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
FOREIGN KEY (template_id) REFERENCES templates (id) ON DELETE CASCADE,
FOREIGN KEY (binding_id) REFERENCES project_template_bindings (id) ON DELETE CASCADE
)",
[],
)?;
// 创建匹配片段结果表
conn.execute(
"CREATE TABLE IF NOT EXISTS matching_segment_results (
id TEXT PRIMARY KEY,
matching_result_id TEXT NOT NULL,
track_segment_id TEXT NOT NULL,
track_segment_name TEXT NOT NULL,
material_segment_id TEXT NOT NULL,
material_id TEXT NOT NULL,
material_name TEXT NOT NULL,
model_id TEXT,
model_name TEXT,
match_score REAL NOT NULL,
match_reason TEXT NOT NULL,
segment_duration INTEGER NOT NULL,
start_time INTEGER NOT NULL,
end_time INTEGER NOT NULL,
properties TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (matching_result_id) REFERENCES template_matching_results (id) ON DELETE CASCADE,
FOREIGN KEY (material_segment_id) REFERENCES material_segments (id) ON DELETE CASCADE,
FOREIGN KEY (material_id) REFERENCES materials (id) ON DELETE CASCADE,
FOREIGN KEY (model_id) REFERENCES models (id) ON DELETE SET NULL
)",
[],
)?;
// 创建匹配失败片段结果表
conn.execute(
"CREATE TABLE IF NOT EXISTS matching_failed_segment_results (
id TEXT PRIMARY KEY,
matching_result_id TEXT NOT NULL,
track_segment_id TEXT NOT NULL,
track_segment_name TEXT NOT NULL,
matching_rule_type TEXT NOT NULL,
matching_rule_data TEXT,
failure_reason TEXT NOT NULL,
failure_details TEXT,
segment_duration INTEGER NOT NULL,
start_time INTEGER NOT NULL,
end_time INTEGER NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (matching_result_id) REFERENCES template_matching_results (id) ON DELETE CASCADE
)",
[],
)?;
// 创建性能监控表
conn.execute(
@@ -748,7 +824,53 @@ impl Database {
[],
)?;
// 创建模板匹配结果表索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_template_matching_results_project_id ON template_matching_results (project_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_template_matching_results_template_id ON template_matching_results (template_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_template_matching_results_binding_id ON template_matching_results (binding_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_template_matching_results_status ON template_matching_results (status)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_template_matching_results_created_at ON template_matching_results (created_at)",
[],
)?;
// 创建匹配片段结果表索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_matching_segment_results_matching_result_id ON matching_segment_results (matching_result_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_matching_segment_results_material_id ON matching_segment_results (material_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_matching_segment_results_model_id ON matching_segment_results (model_id)",
[],
)?;
// 创建匹配失败片段结果表索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_matching_failed_segment_results_matching_result_id ON matching_failed_segment_results (matching_result_id)",
[],
)?;
// 添加新字段(如果不存在)- 数据库迁移
let _ = conn.execute(

View File

@@ -191,8 +191,24 @@ pub fn run() {
commands::project_template_binding_commands::check_project_template_binding_exists,
// 素材匹配命令
commands::material_matching_commands::execute_material_matching,
commands::material_matching_commands::execute_material_matching_with_save,
commands::material_matching_commands::get_project_material_stats_for_matching,
commands::material_matching_commands::validate_template_binding_for_matching,
// 模板匹配结果命令
commands::template_matching_result_commands::save_matching_result,
commands::template_matching_result_commands::get_matching_result_detail,
commands::template_matching_result_commands::get_project_matching_results,
commands::template_matching_result_commands::get_template_matching_results,
commands::template_matching_result_commands::get_binding_matching_results,
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::update_matching_result_info,
commands::template_matching_result_commands::set_matching_result_quality_score,
commands::template_matching_result_commands::get_matching_statistics,
commands::template_matching_result_commands::create_matching_result,
commands::template_matching_result_commands::get_matching_result_by_id,
commands::template_matching_result_commands::get_matching_result_status_options,
// MaterialSegment聚合视图命令
commands::material_segment_view_commands::get_project_segment_view,
commands::material_segment_view_commands::get_project_segment_view_with_query,

View File

@@ -10,10 +10,13 @@ use crate::business::services::material_matching_service::{
MaterialMatchingService, MaterialMatchingRequest, MaterialMatchingResult
};
use crate::business::services::template_service::TemplateService;
use crate::business::services::template_matching_result_service::TemplateMatchingResultService;
use crate::data::repositories::{
material_repository::MaterialRepository,
video_classification_repository::VideoClassificationRepository,
template_matching_result_repository::TemplateMatchingResultRepository,
};
use crate::data::models::template_matching_result::TemplateMatchingResult;
use crate::infrastructure::database::Database;
/// 执行素材匹配
@@ -46,6 +49,43 @@ pub async fn execute_material_matching(
.map_err(|e| e.to_string())
}
/// 执行素材匹配并自动保存结果
#[command]
pub async fn execute_material_matching_with_save(
request: MaterialMatchingRequest,
result_name: String,
description: Option<String>,
database: State<'_, Arc<Database>>,
) -> Result<(MaterialMatchingResult, Option<TemplateMatchingResult>), String> {
// 创建服务实例
let material_repo = Arc::new(
MaterialRepository::new(database.inner().clone())
.map_err(|e| format!("创建素材仓库失败: {}", e))?
);
let template_service = Arc::new(TemplateService::new(database.inner().clone()));
let video_classification_repo = Arc::new(
VideoClassificationRepository::new(database.inner().clone())
);
// 创建匹配结果服务
let matching_result_repo = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
let matching_result_service = Arc::new(TemplateMatchingResultService::new(matching_result_repo));
let matching_service = MaterialMatchingService::new_with_result_service(
material_repo,
template_service,
video_classification_repo,
matching_result_service,
);
// 执行匹配并保存结果
matching_service.match_materials_and_save(request, result_name, description)
.await
.map_err(|e| e.to_string())
}
/// 获取项目的可用素材统计信息
#[command]
pub async fn get_project_material_stats_for_matching(

View File

@@ -12,3 +12,4 @@ pub mod test_commands;
pub mod debug_commands;
pub mod project_template_binding_commands;
pub mod material_matching_commands;
pub mod template_matching_result_commands;

View File

@@ -0,0 +1,215 @@
/**
* 模板匹配结果相关的 Tauri 命令
* 遵循 Tauri 开发规范的 API 设计原则
*/
use tauri::{command, State};
use std::sync::Arc;
use crate::business::services::template_matching_result_service::{
TemplateMatchingResultService, TemplateMatchingResultDetail, MatchingStatistics,
};
use crate::business::services::material_matching_service::MaterialMatchingResult as ServiceMatchingResult;
use crate::data::models::template_matching_result::{
TemplateMatchingResult, CreateTemplateMatchingResultRequest,
TemplateMatchingResultQueryOptions, MatchingResultStatus,
};
use crate::data::repositories::template_matching_result_repository::TemplateMatchingResultRepository;
use crate::infrastructure::database::Database;
/// 保存匹配结果到数据库
#[command]
pub async fn save_matching_result(
service_result: ServiceMatchingResult,
result_name: String,
description: Option<String>,
matching_duration_ms: u64,
database: State<'_, Arc<Database>>,
) -> Result<TemplateMatchingResult, String> {
// 创建服务实例
let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
let service = TemplateMatchingResultService::new(repository);
// 保存匹配结果
service.save_matching_result(&service_result, result_name, description, matching_duration_ms)
.await
.map_err(|e| e.to_string())
}
/// 获取匹配结果详情
#[command]
pub async fn get_matching_result_detail(
result_id: String,
database: State<'_, Arc<Database>>,
) -> Result<Option<TemplateMatchingResultDetail>, String> {
let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
let service = TemplateMatchingResultService::new(repository);
service.get_matching_result_detail(&result_id)
.await
.map_err(|e| e.to_string())
}
/// 获取项目的匹配结果列表
#[command]
pub async fn get_project_matching_results(
project_id: String,
database: State<'_, Arc<Database>>,
) -> Result<Vec<TemplateMatchingResult>, String> {
let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
let service = TemplateMatchingResultService::new(repository);
service.get_project_matching_results(&project_id)
.await
.map_err(|e| e.to_string())
}
/// 获取模板的匹配结果列表
#[command]
pub async fn get_template_matching_results(
template_id: String,
database: State<'_, Arc<Database>>,
) -> Result<Vec<TemplateMatchingResult>, String> {
let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
let service = TemplateMatchingResultService::new(repository);
service.get_template_matching_results(&template_id)
.await
.map_err(|e| e.to_string())
}
/// 获取绑定的匹配结果列表
#[command]
pub async fn get_binding_matching_results(
binding_id: String,
database: State<'_, Arc<Database>>,
) -> Result<Vec<TemplateMatchingResult>, String> {
let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
let service = TemplateMatchingResultService::new(repository);
service.get_binding_matching_results(&binding_id)
.await
.map_err(|e| e.to_string())
}
/// 查询匹配结果列表
#[command]
pub async fn list_matching_results(
options: TemplateMatchingResultQueryOptions,
database: State<'_, Arc<Database>>,
) -> Result<Vec<TemplateMatchingResult>, String> {
let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
let service = TemplateMatchingResultService::new(repository);
service.list_matching_results(options)
.await
.map_err(|e| e.to_string())
}
/// 删除匹配结果
#[command]
pub async fn delete_matching_result(
result_id: String,
database: State<'_, Arc<Database>>,
) -> Result<bool, String> {
let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
let service = TemplateMatchingResultService::new(repository);
service.delete_matching_result(&result_id)
.await
.map_err(|e| e.to_string())
}
/// 软删除匹配结果
#[command]
pub async fn soft_delete_matching_result(
result_id: String,
database: State<'_, Arc<Database>>,
) -> Result<bool, String> {
let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
let service = TemplateMatchingResultService::new(repository);
service.soft_delete_matching_result(&result_id)
.await
.map_err(|e| e.to_string())
}
/// 更新匹配结果信息
#[command]
pub async fn update_matching_result_info(
result_id: String,
result_name: Option<String>,
description: Option<String>,
database: State<'_, Arc<Database>>,
) -> Result<Option<TemplateMatchingResult>, String> {
let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
let service = TemplateMatchingResultService::new(repository);
service.update_matching_result_info(&result_id, result_name, description)
.await
.map_err(|e| e.to_string())
}
/// 设置匹配结果质量评分
#[command]
pub async fn set_matching_result_quality_score(
result_id: String,
quality_score: f64,
database: State<'_, Arc<Database>>,
) -> Result<Option<TemplateMatchingResult>, String> {
let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
let service = TemplateMatchingResultService::new(repository);
service.set_quality_score(&result_id, quality_score)
.await
.map_err(|e| e.to_string())
}
/// 获取匹配结果统计信息
#[command]
pub async fn get_matching_statistics(
project_id: Option<String>,
database: State<'_, Arc<Database>>,
) -> Result<MatchingStatistics, String> {
let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
let service = TemplateMatchingResultService::new(repository);
service.get_matching_statistics(project_id.as_deref())
.await
.map_err(|e| e.to_string())
}
/// 创建匹配结果(手动创建,不通过匹配服务)
#[command]
pub async fn create_matching_result(
request: CreateTemplateMatchingResultRequest,
database: State<'_, Arc<Database>>,
) -> Result<TemplateMatchingResult, String> {
let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
repository.create(request)
.map_err(|e| e.to_string())
}
/// 根据ID获取匹配结果
#[command]
pub async fn get_matching_result_by_id(
result_id: String,
database: State<'_, Arc<Database>>,
) -> Result<Option<TemplateMatchingResult>, String> {
let repository = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
repository.get_by_id(&result_id)
.map_err(|e| e.to_string())
}
/// 获取匹配结果状态选项(用于前端下拉选择)
#[command]
pub async fn get_matching_result_status_options() -> Result<Vec<(String, String)>, String> {
Ok(vec![
("Success".to_string(), "匹配成功".to_string()),
("PartialSuccess".to_string(), "部分成功".to_string()),
("Failed".to_string(), "匹配失败".to_string()),
("Cancelled".to_string(), "已取消".to_string()),
])
}

View File

@@ -12,7 +12,7 @@ interface DeleteConfirmDialogProps {
/** 要删除的项目名称 */
itemName?: string;
/** 是否正在删除 */
deleting: boolean;
deleting?: boolean;
/** 确认删除回调 */
onConfirm: () => void;
/** 取消回调 */

View File

@@ -0,0 +1,198 @@
import React from 'react';
import { TemplateMatchingResult, MatchingResultStatus } from '../types/templateMatchingResult';
interface TemplateMatchingResultCardProps {
result: TemplateMatchingResult;
onViewDetail: () => void;
onDelete: () => void;
onEdit?: () => void;
}
export const TemplateMatchingResultCard: React.FC<TemplateMatchingResultCardProps> = ({
result,
onViewDetail,
onDelete,
onEdit,
}) => {
// 格式化时长显示
const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}min`;
};
// 格式化日期显示
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
// 获取状态样式
const getStatusStyle = (status: MatchingResultStatus) => {
switch (status) {
case MatchingResultStatus.Success:
return 'bg-green-100 text-green-800 border-green-200';
case MatchingResultStatus.PartialSuccess:
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case MatchingResultStatus.Failed:
return 'bg-red-100 text-red-800 border-red-200';
case MatchingResultStatus.Cancelled:
return 'bg-gray-100 text-gray-800 border-gray-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
// 获取状态显示文本
const getStatusText = (status: MatchingResultStatus) => {
switch (status) {
case MatchingResultStatus.Success:
return '匹配成功';
case MatchingResultStatus.PartialSuccess:
return '部分成功';
case MatchingResultStatus.Failed:
return '匹配失败';
case MatchingResultStatus.Cancelled:
return '已取消';
default:
return '未知状态';
}
};
// 获取成功率颜色
const getSuccessRateColor = (rate: number) => {
if (rate >= 90) return 'text-green-600';
if (rate >= 70) return 'text-yellow-600';
if (rate >= 50) return 'text-orange-600';
return 'text-red-600';
};
return (
<div className="template-matching-result-card bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow duration-200">
{/* 卡片头部 */}
<div className="p-4 border-b border-gray-100">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">
{result.result_name}
</h3>
{result.description && (
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
{result.description}
</p>
)}
</div>
<div className={`ml-3 px-2 py-1 rounded-full text-xs font-medium border ${getStatusStyle(result.status)}`}>
{getStatusText(result.status)}
</div>
</div>
</div>
{/* 统计信息 */}
<div className="p-4">
<div className="grid grid-cols-2 gap-4 mb-4">
{/* 成功率 */}
<div className="text-center">
<div className={`text-2xl font-bold ${getSuccessRateColor(result.success_rate)}`}>
{result.success_rate.toFixed(1)}%
</div>
<div className="text-xs text-gray-500"></div>
</div>
{/* 片段统计 */}
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">
{result.matched_segments}/{result.total_segments}
</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
{/* 详细统计 */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">:</span>
<span className="text-gray-900">{result.failed_segments}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">使:</span>
<span className="text-gray-900">{result.used_materials}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">使:</span>
<span className="text-gray-900">{result.used_models}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">:</span>
<span className="text-gray-900">{formatDuration(result.matching_duration_ms)}</span>
</div>
</div>
{/* 质量评分 */}
{result.quality_score && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">:</span>
<div className="flex items-center">
<div className="flex text-yellow-400">
{[1, 2, 3, 4, 5].map((star) => (
<svg
key={star}
className={`w-4 h-4 ${
star <= result.quality_score! ? 'fill-current' : 'text-gray-300'
}`}
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
<span className="ml-2 text-sm text-gray-900">
{result.quality_score.toFixed(1)}
</span>
</div>
</div>
</div>
)}
</div>
{/* 时间信息 */}
<div className="px-4 py-3 bg-gray-50 border-t border-gray-100 text-xs text-gray-500">
<div className="flex justify-between">
<span>: {formatDate(result.created_at)}</span>
<span>: {formatDate(result.updated_at)}</span>
</div>
</div>
{/* 操作按钮 */}
<div className="px-4 py-3 border-t border-gray-100 flex justify-end space-x-2">
<button
onClick={onViewDetail}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
</button>
{onEdit && (
<button
onClick={onEdit}
className="px-3 py-1.5 text-sm bg-gray-600 text-white rounded-md hover:bg-gray-700 transition-colors"
>
</button>
)}
<button
onClick={onDelete}
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
>
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,452 @@
import React, { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
TemplateMatchingResultDetail,
MatchingResultStatus
} from '../types/templateMatchingResult';
import { Modal } from './Modal';
import { LoadingSpinner } from './LoadingSpinner';
import { ErrorMessage } from './ErrorMessage';
import { TabNavigation } from './TabNavigation';
interface TemplateMatchingResultDetailModalProps {
resultId: string;
onClose: () => void;
onUpdate?: () => void;
}
export const TemplateMatchingResultDetailModal: React.FC<TemplateMatchingResultDetailModalProps> = ({
resultId,
onClose,
onUpdate,
}) => {
const [detail, setDetail] = useState<TemplateMatchingResultDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('overview');
const [editMode, setEditMode] = useState(false);
const [editForm, setEditForm] = useState({
result_name: '',
description: '',
quality_score: '',
});
// 加载详情数据
const loadDetail = async () => {
try {
setLoading(true);
setError(null);
const resultDetail = await invoke<TemplateMatchingResultDetail>('get_matching_result_detail', {
resultId,
});
if (resultDetail) {
setDetail(resultDetail);
setEditForm({
result_name: resultDetail.matching_result.result_name,
description: resultDetail.matching_result.description || '',
quality_score: resultDetail.matching_result.quality_score?.toString() || '',
});
} else {
setError('匹配结果不存在');
}
} catch (err) {
setError(err as string);
} finally {
setLoading(false);
}
};
// 保存编辑
const handleSave = async () => {
if (!detail) return;
try {
// 更新基本信息
await invoke('update_matching_result_info', {
resultId: detail.matching_result.id,
resultName: editForm.result_name || undefined,
description: editForm.description || undefined,
});
// 更新质量评分
if (editForm.quality_score) {
const score = parseFloat(editForm.quality_score);
if (!isNaN(score) && score >= 0 && score <= 5) {
await invoke('set_matching_result_quality_score', {
resultId: detail.matching_result.id,
qualityScore: score,
});
}
}
setEditMode(false);
await loadDetail();
onUpdate?.();
} catch (err) {
setError(`保存失败: ${err}`);
}
};
// 格式化时长
const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}min`;
};
// 格式化时间
const formatTime = (microseconds: number): string => {
const seconds = microseconds / 1000000;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = (seconds % 60).toFixed(1);
return minutes > 0 ? `${minutes}:${remainingSeconds.padStart(4, '0')}` : `${remainingSeconds}s`;
};
// 获取状态样式
const getStatusStyle = (status: MatchingResultStatus) => {
switch (status) {
case MatchingResultStatus.Success:
return 'bg-green-100 text-green-800';
case MatchingResultStatus.PartialSuccess:
return 'bg-yellow-100 text-yellow-800';
case MatchingResultStatus.Failed:
return 'bg-red-100 text-red-800';
case MatchingResultStatus.Cancelled:
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
useEffect(() => {
loadDetail();
}, [resultId]);
const tabs = [
{ id: 'overview', label: '概览', count: undefined },
{ id: 'segments', label: '成功片段', count: detail?.segment_results.length },
{ id: 'failed', label: '失败片段', count: detail?.failed_segment_results.length },
];
if (loading) {
return (
<Modal isOpen onClose={onClose} title="匹配结果详情">
<div className="flex justify-center items-center h-64">
<LoadingSpinner text="加载详情..." />
</div>
</Modal>
);
}
if (error || !detail) {
return (
<Modal isOpen onClose={onClose} title="匹配结果详情">
<div className="p-4">
<ErrorMessage message={error || '数据加载失败'} />
</div>
</Modal>
);
}
const { matching_result, segment_results, failed_segment_results } = detail;
return (
<Modal
isOpen
onClose={onClose}
title="匹配结果详情"
size="lg"
>
<div className="max-h-[80vh] overflow-y-auto">
{/* 头部信息 */}
<div className="p-6 border-b border-gray-200">
{editMode ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
value={editForm.result_name}
onChange={(e) => setEditForm(prev => ({ ...prev, result_name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<textarea
value={editForm.description}
onChange={(e) => setEditForm(prev => ({ ...prev, description: e.target.value }))}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
(0-5)
</label>
<input
type="number"
min="0"
max="5"
step="0.1"
value={editForm.quality_score}
onChange={(e) => setEditForm(prev => ({ ...prev, quality_score: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex space-x-2">
<button
onClick={handleSave}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
</button>
<button
onClick={() => setEditMode(false)}
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
>
</button>
</div>
</div>
) : (
<div>
<div className="flex items-start justify-between">
<div>
<h2 className="text-xl font-semibold text-gray-900">
{matching_result.result_name}
</h2>
{matching_result.description && (
<p className="text-gray-600 mt-1">{matching_result.description}</p>
)}
</div>
<div className="flex items-center space-x-2">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusStyle(matching_result.status)}`}>
{matching_result.status === MatchingResultStatus.Success ? '匹配成功' :
matching_result.status === MatchingResultStatus.PartialSuccess ? '部分成功' :
matching_result.status === MatchingResultStatus.Failed ? '匹配失败' : '已取消'}
</span>
<button
onClick={() => setEditMode(true)}
className="px-3 py-1 text-sm bg-gray-600 text-white rounded-md hover:bg-gray-700"
>
</button>
</div>
</div>
{/* 统计信息 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{matching_result.success_rate.toFixed(1)}%
</div>
<div className="text-sm text-gray-500"></div>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-gray-900">
{matching_result.matched_segments}/{matching_result.total_segments}
</div>
<div className="text-sm text-gray-500"></div>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-gray-900">
{matching_result.used_materials}
</div>
<div className="text-sm text-gray-500">使</div>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-gray-900">
{formatDuration(matching_result.matching_duration_ms)}
</div>
<div className="text-sm text-gray-500"></div>
</div>
</div>
{/* 质量评分 */}
{matching_result.quality_score && (
<div className="mt-4 p-3 bg-yellow-50 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">:</span>
<div className="flex items-center">
<div className="flex text-yellow-400 mr-2">
{[1, 2, 3, 4, 5].map((star) => (
<svg
key={star}
className={`w-5 h-5 ${
star <= matching_result.quality_score! ? 'fill-current' : 'text-gray-300'
}`}
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
<span className="text-sm font-medium text-gray-900">
{matching_result.quality_score.toFixed(1)}
</span>
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* 标签页导航 */}
<TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* 标签页内容 */}
<div className="p-6">
{activeTab === 'overview' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-3"></h3>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">ID:</dt>
<dd className="text-sm text-gray-900">{matching_result.project_id}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">ID:</dt>
<dd className="text-sm text-gray-900">{matching_result.template_id}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">ID:</dt>
<dd className="text-sm text-gray-900">{matching_result.binding_id}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">:</dt>
<dd className="text-sm text-gray-900">
{new Date(matching_result.created_at).toLocaleString('zh-CN')}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">:</dt>
<dd className="text-sm text-gray-900">
{new Date(matching_result.updated_at).toLocaleString('zh-CN')}
</dd>
</div>
</dl>
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 mb-3"></h3>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">:</dt>
<dd className="text-sm text-gray-900">{matching_result.total_segments}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">:</dt>
<dd className="text-sm text-green-600">{matching_result.matched_segments}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">:</dt>
<dd className="text-sm text-red-600">{matching_result.failed_segments}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">使:</dt>
<dd className="text-sm text-gray-900">{matching_result.used_materials}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">使:</dt>
<dd className="text-sm text-gray-900">{matching_result.used_models}</dd>
</div>
</dl>
</div>
</div>
</div>
)}
{activeTab === 'segments' && (
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">
({segment_results.length})
</h3>
{segment_results.length === 0 ? (
<p className="text-gray-500 text-center py-8"></p>
) : (
<div className="space-y-3">
{segment_results.map((segment) => (
<div key={segment.id} className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex justify-between items-start">
<div className="flex-1">
<h4 className="font-medium text-gray-900">{segment.track_segment_name}</h4>
<p className="text-sm text-gray-600 mt-1">
: {segment.material_name}
{segment.model_name && ` | 模特: ${segment.model_name}`}
</p>
<p className="text-sm text-gray-500 mt-1">
: {formatTime(segment.segment_duration)} |
: {formatTime(segment.start_time)} - {formatTime(segment.end_time)}
</p>
<p className="text-sm text-gray-500 mt-1">
: {segment.match_reason}
</p>
</div>
<div className="text-right">
<div className="text-lg font-bold text-green-600">
{(segment.match_score * 100).toFixed(1)}%
</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'failed' && (
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">
({failed_segment_results.length})
</h3>
{failed_segment_results.length === 0 ? (
<p className="text-gray-500 text-center py-8"></p>
) : (
<div className="space-y-3">
{failed_segment_results.map((segment) => (
<div key={segment.id} className="bg-red-50 border border-red-200 rounded-lg p-4">
<div>
<h4 className="font-medium text-gray-900">{segment.track_segment_name}</h4>
<p className="text-sm text-gray-600 mt-1">
: {segment.matching_rule_type}
</p>
<p className="text-sm text-gray-500 mt-1">
: {formatTime(segment.segment_duration)} |
: {formatTime(segment.start_time)} - {formatTime(segment.end_time)}
</p>
<p className="text-sm text-red-600 mt-2 font-medium">
: {segment.failure_reason}
</p>
{segment.failure_details && (
<p className="text-sm text-gray-500 mt-1">
: {segment.failure_details}
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,332 @@
import React, { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
TemplateMatchingResult,
TemplateMatchingResultQueryOptions,
MatchingResultStatus,
MatchingStatistics
} from '../types/templateMatchingResult';
import { LoadingSpinner } from './LoadingSpinner';
import { ErrorMessage } from './ErrorMessage';
import { EmptyState } from './EmptyState';
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
import { CustomSelect } from './CustomSelect';
import { TemplateMatchingResultCard } from './TemplateMatchingResultCard';
import { TemplateMatchingResultDetailModal } from './TemplateMatchingResultDetailModal';
import { TemplateMatchingResultStatsPanel } from './TemplateMatchingResultStatsPanel';
interface TemplateMatchingResultManagerProps {
projectId?: string;
templateId?: string;
bindingId?: string;
showStats?: boolean;
onResultSelect?: (result: TemplateMatchingResult) => void;
}
export const TemplateMatchingResultManager: React.FC<TemplateMatchingResultManagerProps> = ({
projectId,
templateId,
bindingId,
showStats = true,
onResultSelect,
}) => {
const [results, setResults] = useState<TemplateMatchingResult[]>([]);
const [statistics, setStatistics] = useState<MatchingStatistics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedResult, setSelectedResult] = useState<TemplateMatchingResult | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
result: TemplateMatchingResult | null;
}>({ show: false, result: null });
// 过滤和排序状态
const [filters, setFilters] = useState<{
status?: MatchingResultStatus;
searchKeyword?: string;
sortBy: string;
sortOrder: string;
}>({
sortBy: 'created_at',
sortOrder: 'desc',
});
const [pagination, setPagination] = useState({
page: 1,
pageSize: 20,
total: 0,
});
// 加载匹配结果列表
const loadResults = async () => {
try {
setLoading(true);
setError(null);
const queryOptions: TemplateMatchingResultQueryOptions = {
project_id: projectId,
template_id: templateId,
binding_id: bindingId,
status: filters.status,
search_keyword: filters.searchKeyword,
sort_by: filters.sortBy,
sort_order: filters.sortOrder,
limit: pagination.pageSize,
offset: (pagination.page - 1) * pagination.pageSize,
};
const resultList = await invoke<TemplateMatchingResult[]>('list_matching_results', {
options: queryOptions,
});
setResults(resultList);
setPagination(prev => ({ ...prev, total: resultList.length }));
} catch (err) {
setError(err as string);
} finally {
setLoading(false);
}
};
// 加载统计信息
const loadStatistics = async () => {
if (!showStats) return;
try {
const stats = await invoke<MatchingStatistics>('get_matching_statistics', {
projectId,
});
setStatistics(stats);
} catch (err) {
console.error('加载统计信息失败:', err);
}
};
// 删除匹配结果
const handleDelete = async (result: TemplateMatchingResult) => {
try {
await invoke<boolean>('soft_delete_matching_result', {
resultId: result.id,
});
// 重新加载列表
await loadResults();
await loadStatistics();
setDeleteConfirm({ show: false, result: null });
} catch (err) {
setError(`删除失败: ${err}`);
}
};
// 查看详情
const handleViewDetail = (result: TemplateMatchingResult) => {
setSelectedResult(result);
setShowDetailModal(true);
onResultSelect?.(result);
};
// 处理过滤器变化
const handleFilterChange = (newFilters: Partial<typeof filters>) => {
setFilters(prev => ({ ...prev, ...newFilters }));
setPagination(prev => ({ ...prev, page: 1 })); // 重置到第一页
};
// 处理分页变化
const handlePageChange = (page: number) => {
setPagination(prev => ({ ...prev, page }));
};
// 初始加载
useEffect(() => {
loadResults();
loadStatistics();
}, [projectId, templateId, bindingId, filters, pagination.page]);
// 状态选项
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'Success', label: '匹配成功' },
{ value: 'PartialSuccess', label: '部分成功' },
{ value: 'Failed', label: '匹配失败' },
{ value: 'Cancelled', label: '已取消' },
];
// 排序选项
const sortOptions = [
{ value: 'created_at', label: '创建时间' },
{ value: 'updated_at', label: '更新时间' },
{ value: 'success_rate', label: '成功率' },
{ value: 'result_name', label: '结果名称' },
{ value: 'total_segments', label: '片段数量' },
{ value: 'matching_duration_ms', label: '匹配耗时' },
];
const sortOrderOptions = [
{ value: 'desc', label: '降序' },
{ value: 'asc', label: '升序' },
];
if (loading && results.length === 0) {
return <LoadingSpinner text="加载匹配结果..." />;
}
return (
<div className="template-matching-result-manager">
{/* 统计面板 */}
{showStats && statistics && (
<TemplateMatchingResultStatsPanel statistics={statistics} />
)}
{/* 过滤器和搜索 */}
<div className="filters-section bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 搜索框 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
placeholder="搜索结果名称或描述..."
value={filters.searchKeyword || ''}
onChange={(e) => handleFilterChange({ searchKeyword: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* 状态过滤 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<CustomSelect
options={statusOptions}
value={filters.status || ''}
onChange={(value) => handleFilterChange({ status: value as MatchingResultStatus })}
placeholder="选择状态"
/>
</div>
{/* 排序字段 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<CustomSelect
options={sortOptions}
value={filters.sortBy}
onChange={(value) => handleFilterChange({ sortBy: value })}
/>
</div>
{/* 排序顺序 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<CustomSelect
options={sortOrderOptions}
value={filters.sortOrder}
onChange={(value) => handleFilterChange({ sortOrder: value })}
/>
</div>
</div>
{/* 操作按钮 */}
<div className="flex justify-between items-center mt-4">
<div className="text-sm text-gray-500">
{pagination.total}
</div>
<button
onClick={() => {
loadResults();
loadStatistics();
}}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
</button>
</div>
</div>
{/* 错误信息 */}
{error && (
<ErrorMessage
message={error}
onDismiss={() => setError(null)}
/>
)}
{/* 结果列表 */}
{results.length === 0 ? (
<EmptyState
title="暂无匹配结果"
description="还没有任何模板匹配结果,请先执行模板匹配操作。"
icon="📊"
/>
) : (
<div className="results-grid grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{results.map((result) => (
<TemplateMatchingResultCard
key={result.id}
result={result}
onViewDetail={() => handleViewDetail(result)}
onDelete={() => setDeleteConfirm({ show: true, result })}
/>
))}
</div>
)}
{/* 分页 */}
{pagination.total > pagination.pageSize && (
<div className="pagination-section mt-6 flex justify-center">
<div className="flex items-center space-x-2">
<button
onClick={() => handlePageChange(pagination.page - 1)}
disabled={pagination.page <= 1}
className="px-3 py-2 border border-gray-300 rounded-md disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
</button>
<span className="px-3 py-2 text-sm text-gray-700">
{pagination.page} {Math.ceil(pagination.total / pagination.pageSize)}
</span>
<button
onClick={() => handlePageChange(pagination.page + 1)}
disabled={pagination.page >= Math.ceil(pagination.total / pagination.pageSize)}
className="px-3 py-2 border border-gray-300 rounded-md disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
</button>
</div>
</div>
)}
{/* 详情模态框 */}
{showDetailModal && selectedResult && (
<TemplateMatchingResultDetailModal
resultId={selectedResult.id}
onClose={() => {
setShowDetailModal(false);
setSelectedResult(null);
}}
onUpdate={() => {
loadResults();
loadStatistics();
}}
/>
)}
{/* 删除确认对话框 */}
<DeleteConfirmDialog
isOpen={deleteConfirm.show}
title="删除匹配结果"
message={`确定要删除匹配结果 "${deleteConfirm.result?.result_name}" 吗?此操作不可撤销。`}
onConfirm={() => deleteConfirm.result && handleDelete(deleteConfirm.result)}
onCancel={() => setDeleteConfirm({ show: false, result: null })}
/>
</div>
);
};

View File

@@ -0,0 +1,215 @@
import React from 'react';
import { MatchingStatistics } from '../types/templateMatchingResult';
interface TemplateMatchingResultStatsPanelProps {
statistics: MatchingStatistics;
}
export const TemplateMatchingResultStatsPanel: React.FC<TemplateMatchingResultStatsPanelProps> = ({
statistics,
}) => {
// 计算成功率百分比
const successPercentage = statistics.total_results > 0
? (statistics.successful_results / statistics.total_results) * 100
: 0;
// 计算片段匹配率
const segmentMatchPercentage = statistics.total_segments > 0
? (statistics.matched_segments / statistics.total_segments) * 100
: 0;
// 获取成功率颜色
const getSuccessRateColor = (rate: number) => {
if (rate >= 90) return 'text-green-600';
if (rate >= 70) return 'text-yellow-600';
if (rate >= 50) return 'text-orange-600';
return 'text-red-600';
};
// 统计卡片组件
const StatCard: React.FC<{
title: string;
value: string | number;
subtitle?: string;
color?: string;
icon?: string;
}> = ({ title, value, subtitle, color = 'text-gray-900', icon }) => (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">{title}</p>
<p className={`text-2xl font-bold ${color}`}>{value}</p>
{subtitle && (
<p className="text-xs text-gray-400 mt-1">{subtitle}</p>
)}
</div>
{icon && (
<div className="text-2xl opacity-60">{icon}</div>
)}
</div>
</div>
);
return (
<div className="template-matching-result-stats-panel mb-6">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg p-6 mb-4">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<span className="mr-2">📊</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 总结果数 */}
<StatCard
title="总匹配结果"
value={statistics.total_results}
subtitle="个匹配结果"
icon="📋"
/>
{/* 成功结果数 */}
<StatCard
title="成功结果"
value={statistics.successful_results}
subtitle={`${successPercentage.toFixed(1)}% 成功率`}
color={getSuccessRateColor(successPercentage)}
icon="✅"
/>
{/* 总片段数 */}
<StatCard
title="总片段数"
value={statistics.total_segments}
subtitle="个模板片段"
icon="🎬"
/>
{/* 匹配片段数 */}
<StatCard
title="匹配片段"
value={statistics.matched_segments}
subtitle={`${segmentMatchPercentage.toFixed(1)}% 匹配率`}
color={getSuccessRateColor(segmentMatchPercentage)}
icon="🎯"
/>
</div>
</div>
{/* 详细统计 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 素材使用统计 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3 flex items-center">
<span className="mr-2">🎥</span>
使
</h3>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">使:</span>
<span className="text-sm font-medium text-gray-900">
{statistics.total_materials}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">:</span>
<span className="text-sm font-medium text-gray-900">
{statistics.total_results > 0
? (statistics.total_materials / statistics.total_results).toFixed(1)
: '0'
}
</span>
</div>
</div>
</div>
{/* 模特使用统计 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3 flex items-center">
<span className="mr-2">👤</span>
使
</h3>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">使:</span>
<span className="text-sm font-medium text-gray-900">
{statistics.total_models}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">:</span>
<span className="text-sm font-medium text-gray-900">
{statistics.total_results > 0
? (statistics.total_models / statistics.total_results).toFixed(1)
: '0'
}
</span>
</div>
</div>
</div>
{/* 质量统计 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3 flex items-center">
<span className="mr-2"></span>
</h3>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">:</span>
<span className={`text-sm font-medium ${getSuccessRateColor(statistics.average_success_rate)}`}>
{statistics.average_success_rate.toFixed(1)}%
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">:</span>
<span className="text-sm font-medium text-gray-900">
{statistics.total_segments - statistics.matched_segments}
</span>
</div>
</div>
</div>
</div>
{/* 进度条显示 */}
<div className="mt-4 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3"></h3>
{/* 结果成功率进度条 */}
<div className="mb-3">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span></span>
<span>{successPercentage.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${
successPercentage >= 90 ? 'bg-green-500' :
successPercentage >= 70 ? 'bg-yellow-500' :
successPercentage >= 50 ? 'bg-orange-500' : 'bg-red-500'
}`}
style={{ width: `${Math.min(successPercentage, 100)}%` }}
/>
</div>
</div>
{/* 片段匹配率进度条 */}
<div>
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span></span>
<span>{segmentMatchPercentage.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${
segmentMatchPercentage >= 90 ? 'bg-green-500' :
segmentMatchPercentage >= 70 ? 'bg-yellow-500' :
segmentMatchPercentage >= 50 ? 'bg-orange-500' : 'bg-red-500'
}`}
style={{ width: `${Math.min(segmentMatchPercentage, 100)}%` }}
/>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,298 @@
/**
* 模板匹配结果相关的类型定义
* 遵循前端开发规范的类型设计原则
*/
// 匹配结果状态枚举
export enum MatchingResultStatus {
Success = 'Success',
PartialSuccess = 'PartialSuccess',
Failed = 'Failed',
Cancelled = 'Cancelled'
}
// 模板匹配结果
export interface TemplateMatchingResult {
id: string;
project_id: string;
template_id: string;
binding_id: string;
result_name: string;
description?: string;
total_segments: number;
matched_segments: number;
failed_segments: number;
success_rate: number;
used_materials: number;
used_models: number;
matching_duration_ms: number;
quality_score?: number;
status: MatchingResultStatus;
metadata?: string;
created_at: string;
updated_at: string;
is_active: boolean;
}
// 匹配片段结果
export interface MatchingSegmentResult {
id: string;
matching_result_id: string;
track_segment_id: string;
track_segment_name: string;
material_segment_id: string;
material_id: string;
material_name: string;
model_id?: string;
model_name?: string;
match_score: number;
match_reason: string;
segment_duration: number;
start_time: number;
end_time: number;
properties?: string;
created_at: string;
updated_at: string;
}
// 匹配失败片段结果
export interface MatchingFailedSegmentResult {
id: string;
matching_result_id: string;
track_segment_id: string;
track_segment_name: string;
matching_rule_type: string;
matching_rule_data?: string;
failure_reason: string;
failure_details?: string;
segment_duration: number;
start_time: number;
end_time: number;
created_at: string;
updated_at: string;
}
// 模板匹配结果详情
export interface TemplateMatchingResultDetail {
matching_result: TemplateMatchingResult;
segment_results: MatchingSegmentResult[];
failed_segment_results: MatchingFailedSegmentResult[];
}
// 创建模板匹配结果请求
export interface CreateTemplateMatchingResultRequest {
project_id: string;
template_id: string;
binding_id: string;
result_name: string;
description?: string;
}
// 模板匹配结果查询选项
export interface TemplateMatchingResultQueryOptions {
project_id?: string;
template_id?: string;
binding_id?: string;
status?: MatchingResultStatus;
limit?: number;
offset?: number;
search_keyword?: string;
sort_by?: string; // "created_at", "success_rate", "result_name"
sort_order?: string; // "asc", "desc"
}
// 匹配统计信息
export interface MatchingStatistics {
total_results: number;
successful_results: number;
total_segments: number;
matched_segments: number;
total_materials: number;
total_models: number;
average_success_rate: number;
}
// 保存匹配结果请求
export interface SaveMatchingResultRequest {
service_result: any; // MaterialMatchingResult from service
result_name: string;
description?: string;
matching_duration_ms: number;
}
// 更新匹配结果信息请求
export interface UpdateMatchingResultInfoRequest {
result_id: string;
result_name?: string;
description?: string;
}
// 设置质量评分请求
export interface SetQualityScoreRequest {
result_id: string;
quality_score: number;
}
// 匹配结果状态选项
export interface MatchingResultStatusOption {
value: string;
label: string;
}
// 匹配结果列表项(用于列表显示)
export interface MatchingResultListItem {
id: string;
result_name: string;
project_id: string;
template_id: string;
status: MatchingResultStatus;
success_rate: number;
total_segments: number;
matched_segments: number;
failed_segments: number;
used_materials: number;
used_models: number;
matching_duration_ms: number;
quality_score?: number;
created_at: string;
updated_at: string;
}
// 匹配结果卡片信息(用于卡片显示)
export interface MatchingResultCardInfo {
id: string;
result_name: string;
description?: string;
status: MatchingResultStatus;
status_display: string;
success_rate: number;
total_segments: number;
matched_segments: number;
failed_segments: number;
used_materials: number;
used_models: number;
matching_duration_display: string; // 格式化后的时长显示
quality_score?: number;
created_at_display: string; // 格式化后的创建时间显示
updated_at_display: string; // 格式化后的更新时间显示
}
// 匹配结果过滤选项
export interface MatchingResultFilterOptions {
status?: MatchingResultStatus[];
success_rate_min?: number;
success_rate_max?: number;
date_range?: {
start_date: string;
end_date: string;
};
has_quality_score?: boolean;
min_segments?: number;
max_segments?: number;
}
// 匹配结果排序选项
export interface MatchingResultSortOptions {
field: 'created_at' | 'updated_at' | 'success_rate' | 'result_name' | 'total_segments' | 'matching_duration_ms';
order: 'asc' | 'desc';
}
// 匹配结果分页选项
export interface MatchingResultPaginationOptions {
page: number;
page_size: number;
total?: number;
}
// 匹配结果搜索选项
export interface MatchingResultSearchOptions {
keyword?: string;
filter?: MatchingResultFilterOptions;
sort?: MatchingResultSortOptions;
pagination?: MatchingResultPaginationOptions;
}
// 匹配结果导出选项
export interface MatchingResultExportOptions {
format: 'json' | 'csv' | 'excel';
include_segments: boolean;
include_failed_segments: boolean;
date_range?: {
start_date: string;
end_date: string;
};
}
// 匹配结果批量操作选项
export interface MatchingResultBatchOperationOptions {
operation: 'delete' | 'soft_delete' | 'export' | 'set_quality_score';
result_ids: string[];
parameters?: Record<string, any>;
}
// 匹配结果分析报告
export interface MatchingResultAnalysisReport {
total_results: number;
status_distribution: Record<MatchingResultStatus, number>;
success_rate_distribution: {
excellent: number; // 90-100%
good: number; // 70-89%
fair: number; // 50-69%
poor: number; // 0-49%
};
duration_statistics: {
min: number;
max: number;
average: number;
median: number;
};
segment_statistics: {
total_segments: number;
total_matched: number;
total_failed: number;
average_segments_per_result: number;
};
material_usage: {
total_materials_used: number;
average_materials_per_result: number;
most_used_materials: Array<{
material_id: string;
material_name: string;
usage_count: number;
}>;
};
model_usage: {
total_models_used: number;
average_models_per_result: number;
most_used_models: Array<{
model_id: string;
model_name: string;
usage_count: number;
}>;
};
quality_score_distribution?: {
excellent: number; // 4.5-5.0
good: number; // 3.5-4.4
fair: number; // 2.5-3.4
poor: number; // 0-2.4
unrated: number;
};
trends: {
success_rate_trend: 'improving' | 'declining' | 'stable';
duration_trend: 'faster' | 'slower' | 'stable';
quality_trend?: 'improving' | 'declining' | 'stable';
};
}
// 匹配结果操作历史
export interface MatchingResultOperationHistory {
id: string;
result_id: string;
operation_type: 'create' | 'update' | 'delete' | 'quality_score_set';
operation_details: string;
performed_by?: string;
performed_at: string;
old_values?: Record<string, any>;
new_values?: Record<string, any>;
}

View File

@@ -0,0 +1,383 @@
# 模板匹配结果 API 文档
## 概述
模板匹配结果 API 提供了完整的模板匹配结果管理功能,包括保存匹配结果、查询历史记录、管理匹配详情等操作。
## 数据模型
### TemplateMatchingResult
模板匹配结果主要实体。
```typescript
interface TemplateMatchingResult {
id: string; // 匹配结果唯一标识
project_id: string; // 项目ID
template_id: string; // 模板ID
binding_id: string; // 绑定ID
result_name: string; // 结果名称
description?: string; // 结果描述
total_segments: number; // 总片段数
matched_segments: number; // 匹配成功片段数
failed_segments: number; // 匹配失败片段数
success_rate: number; // 成功率(百分比)
used_materials: number; // 使用的素材数量
used_models: number; // 使用的模特数量
matching_duration_ms: number; // 匹配耗时(毫秒)
quality_score?: number; // 质量评分0-5
status: MatchingResultStatus; // 匹配状态
metadata?: string; // 额外元数据JSON格式
created_at: string; // 创建时间
updated_at: string; // 更新时间
is_active: boolean; // 是否激活
}
```
### MatchingResultStatus
匹配结果状态枚举。
```typescript
enum MatchingResultStatus {
Success = 'Success', // 匹配成功
PartialSuccess = 'PartialSuccess', // 部分成功
Failed = 'Failed', // 匹配失败
Cancelled = 'Cancelled' // 已取消
}
```
### MatchingSegmentResult
成功匹配的片段结果。
```typescript
interface MatchingSegmentResult {
id: string; // 片段结果ID
matching_result_id: string; // 关联的匹配结果ID
track_segment_id: string; // 模板轨道片段ID
track_segment_name: string; // 轨道片段名称
material_segment_id: string; // 匹配到的素材片段ID
material_id: string; // 素材ID
material_name: string; // 素材名称
model_id?: string; // 模特ID
model_name?: string; // 模特名称
match_score: number; // 匹配评分0-1
match_reason: string; // 匹配原因
segment_duration: number; // 片段时长(微秒)
start_time: number; // 开始时间(微秒)
end_time: number; // 结束时间(微秒)
properties?: string; // 片段属性JSON格式
created_at: string; // 创建时间
updated_at: string; // 更新时间
}
```
### MatchingFailedSegmentResult
匹配失败的片段结果。
```typescript
interface MatchingFailedSegmentResult {
id: string; // 失败片段结果ID
matching_result_id: string; // 关联的匹配结果ID
track_segment_id: string; // 模板轨道片段ID
track_segment_name: string; // 轨道片段名称
matching_rule_type: string; // 匹配规则类型
matching_rule_data?: string; // 匹配规则数据JSON格式
failure_reason: string; // 失败原因
failure_details?: string; // 失败详情JSON格式
segment_duration: number; // 片段时长(微秒)
start_time: number; // 开始时间(微秒)
end_time: number; // 结束时间(微秒)
created_at: string; // 创建时间
updated_at: string; // 更新时间
}
```
## API 接口
### 1. 保存匹配结果
自动保存素材匹配结果到数据库。
```typescript
save_matching_result(
service_result: MaterialMatchingResult,
result_name: string,
description?: string,
matching_duration_ms: number
): Promise<TemplateMatchingResult>
```
**参数说明:**
- `service_result`: 素材匹配服务返回的匹配结果
- `result_name`: 结果名称
- `description`: 可选的结果描述
- `matching_duration_ms`: 匹配耗时(毫秒)
**返回值:** 保存后的模板匹配结果
### 2. 获取匹配结果详情
获取包含所有片段信息的完整匹配结果详情。
```typescript
get_matching_result_detail(result_id: string): Promise<TemplateMatchingResultDetail | null>
```
**参数说明:**
- `result_id`: 匹配结果ID
**返回值:** 匹配结果详情,包含成功和失败的片段信息
### 3. 获取项目匹配结果列表
获取指定项目的所有匹配结果。
```typescript
get_project_matching_results(project_id: string): Promise<TemplateMatchingResult[]>
```
**参数说明:**
- `project_id`: 项目ID
**返回值:** 匹配结果列表
### 4. 获取模板匹配结果列表
获取指定模板的所有匹配结果。
```typescript
get_template_matching_results(template_id: string): Promise<TemplateMatchingResult[]>
```
**参数说明:**
- `template_id`: 模板ID
**返回值:** 匹配结果列表
### 5. 获取绑定匹配结果列表
获取指定绑定的所有匹配结果。
```typescript
get_binding_matching_results(binding_id: string): Promise<TemplateMatchingResult[]>
```
**参数说明:**
- `binding_id`: 绑定ID
**返回值:** 匹配结果列表
### 6. 查询匹配结果列表
根据查询条件获取匹配结果列表。
```typescript
list_matching_results(options: TemplateMatchingResultQueryOptions): Promise<TemplateMatchingResult[]>
```
**参数说明:**
- `options`: 查询选项
```typescript
interface TemplateMatchingResultQueryOptions {
project_id?: string; // 项目ID过滤
template_id?: string; // 模板ID过滤
binding_id?: string; // 绑定ID过滤
status?: MatchingResultStatus; // 状态过滤
limit?: number; // 限制数量
offset?: number; // 偏移量
search_keyword?: string; // 搜索关键词
sort_by?: string; // 排序字段
sort_order?: string; // 排序顺序
}
```
### 7. 删除匹配结果
永久删除匹配结果。
```typescript
delete_matching_result(result_id: string): Promise<boolean>
```
**参数说明:**
- `result_id`: 匹配结果ID
**返回值:** 是否删除成功
### 8. 软删除匹配结果
软删除匹配结果(标记为不活跃)。
```typescript
soft_delete_matching_result(result_id: string): Promise<boolean>
```
**参数说明:**
- `result_id`: 匹配结果ID
**返回值:** 是否删除成功
### 9. 更新匹配结果信息
更新匹配结果的名称和描述。
```typescript
update_matching_result_info(
result_id: string,
result_name?: string,
description?: string
): Promise<TemplateMatchingResult | null>
```
**参数说明:**
- `result_id`: 匹配结果ID
- `result_name`: 新的结果名称(可选)
- `description`: 新的结果描述(可选)
**返回值:** 更新后的匹配结果
### 10. 设置质量评分
为匹配结果设置质量评分。
```typescript
set_matching_result_quality_score(
result_id: string,
quality_score: number
): Promise<TemplateMatchingResult | null>
```
**参数说明:**
- `result_id`: 匹配结果ID
- `quality_score`: 质量评分0-5
**返回值:** 更新后的匹配结果
### 11. 获取匹配统计信息
获取匹配结果的统计信息。
```typescript
get_matching_statistics(project_id?: string): Promise<MatchingStatistics>
```
**参数说明:**
- `project_id`: 可选的项目ID如果提供则只统计该项目的数据
**返回值:** 匹配统计信息
```typescript
interface MatchingStatistics {
total_results: number; // 总结果数
successful_results: number; // 成功结果数
total_segments: number; // 总片段数
matched_segments: number; // 匹配片段数
total_materials: number; // 总素材数
total_models: number; // 总模特数
average_success_rate: number; // 平均成功率
}
```
### 12. 执行匹配并自动保存
执行素材匹配并自动保存结果到数据库。
```typescript
execute_material_matching_with_save(
request: MaterialMatchingRequest,
result_name: string,
description?: string
): Promise<[MaterialMatchingResult, TemplateMatchingResult | null]>
```
**参数说明:**
- `request`: 素材匹配请求
- `result_name`: 结果名称
- `description`: 可选的结果描述
**返回值:** 包含匹配结果和保存结果的元组
## 错误处理
所有 API 接口都会返回 `Result<T, String>` 类型,其中错误信息为字符串格式。常见错误包括:
- `"模板匹配结果不存在"`: 指定的匹配结果ID不存在
- `"项目不存在"`: 指定的项目ID不存在
- `"模板不存在"`: 指定的模板ID不存在
- `"数据库操作失败"`: 数据库操作出现错误
- `"参数验证失败"`: 输入参数不符合要求
## 使用示例
### 保存匹配结果
```typescript
import { invoke } from '@tauri-apps/api/core';
// 执行匹配并保存结果
const result = await invoke('execute_material_matching_with_save', {
request: {
project_id: 'project-1',
template_id: 'template-1',
binding_id: 'binding-1',
overwrite_existing: false,
},
resultName: '我的匹配结果',
description: '这是一个测试匹配结果',
});
console.log('匹配结果:', result[0]);
console.log('保存结果:', result[1]);
```
### 查询匹配结果
```typescript
// 获取项目的所有匹配结果
const results = await invoke('get_project_matching_results', {
projectId: 'project-1',
});
// 获取匹配结果详情
const detail = await invoke('get_matching_result_detail', {
resultId: 'result-1',
});
// 查询匹配结果(带过滤条件)
const filteredResults = await invoke('list_matching_results', {
options: {
project_id: 'project-1',
status: 'Success',
limit: 10,
offset: 0,
sort_by: 'created_at',
sort_order: 'desc',
},
});
```
### 管理匹配结果
```typescript
// 更新匹配结果信息
await invoke('update_matching_result_info', {
resultId: 'result-1',
resultName: '更新后的名称',
description: '更新后的描述',
});
// 设置质量评分
await invoke('set_matching_result_quality_score', {
resultId: 'result-1',
qualityScore: 4.5,
});
// 软删除匹配结果
await invoke('soft_delete_matching_result', {
resultId: 'result-1',
});
```