mixvideo-v2/apps/desktop/src/components/template/SegmentMatchingRuleEditor.tsx

428 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import { PencilIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { SegmentMatchingRule, SegmentMatchingRuleHelper } from '../../types/template';
import { AiClassification } from '../../types/aiClassification';
import { useTemplateStore } from '../../stores/templateStore';
import { CustomSelect } from '../CustomSelect';
import { AiClassificationService } from '../../services/aiClassificationService';
import { TemplateSegmentWeightService } from '../../services/templateSegmentWeightService';
interface SegmentMatchingRuleEditorProps {
segmentId: string;
currentRule: SegmentMatchingRule;
templateId?: string; // 添加模板ID用于权重配置
onRuleUpdated?: (newRule: SegmentMatchingRule) => void;
}
/**
* 片段匹配规则编辑器组件
* 遵循前端开发规范的组件设计支持固定素材和AI分类规则的设置
*/
export const SegmentMatchingRuleEditor: React.FC<SegmentMatchingRuleEditorProps> = ({
segmentId,
currentRule,
templateId,
onRuleUpdated,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editingRule, setEditingRule] = useState<SegmentMatchingRule>(currentRule);
const [aiClassifications, setAiClassifications] = useState<AiClassification[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 权重配置相关状态
const [editingWeights, setEditingWeights] = useState<Record<string, number>>({});
const { updateSegmentMatchingRule } = useTemplateStore();
// 加载AI分类列表
useEffect(() => {
const loadClassifications = async () => {
try {
const classifications = await AiClassificationService.getActiveClassifications();
setAiClassifications(classifications);
} catch (error) {
console.error('加载AI分类失败:', error);
}
};
if (isEditing) {
loadClassifications();
}
}, [isEditing]);
// 加载权重数据
useEffect(() => {
const loadWeightData = async () => {
if (!templateId) return;
try {
if (SegmentMatchingRuleHelper.isPriorityOrder(editingRule)) {
const selectedCategoryIds = typeof editingRule === 'object' && 'PriorityOrder' in editingRule
? editingRule.PriorityOrder.category_ids
: [];
if (selectedCategoryIds.length > 0) {
// 只加载选中分类的权重
const weights = await TemplateSegmentWeightService.getSegmentWeightsForCategories(
templateId,
segmentId,
selectedCategoryIds
);
setEditingWeights({ ...weights });
} else {
setEditingWeights({});
}
}
} catch (error) {
console.error('Failed to load weight data:', error);
}
};
if (isEditing && templateId && SegmentMatchingRuleHelper.isPriorityOrder(editingRule)) {
loadWeightData();
}
}, [isEditing, templateId, segmentId, editingRule]);
// 重置编辑状态
useEffect(() => {
setEditingRule(currentRule);
setError(null);
}, [currentRule, isEditing]);
const handleStartEdit = () => {
setIsEditing(true);
setEditingRule(currentRule);
setError(null);
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditingRule(currentRule);
setError(null);
};
const handleSaveRule = async () => {
try {
setLoading(true);
setError(null);
// 保存匹配规则
await updateSegmentMatchingRule(segmentId, editingRule);
// 如果是按顺序匹配规则且有模板ID同时保存权重配置
if (SegmentMatchingRuleHelper.isPriorityOrder(editingRule) && templateId) {
try {
// 获取当前选中的分类ID
const selectedCategoryIds = typeof editingRule === 'object' && 'PriorityOrder' in editingRule
? editingRule.PriorityOrder.category_ids
: [];
// 只保存选中分类的权重,过滤掉未选中的分类
const selectedWeights = Object.fromEntries(
Object.entries(editingWeights).filter(([classificationId]) =>
selectedCategoryIds.includes(classificationId)
)
);
await TemplateSegmentWeightService.setSegmentWeights(
templateId,
segmentId,
selectedWeights
);
} catch (weightError) {
console.error('保存权重配置失败:', weightError);
// 权重保存失败不阻止规则保存
}
}
setIsEditing(false);
onRuleUpdated?.(editingRule);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '保存匹配规则失败';
setError(errorMessage);
} finally {
setLoading(false);
}
};
const handleRuleTypeChange = (ruleType: string) => {
if (ruleType === 'fixed') {
setEditingRule(SegmentMatchingRuleHelper.createFixedMaterial());
} else if (ruleType === 'ai_classification') {
// 如果有可用的AI分类选择第一个作为默认值
if (aiClassifications.length > 0) {
const firstClassification = aiClassifications[0];
setEditingRule(SegmentMatchingRuleHelper.createAiClassification(
firstClassification.id,
firstClassification.name
));
}
} else if (ruleType === 'random') {
setEditingRule(SegmentMatchingRuleHelper.createRandomMatch());
} else if (ruleType === 'priority_order') {
// 默认选择所有激活的AI分类
const categoryIds = aiClassifications.map(c => c.id);
setEditingRule(SegmentMatchingRuleHelper.createPriorityOrder(categoryIds));
}
};
const handleAiClassificationChange = (classificationId: string) => {
const classification = aiClassifications.find(c => c.id === classificationId);
if (classification) {
setEditingRule(SegmentMatchingRuleHelper.createAiClassification(
classification.id,
classification.name
));
}
};
const handlePriorityOrderChange = (categoryId: string, isSelected: boolean) => {
if (typeof editingRule === 'object' && 'PriorityOrder' in editingRule) {
const currentCategoryIds = editingRule.PriorityOrder.category_ids;
let newCategoryIds: string[];
if (isSelected) {
// 添加分类ID
newCategoryIds = [...currentCategoryIds, categoryId];
// 为新选中的分类设置默认权重(如果还没有的话)
if (!editingWeights[categoryId]) {
const classification = aiClassifications.find(c => c.id === categoryId);
setEditingWeights(prev => ({
...prev,
[categoryId]: classification?.weight || 50
}));
}
} else {
// 移除分类ID
newCategoryIds = currentCategoryIds.filter(id => id !== categoryId);
// 从权重配置中移除未选中的分类
setEditingWeights(prev => {
const newWeights = { ...prev };
delete newWeights[categoryId];
return newWeights;
});
}
setEditingRule(SegmentMatchingRuleHelper.createPriorityOrder(newCategoryIds));
}
};
// 权重编辑处理函数
const handleWeightChange = (classificationId: string, weight: number) => {
setEditingWeights(prev => ({
...prev,
[classificationId]: weight,
}));
};
const validateWeight = (weight: number): boolean => {
return weight >= 0 && weight <= 100;
};
const getCurrentRuleType = (rule: SegmentMatchingRule): string => {
if (SegmentMatchingRuleHelper.isFixedMaterial(rule)) {
return 'fixed';
} else if (SegmentMatchingRuleHelper.isAiClassification(rule)) {
return 'ai_classification';
} else if (SegmentMatchingRuleHelper.isRandomMatch(rule)) {
return 'random';
} else if (SegmentMatchingRuleHelper.isPriorityOrder(rule)) {
return 'priority_order';
}
return 'fixed'; // 默认值
};
const ruleTypeOptions = [
{ value: 'fixed', label: '固定素材' },
{ value: 'ai_classification', label: 'AI分类素材' },
{ value: 'random', label: '随机匹配' },
{ value: 'priority_order', label: '按顺序匹配' },
];
const classificationOptions = aiClassifications.map(classification => ({
value: classification.id,
label: classification.name,
}));
if (!isEditing) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<span className="text-xs font-medium text-gray-700">:</span>
<span className={`px-2 py-1 rounded text-xs ${
SegmentMatchingRuleHelper.isFixedMaterial(currentRule)
? 'bg-gray-100 text-gray-800'
: SegmentMatchingRuleHelper.isAiClassification(currentRule)
? 'bg-blue-100 text-blue-800'
: SegmentMatchingRuleHelper.isRandomMatch(currentRule)
? 'bg-green-100 text-green-800'
: SegmentMatchingRuleHelper.isPriorityOrder(currentRule)
? 'bg-purple-100 text-purple-800'
: 'bg-gray-100 text-gray-800'
}`}>
{SegmentMatchingRuleHelper.getDisplayName(currentRule)}
</span>
</div>
<button
onClick={handleStartEdit}
className="inline-flex items-center px-2 py-1 bg-blue-50 text-blue-600 text-xs font-medium rounded-md hover:bg-blue-100 hover:text-blue-700 transition-all duration-200 border border-blue-200"
title="编辑匹配规则"
>
<PencilIcon className="w-3 h-3 mr-1" />
</button>
</div>
);
}
return (
<div className="space-y-3 bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-blue-800">:</span>
<div className="flex items-center space-x-2">
<button
onClick={handleSaveRule}
disabled={loading}
className="inline-flex items-center px-3 py-1.5 bg-green-600 text-white text-xs font-medium rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow-md"
title="保存匹配规则"
>
<CheckIcon className="w-3 h-3 mr-1" />
{loading ? '保存中...' : '保存'}
</button>
<button
onClick={handleCancelEdit}
disabled={loading}
className="inline-flex items-center px-3 py-1.5 bg-gray-100 text-gray-700 text-xs font-medium rounded-md hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
title="取消编辑"
>
<XMarkIcon className="w-3 h-3 mr-1" />
</button>
</div>
</div>
<div className="space-y-2">
<div>
<label className="block text-xs font-medium text-blue-800 mb-1">
</label>
<CustomSelect
value={getCurrentRuleType(editingRule)}
onChange={handleRuleTypeChange}
options={ruleTypeOptions}
placeholder="选择规则类型"
className="text-xs bg-white border-blue-200"
/>
</div>
{SegmentMatchingRuleHelper.isAiClassification(editingRule) && (
<div>
<label className="block text-xs font-medium text-blue-800 mb-1">
AI分类
</label>
<CustomSelect
value={SegmentMatchingRuleHelper.getAiClassificationInfo(editingRule)?.category_id || ''}
onChange={handleAiClassificationChange}
options={classificationOptions}
placeholder="选择AI分类"
className="text-xs bg-white border-blue-200"
/>
</div>
)}
{SegmentMatchingRuleHelper.isPriorityOrder(editingRule) && (
<div>
<label className="block text-xs font-medium text-blue-800 mb-1">
</label>
<div className="text-xs text-gray-600 mb-2">
AI分类的权重顺序依次尝试匹配素材
</div>
<div className="space-y-1 max-h-32 overflow-y-auto border border-blue-200 rounded-md p-2 bg-white">
{aiClassifications
.sort((a, b) => (b.weight || 0) - (a.weight || 0)) // 按权重降序排列
.map((classification) => {
const currentCategoryIds = typeof editingRule === 'object' && 'PriorityOrder' in editingRule
? editingRule.PriorityOrder.category_ids
: [];
const isSelected = currentCategoryIds.includes(classification.id);
return (
<div key={classification.id} className="flex items-center space-x-2 hover:bg-blue-50 p-1 rounded">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => handlePriorityOrderChange(classification.id, e.target.checked)}
className="w-3 h-3 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-xs text-gray-700 flex-1">
{classification.name}
</span>
{/* 权重编辑区域 */}
<div className="flex items-center space-x-1">
{isSelected ? (
// 选中时显示可编辑的输入框
<div className="flex items-center space-x-1">
<span className="text-xs text-gray-500">:</span>
<input
type="number"
min="0"
max="100"
value={editingWeights[classification.id] || classification.weight || 0}
onChange={(e) => {
const value = parseInt(e.target.value) || 0;
if (validateWeight(value)) {
handleWeightChange(classification.id, value);
}
}}
className="w-12 text-xs text-center border border-gray-300 rounded px-1 py-0.5 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
onClick={(e) => e.stopPropagation()}
/>
</div>
) : (
// 未选中时显示只读的权重值
<span className="text-xs text-gray-500 bg-gray-100 px-1 rounded">
: {classification.weight || 0}
</span>
)}
</div>
</div>
);
})}
</div>
{typeof editingRule === 'object' && 'PriorityOrder' in editingRule && editingRule.PriorityOrder.category_ids.length === 0 && (
<div className="text-xs text-amber-700 bg-amber-100 border border-amber-200 p-2 rounded-md mt-2">
AI分类
</div>
)}
{/* 权重配置说明 */}
{templateId && typeof editingRule === 'object' && 'PriorityOrder' in editingRule && editingRule.PriorityOrder.category_ids.length > 0 && (
<div className="mt-3 text-xs text-gray-500 bg-blue-50 border border-blue-200 p-2 rounded-md">
💡 0-100
</div>
)}
</div>
)}
</div>
{error && (
<div className="text-xs text-red-700 bg-red-100 border border-red-200 p-2 rounded-md">
{error}
</div>
)}
{loading && (
<div className="text-xs text-blue-700 bg-white border border-blue-300 p-2 rounded-md flex items-center">
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-600 mr-2"></div>
...
</div>
)}
</div>
);
};