428 lines
16 KiB
TypeScript
428 lines
16 KiB
TypeScript
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>
|
||
);
|
||
};
|