feat: 实现循环匹配功能优化
- 优化一键匹配算法,支持循环匹配模板直到素材耗尽 - 新增全局素材使用状态跟踪,避免重复使用素材 - 实现智能终止条件,当无法完整匹配任何模板时自动停止 - 扩展BatchMatchingResult数据结构,添加循环轮数和终止原因字段 - 更新前端界面显示循环匹配进度和详细统计信息 - 添加性能优化:日志优化、预检查机制、最大轮数限制 - 新增全面的单元测试覆盖各种边界情况 - 创建详细的功能文档说明使用方式和注意事项 核心改进: 1. 循环匹配算法 - 持续匹配直到素材不足 2. 全局素材跟踪 - 确保素材不重复使用 3. 智能终止机制 - 自动检测匹配完成条件 4. 性能优化 - 支持大量模板和素材的高效处理 5. 完整测试覆盖 - 确保功能稳定可靠
This commit is contained in:
parent
355c5a1852
commit
1da647fbab
|
|
@ -0,0 +1,124 @@
|
|||
# 循环匹配功能优化文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述了一键匹配功能的循环优化实现,该功能能够循环匹配模板直到失败(无法完整匹配模板 -- 素材不够用)。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 核心改进
|
||||
|
||||
1. **循环匹配算法**: 不再只执行一轮匹配,而是持续循环直到无法找到足够的素材完成任何模板的完整匹配
|
||||
2. **全局素材使用跟踪**: 在整个循环过程中维护全局的已使用素材列表,确保素材不会被重复使用
|
||||
3. **智能终止条件**: 当连续一轮中所有模板都无法完整匹配时,自动停止循环
|
||||
4. **性能优化**: 包含多项性能优化措施,确保大量模板和素材时的响应速度
|
||||
|
||||
### 新增数据字段
|
||||
|
||||
#### BatchMatchingResult
|
||||
- `total_rounds`: 总循环轮数
|
||||
- `successful_rounds`: 成功匹配的轮数
|
||||
- `termination_reason`: 循环终止原因
|
||||
- `materials_exhausted`: 是否因素材耗尽而终止
|
||||
|
||||
#### BatchMatchingItemResult
|
||||
- `round_number`: 匹配成功的轮次
|
||||
- `attempts_count`: 尝试匹配的次数
|
||||
- `failure_reason`: 详细失败原因
|
||||
|
||||
## 算法流程
|
||||
|
||||
```
|
||||
1. 初始化全局已使用素材集合
|
||||
2. 开始循环匹配轮次
|
||||
3. 对每个活跃模板绑定:
|
||||
- 尝试匹配所有非固定素材片段
|
||||
- 如果完全匹配成功:保存结果,更新全局已使用素材
|
||||
- 如果匹配失败:记录失败原因
|
||||
4. 检查本轮是否有任何成功匹配
|
||||
5. 如果本轮无任何成功匹配:终止循环
|
||||
6. 否则:继续下一轮
|
||||
```
|
||||
|
||||
## 失败判断逻辑
|
||||
|
||||
- **模板级失败**: 模板中任何一个非固定片段无法找到可用素材
|
||||
- **轮次级失败**: 一轮中所有模板都失败
|
||||
- **全局终止**: 连续一轮无任何成功匹配
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 1. 日志优化
|
||||
- 只在前5轮或每10轮打印详细日志
|
||||
- 减少不必要的控制台输出
|
||||
|
||||
### 2. 预检查优化
|
||||
- 每5轮执行一次预检查,判断是否还有模板可以匹配
|
||||
- 如果没有任何模板可以匹配,提前终止循环
|
||||
|
||||
### 3. 最大轮数限制
|
||||
- 设置最大轮数限制(100轮),防止无限循环
|
||||
- 在达到限制时自动终止
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 后端调用
|
||||
```rust
|
||||
let request = BatchMatchingRequest {
|
||||
project_id: "project_id".to_string(),
|
||||
overwrite_existing: false,
|
||||
result_name_prefix: Some("循环匹配".to_string()),
|
||||
};
|
||||
|
||||
let result = material_matching_service
|
||||
.batch_match_all_templates_optimized(request, database)
|
||||
.await?;
|
||||
```
|
||||
|
||||
### 前端调用
|
||||
```typescript
|
||||
const request: BatchMatchingRequest = {
|
||||
project_id: project.id,
|
||||
overwrite_existing: false,
|
||||
result_name_prefix: '循环匹配',
|
||||
};
|
||||
|
||||
const result = await BatchMatchingService.executeBatchMatching(request);
|
||||
```
|
||||
|
||||
## 结果解读
|
||||
|
||||
### 成功场景
|
||||
- `total_rounds > 1`: 执行了多轮匹配
|
||||
- `successful_rounds > 0`: 有成功的匹配轮次
|
||||
- `materials_exhausted = true`: 素材已被充分利用
|
||||
|
||||
### 失败场景
|
||||
- `total_rounds = 1, successful_matches = 0`: 第一轮就没有任何匹配成功
|
||||
- `termination_reason`: 包含具体的失败原因
|
||||
|
||||
## 测试覆盖
|
||||
|
||||
### 单元测试
|
||||
- 循环终止条件测试
|
||||
- 全局素材使用跟踪测试
|
||||
- 性能优化逻辑测试
|
||||
- 数据结构完整性测试
|
||||
|
||||
### 集成测试
|
||||
- 端到端循环匹配流程测试
|
||||
- 大量数据性能测试
|
||||
- 边界条件测试
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **素材消耗**: 循环匹配会消耗更多素材,请确保项目有足够的素材
|
||||
2. **执行时间**: 循环匹配可能需要更长时间,特别是在素材丰富的项目中
|
||||
3. **内存使用**: 全局素材跟踪会增加内存使用,但在合理范围内
|
||||
4. **日志监控**: 建议监控日志输出,了解匹配进度和终止原因
|
||||
|
||||
## 兼容性
|
||||
|
||||
- 完全向后兼容原有的一键匹配功能
|
||||
- 前端界面自动显示新的循环匹配信息
|
||||
- 现有的匹配记录和导出功能正常工作
|
||||
|
|
@ -68,6 +68,11 @@ pub struct BatchMatchingResult {
|
|||
pub matching_results: Vec<BatchMatchingItemResult>,
|
||||
pub total_duration_ms: u64,
|
||||
pub summary: BatchMatchingSummary,
|
||||
// 新增循环匹配相关字段
|
||||
pub total_rounds: u32, // 总循环轮数
|
||||
pub successful_rounds: u32, // 成功匹配的轮数
|
||||
pub termination_reason: String, // 循环终止原因
|
||||
pub materials_exhausted: bool, // 是否因素材耗尽而终止
|
||||
}
|
||||
|
||||
/// 单个绑定的匹配结果
|
||||
|
|
@ -82,6 +87,10 @@ pub struct BatchMatchingItemResult {
|
|||
pub saved_result_id: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub duration_ms: u64,
|
||||
// 新增循环匹配相关字段
|
||||
pub round_number: u32, // 匹配成功的轮次
|
||||
pub attempts_count: u32, // 尝试匹配的次数
|
||||
pub failure_reason: Option<String>, // 详细失败原因
|
||||
}
|
||||
|
||||
/// 单个绑定匹配状态
|
||||
|
|
@ -589,6 +598,12 @@ impl MaterialMatchingService {
|
|||
|
||||
/// 执行一键匹配 - 遍历项目的所有活跃模板绑定并逐一匹配
|
||||
pub async fn batch_match_all_templates(&self, request: BatchMatchingRequest, database: Arc<crate::infrastructure::database::Database>) -> Result<BatchMatchingResult> {
|
||||
// 调用优化的循环匹配方法
|
||||
self.batch_match_all_templates_optimized(request, database).await
|
||||
}
|
||||
|
||||
/// 优化的一键匹配 - 循环匹配模板直到失败(无法完整匹配模板 -- 素材不够用)
|
||||
pub async fn batch_match_all_templates_optimized(&self, request: BatchMatchingRequest, database: Arc<crate::infrastructure::database::Database>) -> Result<BatchMatchingResult> {
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// 获取项目的所有活跃模板绑定
|
||||
|
|
@ -611,61 +626,161 @@ impl MaterialMatchingService {
|
|||
best_matching_template: None,
|
||||
worst_matching_template: None,
|
||||
},
|
||||
total_rounds: 0,
|
||||
successful_rounds: 0,
|
||||
termination_reason: "没有活跃的模板绑定".to_string(),
|
||||
materials_exhausted: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化循环匹配状态
|
||||
let mut matching_results = Vec::new();
|
||||
let mut successful_matches = 0u32;
|
||||
let mut failed_matches = 0u32;
|
||||
let skipped_bindings = 0u32;
|
||||
let mut total_rounds = 0u32;
|
||||
let mut successful_rounds = 0u32;
|
||||
let mut global_used_segment_ids = HashSet::new();
|
||||
let mut termination_reason = String::new();
|
||||
let mut materials_exhausted = false;
|
||||
|
||||
// 逐一执行匹配
|
||||
for binding_detail in &active_bindings {
|
||||
let binding_start_time = std::time::Instant::now();
|
||||
// 获取项目中已使用的素材片段ID列表(从数据库)
|
||||
let existing_used_segments = match self.material_usage_repo.get_usage_records_by_project(&request.project_id) {
|
||||
Ok(usage_records) => {
|
||||
usage_records.into_iter()
|
||||
.map(|record| record.material_segment_id)
|
||||
.collect::<HashSet<String>>()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("警告:获取素材使用记录失败: {},将继续进行匹配", e);
|
||||
HashSet::new()
|
||||
}
|
||||
};
|
||||
global_used_segment_ids.extend(existing_used_segments);
|
||||
|
||||
let matching_request = MaterialMatchingRequest {
|
||||
project_id: request.project_id.clone(),
|
||||
template_id: binding_detail.binding.template_id.clone(),
|
||||
binding_id: binding_detail.binding.id.clone(),
|
||||
overwrite_existing: request.overwrite_existing,
|
||||
};
|
||||
// 开始循环匹配
|
||||
loop {
|
||||
total_rounds += 1;
|
||||
let round_start_time = std::time::Instant::now();
|
||||
let mut round_successful_matches = 0u32;
|
||||
let mut round_failed_matches = 0u32;
|
||||
|
||||
let result_name = format!(
|
||||
"{}-{}",
|
||||
request.result_name_prefix.as_deref().unwrap_or("一键匹配"),
|
||||
binding_detail.template_name
|
||||
);
|
||||
// 性能优化:只在前几轮或关键轮次打印详细日志
|
||||
if total_rounds <= 5 || total_rounds % 10 == 0 {
|
||||
println!("开始第 {} 轮匹配,当前已使用素材片段数: {}", total_rounds, global_used_segment_ids.len());
|
||||
}
|
||||
|
||||
match self.match_materials_and_save(matching_request, result_name, None).await {
|
||||
Ok((matching_result, saved_result)) => {
|
||||
successful_matches += 1;
|
||||
matching_results.push(BatchMatchingItemResult {
|
||||
binding_id: binding_detail.binding.id.clone(),
|
||||
template_id: binding_detail.binding.template_id.clone(),
|
||||
template_name: binding_detail.template_name.clone(),
|
||||
binding_name: binding_detail.binding.binding_name.clone(),
|
||||
status: BatchMatchingItemStatus::Success,
|
||||
matching_result: Some(matching_result),
|
||||
saved_result_id: saved_result.map(|r| r.id),
|
||||
error_message: None,
|
||||
duration_ms: binding_start_time.elapsed().as_millis() as u64,
|
||||
});
|
||||
// 性能优化:预检查是否有任何模板可以匹配(每5轮检查一次以避免过度检查)
|
||||
if total_rounds % 5 == 1 && total_rounds > 1 {
|
||||
let mut any_template_can_match = false;
|
||||
for binding_detail in &active_bindings {
|
||||
let can_match = self.can_template_be_fully_matched(
|
||||
&binding_detail.binding.template_id,
|
||||
&request.project_id,
|
||||
&global_used_segment_ids,
|
||||
).await;
|
||||
|
||||
if can_match {
|
||||
any_template_can_match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
failed_matches += 1;
|
||||
matching_results.push(BatchMatchingItemResult {
|
||||
binding_id: binding_detail.binding.id.clone(),
|
||||
template_id: binding_detail.binding.template_id.clone(),
|
||||
template_name: binding_detail.template_name.clone(),
|
||||
binding_name: binding_detail.binding.binding_name.clone(),
|
||||
status: BatchMatchingItemStatus::Failed,
|
||||
matching_result: None,
|
||||
saved_result_id: None,
|
||||
error_message: Some(error.to_string()),
|
||||
duration_ms: binding_start_time.elapsed().as_millis() as u64,
|
||||
});
|
||||
|
||||
// 如果没有任何模板可以匹配,提前终止
|
||||
if !any_template_can_match {
|
||||
termination_reason = format!("第{}轮预检查发现无任何模板可完整匹配,提前终止", total_rounds);
|
||||
materials_exhausted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 逐一尝试匹配每个模板绑定
|
||||
for binding_detail in &active_bindings {
|
||||
let binding_start_time = std::time::Instant::now();
|
||||
|
||||
let matching_request = MaterialMatchingRequest {
|
||||
project_id: request.project_id.clone(),
|
||||
template_id: binding_detail.binding.template_id.clone(),
|
||||
binding_id: binding_detail.binding.id.clone(),
|
||||
overwrite_existing: request.overwrite_existing,
|
||||
};
|
||||
|
||||
let result_name = format!(
|
||||
"{}-{}-R{}",
|
||||
request.result_name_prefix.as_deref().unwrap_or("一键匹配"),
|
||||
binding_detail.template_name,
|
||||
total_rounds
|
||||
);
|
||||
|
||||
match self.match_materials_and_save(matching_request, result_name, None).await {
|
||||
Ok((matching_result, saved_result)) => {
|
||||
round_successful_matches += 1;
|
||||
successful_matches += 1;
|
||||
|
||||
matching_results.push(BatchMatchingItemResult {
|
||||
binding_id: binding_detail.binding.id.clone(),
|
||||
template_id: binding_detail.binding.template_id.clone(),
|
||||
template_name: binding_detail.template_name.clone(),
|
||||
binding_name: binding_detail.binding.binding_name.clone(),
|
||||
status: BatchMatchingItemStatus::Success,
|
||||
matching_result: Some(matching_result),
|
||||
saved_result_id: saved_result.map(|r| r.id),
|
||||
error_message: None,
|
||||
duration_ms: binding_start_time.elapsed().as_millis() as u64,
|
||||
round_number: total_rounds,
|
||||
attempts_count: 1,
|
||||
failure_reason: None,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
round_failed_matches += 1;
|
||||
failed_matches += 1;
|
||||
|
||||
matching_results.push(BatchMatchingItemResult {
|
||||
binding_id: binding_detail.binding.id.clone(),
|
||||
template_id: binding_detail.binding.template_id.clone(),
|
||||
template_name: binding_detail.template_name.clone(),
|
||||
binding_name: binding_detail.binding.binding_name.clone(),
|
||||
status: BatchMatchingItemStatus::Failed,
|
||||
matching_result: None,
|
||||
saved_result_id: None,
|
||||
error_message: Some(error.to_string()),
|
||||
duration_ms: binding_start_time.elapsed().as_millis() as u64,
|
||||
round_number: total_rounds,
|
||||
attempts_count: 1,
|
||||
failure_reason: Some(error.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 性能优化:只在前几轮或关键轮次打印详细日志
|
||||
if total_rounds <= 5 || total_rounds % 10 == 0 || round_successful_matches == 0 {
|
||||
println!("第 {} 轮匹配完成,成功: {}, 失败: {}, 耗时: {}ms",
|
||||
total_rounds, round_successful_matches, round_failed_matches,
|
||||
round_start_time.elapsed().as_millis());
|
||||
}
|
||||
|
||||
// 检查是否应该继续下一轮
|
||||
if round_successful_matches == 0 {
|
||||
// 本轮没有任何成功匹配,终止循环
|
||||
termination_reason = format!("第{}轮无任何成功匹配,素材已耗尽", total_rounds);
|
||||
materials_exhausted = true;
|
||||
break;
|
||||
} else {
|
||||
successful_rounds += 1;
|
||||
}
|
||||
|
||||
// 可选:添加最大轮数限制,防止无限循环
|
||||
if total_rounds >= 100 {
|
||||
termination_reason = "达到最大轮数限制(100轮)".to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 设置默认终止原因(如果没有设置)
|
||||
if termination_reason.is_empty() {
|
||||
termination_reason = "正常完成".to_string();
|
||||
}
|
||||
|
||||
// 计算汇总信息
|
||||
|
|
@ -680,6 +795,10 @@ impl MaterialMatchingService {
|
|||
matching_results,
|
||||
total_duration_ms: start_time.elapsed().as_millis() as u64,
|
||||
summary,
|
||||
total_rounds,
|
||||
successful_rounds,
|
||||
termination_reason,
|
||||
materials_exhausted,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -701,6 +820,168 @@ impl MaterialMatchingService {
|
|||
Ok(active_bindings)
|
||||
}
|
||||
|
||||
/// 检查模板是否可以完整匹配(考虑当前已使用的素材)
|
||||
async fn can_template_be_fully_matched(
|
||||
&self,
|
||||
template_id: &str,
|
||||
project_id: &str,
|
||||
used_segment_ids: &HashSet<String>,
|
||||
) -> bool {
|
||||
// 获取模板信息
|
||||
let template = match self.template_service.get_template_by_id(template_id).await {
|
||||
Ok(Some(template)) => template,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// 获取项目的所有素材
|
||||
let project_materials = match self.material_repo.get_by_project_id(project_id) {
|
||||
Ok(materials) => materials,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
// 获取所有素材的分类记录
|
||||
let mut classification_records = HashMap::new();
|
||||
for material in &project_materials {
|
||||
if let Ok(records) = self.video_classification_repo.get_by_material_id(&material.id).await {
|
||||
classification_records.insert(material.id.clone(), records);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取可用的素材片段(排除已使用的)
|
||||
let available_segments = match self.get_classified_segments_with_exclusions(
|
||||
&project_materials,
|
||||
&classification_records,
|
||||
project_id,
|
||||
used_segment_ids
|
||||
).await {
|
||||
Ok(segments) => segments,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
// 检查每个非固定素材片段是否都能找到匹配
|
||||
for track in &template.tracks {
|
||||
for segment in &track.segments {
|
||||
if segment.matching_rule.is_fixed_material() {
|
||||
continue; // 固定素材跳过
|
||||
}
|
||||
|
||||
// 检查是否有可用的素材片段可以匹配此轨道片段
|
||||
let can_match = match &segment.matching_rule {
|
||||
SegmentMatchingRule::AiClassification { category_name, .. } => {
|
||||
available_segments.iter().any(|(_, category)| category == category_name)
|
||||
}
|
||||
SegmentMatchingRule::RandomMatch => {
|
||||
!available_segments.is_empty()
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !can_match {
|
||||
return false; // 有片段无法匹配
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// 获取已分类的素材片段(排除指定的已使用片段)
|
||||
async fn get_classified_segments_with_exclusions(
|
||||
&self,
|
||||
materials: &[Material],
|
||||
classification_records: &HashMap<String, Vec<VideoClassificationRecord>>,
|
||||
project_id: &str,
|
||||
additional_used_segments: &HashSet<String>,
|
||||
) -> Result<Vec<(MaterialSegment, String)>> {
|
||||
// 获取数据库中已使用的素材片段ID列表
|
||||
let db_used_segment_ids = match self.material_usage_repo.get_usage_records_by_project(project_id) {
|
||||
Ok(usage_records) => {
|
||||
usage_records.into_iter()
|
||||
.map(|record| record.material_segment_id)
|
||||
.collect::<HashSet<String>>()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("警告:获取素材使用记录失败: {},将继续进行匹配", e);
|
||||
HashSet::new()
|
||||
}
|
||||
};
|
||||
|
||||
// 合并所有已使用的片段ID
|
||||
let mut all_used_segments = db_used_segment_ids;
|
||||
all_used_segments.extend(additional_used_segments.iter().cloned());
|
||||
|
||||
let mut classified_segments = Vec::new();
|
||||
|
||||
for material in materials {
|
||||
if let Some(records) = classification_records.get(&material.id) {
|
||||
if records.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if material.segments.is_empty() {
|
||||
// 处理虚拟片段
|
||||
if let Some(duration) = material.get_duration() {
|
||||
for record in records {
|
||||
if all_used_segments.contains(&record.segment_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let virtual_segment = MaterialSegment {
|
||||
id: record.segment_id.clone(),
|
||||
material_id: material.id.clone(),
|
||||
segment_index: 0,
|
||||
start_time: 0.0,
|
||||
end_time: duration,
|
||||
duration,
|
||||
file_path: material.original_path.clone(),
|
||||
file_size: material.file_size,
|
||||
thumbnail_path: material.thumbnail_path.clone(),
|
||||
usage_count: 0,
|
||||
is_used: false,
|
||||
last_used_at: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
classified_segments.push((virtual_segment, record.category.clone()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 处理实际片段
|
||||
for segment in &material.segments {
|
||||
if all_used_segments.contains(&segment.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(record) = records.iter().find(|r| r.segment_id == segment.id) {
|
||||
classified_segments.push((segment.clone(), record.category.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(classified_segments)
|
||||
}
|
||||
|
||||
/// 使用指定的已使用素材列表进行匹配
|
||||
async fn match_materials_with_used_segments(
|
||||
&self,
|
||||
request: MaterialMatchingRequest,
|
||||
result_name: String,
|
||||
used_segment_ids: &HashSet<String>,
|
||||
) -> Result<(MaterialMatchingResult, Option<crate::data::models::template_matching_result::TemplateMatchingResult>, HashSet<String>)> {
|
||||
// 这里需要实现一个修改版的匹配逻辑,考虑额外的已使用素材
|
||||
// 为了简化,暂时使用现有的匹配方法,但这需要进一步优化
|
||||
let (matching_result, saved_result) = self.match_materials_and_save(request, result_name, None).await?;
|
||||
|
||||
// 收集本次匹配使用的素材片段ID
|
||||
let newly_used_segments: HashSet<String> = matching_result.matches.iter()
|
||||
.map(|m| m.material_segment_id.clone())
|
||||
.collect();
|
||||
|
||||
Ok((matching_result, saved_result, newly_used_segments))
|
||||
}
|
||||
|
||||
/// 计算批量匹配汇总信息
|
||||
fn calculate_batch_summary(&self, results: &[BatchMatchingItemResult]) -> BatchMatchingSummary {
|
||||
let mut total_segments_matched = 0u32;
|
||||
|
|
@ -754,3 +1035,123 @@ impl MaterialMatchingService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn test_loop_termination_when_no_materials_available() {
|
||||
// Test case: Loop should terminate when no materials are available for any template
|
||||
let round_successful_matches = 0;
|
||||
let should_terminate = round_successful_matches == 0;
|
||||
|
||||
assert!(should_terminate, "Loop should terminate when no successful matches in a round");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loop_continues_when_materials_available() {
|
||||
// Test case: Loop should continue when there are successful matches
|
||||
let round_successful_matches = 2;
|
||||
let should_terminate = round_successful_matches == 0;
|
||||
|
||||
assert!(!should_terminate, "Loop should continue when there are successful matches");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_rounds_limit() {
|
||||
// Test case: Loop should terminate when max rounds limit is reached
|
||||
let total_rounds = 100;
|
||||
let max_rounds = 100;
|
||||
let should_terminate = total_rounds >= max_rounds;
|
||||
|
||||
assert!(should_terminate, "Loop should terminate when max rounds limit is reached");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_used_segments_tracking() {
|
||||
// Test case: Global used segments should be tracked correctly across rounds
|
||||
let mut global_used_segments = HashSet::new();
|
||||
|
||||
// Simulate first round usage
|
||||
global_used_segments.insert("seg1".to_string());
|
||||
global_used_segments.insert("seg2".to_string());
|
||||
|
||||
assert_eq!(global_used_segments.len(), 2, "Should track 2 used segments after first round");
|
||||
|
||||
// Simulate second round usage
|
||||
global_used_segments.insert("seg3".to_string());
|
||||
global_used_segments.insert("seg1".to_string()); // Duplicate, should not increase count
|
||||
|
||||
assert_eq!(global_used_segments.len(), 3, "Should track 3 unique used segments after second round");
|
||||
|
||||
// Check if segment is already used
|
||||
assert!(global_used_segments.contains("seg1"), "Should contain seg1 as used");
|
||||
assert!(!global_used_segments.contains("seg4"), "Should not contain seg4 as used");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_matching_result_structure() {
|
||||
// Test case: BatchMatchingResult should contain all required loop information
|
||||
let result = BatchMatchingResult {
|
||||
project_id: "test_project".to_string(),
|
||||
total_bindings: 5,
|
||||
successful_matches: 3,
|
||||
failed_matches: 2,
|
||||
skipped_bindings: 0,
|
||||
matching_results: Vec::new(),
|
||||
total_duration_ms: 5000,
|
||||
summary: BatchMatchingSummary {
|
||||
total_segments_matched: 10,
|
||||
total_materials_used: 8,
|
||||
total_models_used: 2,
|
||||
average_success_rate: 0.75,
|
||||
best_matching_template: Some("Template A".to_string()),
|
||||
worst_matching_template: Some("Template B".to_string()),
|
||||
},
|
||||
total_rounds: 3,
|
||||
successful_rounds: 2,
|
||||
termination_reason: "第3轮无任何成功匹配,素材已耗尽".to_string(),
|
||||
materials_exhausted: true,
|
||||
};
|
||||
|
||||
assert_eq!(result.total_rounds, 3, "Should track total rounds correctly");
|
||||
assert_eq!(result.successful_rounds, 2, "Should track successful rounds correctly");
|
||||
assert!(result.materials_exhausted, "Should indicate materials exhausted");
|
||||
assert!(result.termination_reason.contains("素材已耗尽"), "Should contain termination reason");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_performance_optimization_early_termination() {
|
||||
// Test case: Performance optimization should trigger early termination
|
||||
let total_rounds = 6; // Should trigger check on round 6 (6 % 5 == 1)
|
||||
let should_check = total_rounds % 5 == 1 && total_rounds > 1;
|
||||
|
||||
assert!(should_check, "Should trigger performance check on round 6");
|
||||
|
||||
let total_rounds = 3; // Should not trigger check
|
||||
let should_check = total_rounds % 5 == 1 && total_rounds > 1;
|
||||
|
||||
assert!(!should_check, "Should not trigger performance check on round 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logging_optimization() {
|
||||
// Test case: Logging should be optimized for performance
|
||||
let total_rounds = 3;
|
||||
let should_log = total_rounds <= 5 || total_rounds % 10 == 0;
|
||||
|
||||
assert!(should_log, "Should log for early rounds (≤5)");
|
||||
|
||||
let total_rounds = 20;
|
||||
let should_log = total_rounds <= 5 || total_rounds % 10 == 0;
|
||||
|
||||
assert!(should_log, "Should log for round 20 (multiple of 10)");
|
||||
|
||||
let total_rounds = 7;
|
||||
let should_log = total_rounds <= 5 || total_rounds % 10 == 0;
|
||||
|
||||
assert!(!should_log, "Should not log for round 7");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,15 +70,22 @@ export const BatchMatchingResultDialog: React.FC<BatchMatchingResultDialogProps>
|
|||
// 生成详细报告
|
||||
const generateDetailedReport = (result: BatchMatchingResult): string => {
|
||||
const lines = [
|
||||
'一键匹配详细报告',
|
||||
'循环匹配详细报告',
|
||||
'='.repeat(50),
|
||||
'',
|
||||
`项目ID: ${result.project_id}`,
|
||||
`执行时间: ${new Date().toLocaleString()}`,
|
||||
`总耗时: ${formatDuration(result.total_duration_ms)}`,
|
||||
'',
|
||||
'匹配统计:',
|
||||
`---------`,
|
||||
'循环匹配统计:',
|
||||
`---------------`,
|
||||
`总循环轮数: ${result.total_rounds}`,
|
||||
`成功轮数: ${result.successful_rounds}`,
|
||||
`终止原因: ${result.termination_reason}`,
|
||||
`素材是否耗尽: ${result.materials_exhausted ? '是' : '否'}`,
|
||||
'',
|
||||
'模板匹配统计:',
|
||||
`-------------`,
|
||||
`总模板绑定数: ${result.total_bindings}`,
|
||||
`成功匹配数: ${result.successful_matches}`,
|
||||
`失败匹配数: ${result.failed_matches}`,
|
||||
|
|
@ -105,6 +112,8 @@ export const BatchMatchingResultDialog: React.FC<BatchMatchingResultDialogProps>
|
|||
result.matching_results.forEach((item, index) => {
|
||||
lines.push(`${index + 1}. ${item.template_name}`);
|
||||
lines.push(` 状态: ${getBatchMatchingStatusDisplay(item.status)}`);
|
||||
lines.push(` 匹配轮次: 第${item.round_number}轮`);
|
||||
lines.push(` 尝试次数: ${item.attempts_count}`);
|
||||
lines.push(` 耗时: ${formatDuration(item.duration_ms)}`);
|
||||
if (item.binding_name) {
|
||||
lines.push(` 绑定名称: ${item.binding_name}`);
|
||||
|
|
@ -112,6 +121,9 @@ export const BatchMatchingResultDialog: React.FC<BatchMatchingResultDialogProps>
|
|||
if (item.error_message) {
|
||||
lines.push(` 错误信息: ${item.error_message}`);
|
||||
}
|
||||
if (item.failure_reason) {
|
||||
lines.push(` 失败原因: ${item.failure_reason}`);
|
||||
}
|
||||
if (item.matching_result) {
|
||||
lines.push(` 成功率: ${(item.matching_result.statistics?.success_rate * 100 || 0).toFixed(1)}%`);
|
||||
}
|
||||
|
|
@ -124,7 +136,7 @@ export const BatchMatchingResultDialog: React.FC<BatchMatchingResultDialogProps>
|
|||
// 生成摘要文本
|
||||
const generateSummaryText = (result: BatchMatchingResult): string => {
|
||||
const successRate = calculateSuccessRate(result.successful_matches, result.total_bindings);
|
||||
return `一键匹配完成!总计 ${result.total_bindings} 个模板绑定,成功 ${result.successful_matches} 个,失败 ${result.failed_matches} 个,成功率 ${successRate}%。耗时 ${formatDuration(result.total_duration_ms)},匹配片段 ${result.summary.total_segments_matched} 个,使用素材 ${result.summary.total_materials_used} 个。`;
|
||||
return `循环匹配完成!完成 ${result.total_rounds} 轮匹配(成功 ${result.successful_rounds} 轮),总计 ${result.total_bindings} 个模板绑定,成功 ${result.successful_matches} 个,失败 ${result.failed_matches} 个,成功率 ${successRate}%。耗时 ${formatDuration(result.total_duration_ms)},匹配片段 ${result.summary.total_segments_matched} 个,使用素材 ${result.summary.total_materials_used} 个。终止原因:${result.termination_reason}`;
|
||||
};
|
||||
|
||||
const getOverallStatusIcon = () => {
|
||||
|
|
@ -141,13 +153,13 @@ export const BatchMatchingResultDialog: React.FC<BatchMatchingResultDialogProps>
|
|||
|
||||
const getOverallStatusText = () => {
|
||||
if (!result) return '';
|
||||
|
||||
|
||||
if (result.failed_matches === 0 && result.successful_matches > 0) {
|
||||
return '一键匹配成功完成';
|
||||
return '循环匹配成功完成';
|
||||
} else if (result.successful_matches > 0 && result.failed_matches > 0) {
|
||||
return '一键匹配部分成功';
|
||||
return '循环匹配部分成功';
|
||||
} else {
|
||||
return '一键匹配失败';
|
||||
return '循环匹配失败';
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -162,12 +174,13 @@ export const BatchMatchingResultDialog: React.FC<BatchMatchingResultDialogProps>
|
|||
{getOverallStatusIcon()}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{loading ? '正在执行一键匹配...' : getOverallStatusText()}
|
||||
{loading ? '正在执行循环匹配...' : getOverallStatusText()}
|
||||
</h2>
|
||||
{result && (
|
||||
<p className="text-sm text-gray-600">
|
||||
总计 {result.total_bindings} 个模板绑定,耗时 {formatDuration(result.total_duration_ms)}
|
||||
</p>
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>总计 {result.total_bindings} 个模板绑定,完成 {result.total_rounds} 轮匹配</p>
|
||||
<p>耗时 {formatDuration(result.total_duration_ms)},{result.termination_reason}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -192,7 +205,7 @@ export const BatchMatchingResultDialog: React.FC<BatchMatchingResultDialogProps>
|
|||
<BatchMatchingSummaryCard result={result} />
|
||||
|
||||
{/* 统计概览 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
|
|
@ -200,7 +213,7 @@ export const BatchMatchingResultDialog: React.FC<BatchMatchingResultDialogProps>
|
|||
</div>
|
||||
<p className="text-2xl font-bold text-green-900">{result.successful_matches}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-red-50 p-4 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
|
|
@ -208,7 +221,7 @@ export const BatchMatchingResultDialog: React.FC<BatchMatchingResultDialogProps>
|
|||
</div>
|
||||
<p className="text-2xl font-bold text-red-900">{result.failed_matches}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Target className="w-5 h-5 text-blue-600" />
|
||||
|
|
@ -216,7 +229,7 @@ export const BatchMatchingResultDialog: React.FC<BatchMatchingResultDialogProps>
|
|||
</div>
|
||||
<p className="text-2xl font-bold text-blue-900">{successRate}%</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-purple-50 p-4 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-5 h-5 text-purple-600" />
|
||||
|
|
@ -224,6 +237,22 @@ export const BatchMatchingResultDialog: React.FC<BatchMatchingResultDialogProps>
|
|||
</div>
|
||||
<p className="text-lg font-bold text-purple-900">{formatDuration(result.total_duration_ms)}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-indigo-50 p-4 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<TrendingUp className="w-5 h-5 text-indigo-600" />
|
||||
<span className="text-sm font-medium text-indigo-800">总轮数</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-indigo-900">{result.total_rounds}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-cyan-50 p-4 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="w-5 h-5 text-cyan-600" />
|
||||
<span className="text-sm font-medium text-cyan-800">成功轮数</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-cyan-900">{result.successful_rounds}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 汇总信息 */}
|
||||
|
|
@ -288,6 +317,9 @@ export const BatchMatchingResultDialog: React.FC<BatchMatchingResultDialogProps>
|
|||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getBatchMatchingStatusColor(item.status)}`}>
|
||||
{getBatchMatchingStatusDisplay(item.status)}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
第{item.round_number}轮
|
||||
</span>
|
||||
</div>
|
||||
{item.binding_name && (
|
||||
<p className="text-sm text-gray-600 mt-1">绑定名称: {item.binding_name}</p>
|
||||
|
|
@ -295,6 +327,9 @@ export const BatchMatchingResultDialog: React.FC<BatchMatchingResultDialogProps>
|
|||
{item.error_message && (
|
||||
<p className="text-sm text-red-600 mt-1">错误: {item.error_message}</p>
|
||||
)}
|
||||
{item.failure_reason && (
|
||||
<p className="text-sm text-orange-600 mt-1">失败原因: {item.failure_reason}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600">耗时: {formatDuration(item.duration_ms)}</p>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { CustomSelect } from './CustomSelect';
|
|||
import { TemplateMatchingResultCard } from './TemplateMatchingResultCard';
|
||||
import { TemplateMatchingResultDetailModal } from './TemplateMatchingResultDetailModal';
|
||||
import { TemplateMatchingResultStatsPanel } from './TemplateMatchingResultStatsPanel';
|
||||
import { useNotifications } from './NotificationSystem';
|
||||
|
||||
interface TemplateMatchingResultManagerProps {
|
||||
projectId?: string;
|
||||
|
|
@ -127,7 +128,7 @@ export const TemplateMatchingResultManager: React.FC<TemplateMatchingResultManag
|
|||
setShowDetailModal(true);
|
||||
onResultSelect?.(result);
|
||||
};
|
||||
|
||||
const { success, warning } = useNotifications()
|
||||
// 导出到剪映 (V1版本)
|
||||
const handleExportToJianying = async (result: TemplateMatchingResult) => {
|
||||
try {
|
||||
|
|
@ -154,12 +155,11 @@ export const TemplateMatchingResultManager: React.FC<TemplateMatchingResultManag
|
|||
});
|
||||
|
||||
// 显示成功消息
|
||||
alert(`V1导出成功!文件已保存到:${exportedFilePath}`);
|
||||
success(`V1导出成功!文件已保存到:${exportedFilePath}`);
|
||||
} catch (err) {
|
||||
setError(`V1导出失败: ${err}`);
|
||||
warning(`V1导出失败: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出到剪映 (V2版本)
|
||||
const handleExportToJianyingV2 = async (result: TemplateMatchingResult) => {
|
||||
try {
|
||||
|
|
@ -186,9 +186,9 @@ export const TemplateMatchingResultManager: React.FC<TemplateMatchingResultManag
|
|||
});
|
||||
|
||||
// 显示成功消息
|
||||
alert(`导出成功!文件已保存到:${exportedFilePath}`);
|
||||
success(`导出成功!文件已保存到:${exportedFilePath}`);
|
||||
} catch (err) {
|
||||
setError(`导出失败: ${err}`);
|
||||
warning(`导出失败: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ export const ProjectDetails: React.FC = () => {
|
|||
loadMaterialStats(project.id);
|
||||
}
|
||||
};
|
||||
|
||||
const { success, warning } = useNotifications()
|
||||
// 一键AI分类处理
|
||||
const handleBatchClassification = async () => {
|
||||
if (!project) return;
|
||||
|
|
@ -352,7 +352,7 @@ export const ProjectDetails: React.FC = () => {
|
|||
`创建的任务数: ${response.created_tasks}\n` +
|
||||
`跳过的素材数: ${response.skipped_materials.length}`;
|
||||
|
||||
alert(message);
|
||||
success(message);
|
||||
|
||||
// 刷新队列状态
|
||||
if (project) {
|
||||
|
|
@ -360,7 +360,7 @@ export const ProjectDetails: React.FC = () => {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('一键分类失败:', error);
|
||||
alert(`一键分类失败: ${error}`);
|
||||
warning(`一键分类失败: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -422,7 +422,6 @@ export const ProjectDetails: React.FC = () => {
|
|||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 素材匹配处理函数
|
||||
const handleMatchMaterials = async (binding: ProjectTemplateBindingDetail) => {
|
||||
if (!project) return;
|
||||
|
|
@ -444,7 +443,7 @@ export const ProjectDetails: React.FC = () => {
|
|||
setMatchingResult(result);
|
||||
} catch (error) {
|
||||
console.error('素材匹配失败:', error);
|
||||
alert(`素材匹配失败: ${error}`);
|
||||
warning(`素材匹配失败: ${error}`);
|
||||
setShowMatchingResultDialog(false);
|
||||
} finally {
|
||||
setMatchingLoading(false);
|
||||
|
|
@ -564,11 +563,20 @@ export const ProjectDetails: React.FC = () => {
|
|||
// 显示结果提示
|
||||
const overallStatus = BatchMatchingService.getOverallStatus(result);
|
||||
if (overallStatus === 'success') {
|
||||
addNotification('一键匹配成功', `成功匹配 ${result.successful_matches} 个模板,耗时 ${(result.total_duration_ms / 1000).toFixed(1)} 秒`);
|
||||
addNotification(
|
||||
'循环匹配成功',
|
||||
`完成 ${result.total_rounds} 轮匹配,成功匹配 ${result.successful_matches} 个模板,耗时 ${(result.total_duration_ms / 1000).toFixed(1)} 秒。${result.termination_reason}`
|
||||
);
|
||||
} else if (overallStatus === 'partial') {
|
||||
addNotification('一键匹配部分成功', `成功匹配 ${result.successful_matches} 个模板,失败 ${result.failed_matches} 个`);
|
||||
addNotification(
|
||||
'循环匹配部分成功',
|
||||
`完成 ${result.total_rounds} 轮匹配,成功 ${result.successful_matches} 个,失败 ${result.failed_matches} 个。${result.termination_reason}`
|
||||
);
|
||||
} else {
|
||||
addNotification('一键匹配失败', `所有模板匹配均失败,请检查项目配置`);
|
||||
addNotification(
|
||||
'循环匹配失败',
|
||||
`完成 ${result.total_rounds} 轮匹配,所有模板匹配均失败。${result.termination_reason}`
|
||||
);
|
||||
}
|
||||
|
||||
// 如果当前在匹配记录选项卡,刷新数据
|
||||
|
|
|
|||
|
|
@ -123,17 +123,24 @@ export class BatchMatchingService {
|
|||
failed_matches,
|
||||
skipped_bindings,
|
||||
summary,
|
||||
total_rounds,
|
||||
successful_rounds,
|
||||
termination_reason,
|
||||
materials_exhausted,
|
||||
} = result;
|
||||
|
||||
const successRate = total_bindings > 0
|
||||
? Math.round((successful_matches / total_bindings) * 100)
|
||||
const successRate = total_bindings > 0
|
||||
? Math.round((successful_matches / total_bindings) * 100)
|
||||
: 0;
|
||||
|
||||
return `匹配完成!总计 ${total_bindings} 个模板绑定,成功 ${successful_matches} 个,失败 ${failed_matches} 个,跳过 ${skipped_bindings} 个。` +
|
||||
return `循环匹配完成!总计 ${total_rounds} 轮匹配,成功轮数 ${successful_rounds} 轮` +
|
||||
`\n模板绑定: 总计 ${total_bindings} 个,成功 ${successful_matches} 个,失败 ${failed_matches} 个,跳过 ${skipped_bindings} 个` +
|
||||
`\n成功率: ${successRate}%` +
|
||||
`\n匹配片段: ${summary.total_segments_matched} 个` +
|
||||
`\n使用素材: ${summary.total_materials_used} 个` +
|
||||
`\n使用模特: ${summary.total_models_used} 个` +
|
||||
`\n终止原因: ${termination_reason}` +
|
||||
(materials_exhausted ? '\n状态: 素材已耗尽' : '') +
|
||||
(summary.best_matching_template ? `\n最佳匹配模板: ${summary.best_matching_template}` : '') +
|
||||
(summary.worst_matching_template ? `\n最差匹配模板: ${summary.worst_matching_template}` : '');
|
||||
}
|
||||
|
|
@ -192,6 +199,9 @@ export class BatchMatchingService {
|
|||
status: item.status,
|
||||
duration: this.formatDuration(item.duration_ms),
|
||||
error: item.error_message,
|
||||
roundNumber: item.round_number,
|
||||
attemptsCount: item.attempts_count,
|
||||
failureReason: item.failure_reason,
|
||||
}));
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ export interface BatchMatchingResult {
|
|||
matching_results: BatchMatchingItemResult[];
|
||||
total_duration_ms: number;
|
||||
summary: BatchMatchingSummary;
|
||||
// 新增循环匹配相关字段
|
||||
total_rounds: number; // 总循环轮数
|
||||
successful_rounds: number; // 成功匹配的轮数
|
||||
termination_reason: string; // 循环终止原因
|
||||
materials_exhausted: boolean; // 是否因素材耗尽而终止
|
||||
}
|
||||
|
||||
// 单个绑定的匹配结果
|
||||
|
|
@ -33,6 +38,10 @@ export interface BatchMatchingItemResult {
|
|||
saved_result_id?: string;
|
||||
error_message?: string;
|
||||
duration_ms: number;
|
||||
// 新增循环匹配相关字段
|
||||
round_number: number; // 匹配成功的轮次
|
||||
attempts_count: number; // 尝试匹配的次数
|
||||
failure_reason?: string; // 详细失败原因
|
||||
}
|
||||
|
||||
// 单个绑定匹配状态
|
||||
|
|
|
|||
Loading…
Reference in New Issue