Files
mixvideo-v2/apps/desktop/src/components/TemplateMatchingResultCard.tsx
imeepos 483d63caaa fix: 修复一键匹配失败入库和成功率超过100%的问题
## 修复内容

### 1. 修复匹配失败时仍然入库的问题
- 在match_materials_with_used_segments方法中添加匹配成功判断
- 只有当所有需要匹配的片段都成功匹配时才保存到数据库
- 匹配失败时不记录资源使用,确保资源可以被后续匹配使用
- 修改match_materials_and_save方法,确保一致的失败处理逻辑

### 2. 修复匹配失败时的资源释放
- 部分匹配失败时,已分配的资源不会被标记为已使用
- 在批量匹配中正确处理部分匹配失败的情况
- 失败的匹配不会影响全局资源使用状态

### 3. 修复成功率计算超过100%的问题
- 统一所有地方的成功率计算逻辑,确保基于可匹配片段计算
- 在前端显示时添加Math.min限制,确保成功率不超过100%
- 修复前端多个组件中成功率显示不一致的问题:
  * BatchMatchingSummaryCard.tsx
  * BatchMatchingResultDialog.tsx
  * TemplateMatchingResultCard.tsx
  * TemplateMatchingResultDetailModal.tsx
  * TemplateMatchingResultStatsPanel.tsx
  * materialMatchingService.ts

### 4. 改进批量匹配逻辑
- 区分完全匹配失败和部分匹配失败
- 部分匹配失败时提供详细的失败原因
- 保持匹配结果用于分析,但不保存到数据库

## 技术细节
- 后端成功率统一为0-1的小数格式
- 前端显示时统一乘以100并限制最大值为100
- 确保匹配失败时的事务一致性
- 添加详细的日志输出便于调试
2025-07-18 13:16:45 +08:00

247 lines
8.9 KiB
TypeScript

import React from 'react';
import { TemplateMatchingResult, MatchingResultStatus } from '../types/templateMatchingResult';
interface TemplateMatchingResultCardProps {
result: TemplateMatchingResult;
onViewDetail: () => void;
onDelete: () => void;
onEdit?: () => void;
onExportToJianying?: () => void;
onExportToJianyingV2?: () => void;
isSelected?: boolean;
onToggleSelect?: () => void;
showExportStatus?: boolean;
disabled?: boolean;
}
export const TemplateMatchingResultCard: React.FC<TemplateMatchingResultCardProps> = ({
result,
onViewDetail,
onDelete,
onEdit,
onExportToJianyingV2,
isSelected = false,
onToggleSelect,
showExportStatus = false,
disabled = false,
}) => {
// 格式化时长显示
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={`card card-interactive group ${isSelected ? 'ring-2 ring-blue-500 bg-blue-50' : ''} ${disabled ? 'opacity-75' : ''}`}>
{/* 卡片头部 */}
<div className="card-header">
<div className="flex items-start justify-between">
{/* 选择框 */}
{onToggleSelect && (
<div className="flex items-center mr-3">
<input
type="checkbox"
checked={isSelected}
onChange={onToggleSelect}
disabled={disabled}
className={`form-checkbox h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500 ${
disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<h3 className="text-lg font-semibold text-gray-900 truncate group-hover:text-primary-600 transition-colors">
{result.result_name}
</h3>
{/* 导出状态标识 */}
{showExportStatus && (
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
result.is_exported
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{result.is_exported ? '✓ 已导出' : '○ 未导出'}
</span>
)}
</div>
{result.description && (
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
{result.description}
</p>
)}
</div>
<div className={`ml-3 px-3 py-1 rounded-full text-xs font-medium ${getStatusStyle(result.status)}`}>
{getStatusText(result.status)}
</div>
</div>
</div>
{/* 统计信息 */}
<div className="card-body">
<div className="grid grid-cols-2 gap-6 mb-6">
{/* 成功率 */}
<div className="text-center p-3 bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl">
<div className={`text-2xl font-bold ${getSuccessRateColor(Math.min(result.success_rate * 100, 100))}`}>
{Math.min(result.success_rate * 100, 100).toFixed(1)}%
</div>
<div className="text-xs text-gray-600 font-medium mt-1"></div>
</div>
{/* 片段统计 */}
<div className="text-center p-3 bg-gradient-to-br from-primary-50 to-primary-100 rounded-xl">
<div className="text-2xl font-bold text-primary-700">
{result.matched_segments}/{result.total_segments}
</div>
<div className="text-xs text-primary-600 font-medium mt-1"></div>
</div>
</div>
{/* 详细统计 */}
<div className="space-y-3 text-sm">
<div className="flex justify-between items-center py-1">
<span className="text-gray-600"></span>
<span className="font-medium text-gray-900">{result.failed_segments}</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-gray-600">使</span>
<span className="font-medium text-gray-900">{result.used_materials}</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-gray-600">使</span>
<span className="font-medium text-gray-900">{result.used_models}</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-gray-600"></span>
<span className="font-medium text-gray-900">{formatDuration(result.matching_duration_ms)}</span>
</div>
</div>
{/* 质量评分 */}
{result.quality_score && (
<div className="mt-4 pt-4 border-t border-gray-100">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600 font-medium"></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 font-semibold text-gray-900">
{result.quality_score.toFixed(1)}
</span>
</div>
</div>
</div>
)}
</div>
{/* 时间信息 */}
<div className="card-footer">
<div className="flex justify-between text-xs text-gray-500">
<span>: {formatDate(result.created_at)}</span>
<span>: {formatDate(result.updated_at)}</span>
</div>
</div>
{/* 操作按钮 */}
<div className="px-6 py-4 border-t border-gray-100 flex justify-end space-x-3">
<button
onClick={onViewDetail}
className="btn btn-primary btn-sm"
>
</button>
{onEdit && (
<button
onClick={onEdit}
className="btn btn-secondary btn-sm"
>
</button>
)}
{onExportToJianyingV2 && (
<button
onClick={onExportToJianyingV2}
className="btn btn-primary btn-sm"
title="导出到剪映 (V2版本 - 基于原始模板)"
>
</button>
)}
<button
onClick={onDelete}
className="btn btn-danger btn-sm"
>
</button>
</div>
</div>
);
};