Files
mixvideo-v2/apps/desktop/src/components/template/TemplateDetailModal.tsx
imeepos 05d29832b0 feat: 实现模板片段匹配规则功能并修复数据库迁移问题
新功能:
- 为TrackSegment添加匹配规则字段,支持固定素材和AI分类两种规则
- 实现SegmentMatchingRuleEditor组件,支持在模板详情页面编辑片段匹配规则
- 添加update_segment_matching_rule和get_segment_matching_rule API接口
- 扩展前端类型定义和服务函数以支持匹配规则操作

 修复:
- 修复数据库迁移逻辑导致每次重启清空素材和轨道数据的问题
- 为模板表和轨道片段表迁移添加条件检查,只在必要时执行
- 修正matching_rule字段的默认值格式,匹配Rust枚举序列化格式
- 完善轨道片段表重建时的字段迁移逻辑

 技术改进:
- 数据库schema更新,添加matching_rule列到track_segments表
- 优化数据库迁移性能,避免不必要的表重建操作
- 增强错误处理和日志输出,便于问题排查

 文件变更:
- 后端: template_service.rs, template.rs, database.rs, template_commands.rs, lib.rs
- 前端: SegmentMatchingRuleEditor.tsx, TemplateDetailModal.tsx, templateStore.ts, template.ts
2025-07-15 09:43:04 +08:00

589 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 } from 'react';
import { X, Calendar, Clock, Monitor, Layers, FileText, Image, Video, Music, Type, Sparkles, CheckCircle, XCircle, AlertCircle, Upload, Cloud } from 'lucide-react';
import { Template, TemplateMaterial, TemplateMaterialType, TrackType } from '../../types/template';
import { SegmentMatchingRuleEditor } from './SegmentMatchingRuleEditor';
interface TemplateDetailModalProps {
template: Template;
onClose: () => void;
}
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
template,
onClose,
}) => {
const [activeTab, setActiveTab] = useState<'overview' | 'materials' | 'tracks'>('overview');
// 格式化时长
const formatDuration = (microseconds: number) => {
const seconds = Math.floor(microseconds / 1000000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
// 格式化时长(精确到毫秒)
const formatDurationWithMs = (microseconds: number) => {
const totalMs = Math.floor(microseconds / 1000);
const seconds = Math.floor(totalMs / 1000);
const ms = totalMs % 1000;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
};
// 格式化文件大小
const formatFileSize = (bytes?: number) => {
if (!bytes) return '未知';
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
};
// 格式化日期
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('zh-CN');
};
// 获取素材类型图标
const getMaterialIcon = (type: TemplateMaterialType) => {
switch (type) {
case TemplateMaterialType.Video:
return <Video className="w-4 h-4 text-blue-600" />;
case TemplateMaterialType.Audio:
return <Music className="w-4 h-4 text-green-600" />;
case TemplateMaterialType.Image:
return <Image className="w-4 h-4 text-purple-600" />;
case TemplateMaterialType.Text:
return <Type className="w-4 h-4 text-orange-600" />;
case TemplateMaterialType.Effect:
return <Sparkles className="w-4 h-4 text-pink-600" />;
default:
return <FileText className="w-4 h-4 text-gray-600" />;
}
};
// 获取轨道类型图标
const getTrackIcon = (type: TrackType) => {
switch (type) {
case TrackType.Video:
return <Video className="w-4 h-4 text-blue-600" />;
case TrackType.Audio:
return <Music className="w-4 h-4 text-green-600" />;
case TrackType.Text:
return <Type className="w-4 h-4 text-orange-600" />;
default:
return <Layers className="w-4 h-4 text-gray-600" />;
}
};
// 检查文件是否存在(基于数据库字段和路径)
const getFileExistenceInfo = (material: any) => {
const hasRemoteFile = material.remote_url && material.remote_url.trim() !== '';
if (hasRemoteFile) {
return {
icon: <Cloud className="w-4 h-4 text-blue-600" />,
text: '云端文件',
color: 'text-blue-600'
};
} else if (material.file_exists) {
return {
icon: <CheckCircle className="w-4 h-4 text-green-600" />,
text: '本地文件存在',
color: 'text-green-600'
};
} else {
return {
icon: <XCircle className="w-4 h-4 text-red-600" />,
text: '文件不存在',
color: 'text-red-600'
};
}
};
// 获取上传状态(基于数据库字段)
const getUploadStatusInfo = (material: any) => {
if (material.upload_success) {
return {
icon: <CheckCircle className="w-4 h-4 text-green-600" />,
text: '上传成功',
color: 'text-green-600'
};
} else {
// 根据 upload_status 显示具体状态
switch (material.upload_status) {
case 'Uploading':
return {
icon: <Upload className="w-4 h-4 text-blue-600" />,
text: '上传中',
color: 'text-blue-600'
};
case 'Failed':
case 'Pending':
return {
icon: <AlertCircle className="w-4 h-4 text-yellow-600" />,
text: '待上传',
color: 'text-yellow-600'
};
case 'Skipped':
return {
icon: <AlertCircle className="w-4 h-4 text-gray-600" />,
text: '已跳过',
color: 'text-gray-600'
};
default:
return {
icon: <AlertCircle className="w-4 h-4 text-gray-600" />,
text: '未上传',
color: 'text-gray-600'
};
}
}
};
// 解析文字素材的文本内容和样式
const parseTextContent = (metadata: string | null) => {
if (!metadata) return null;
try {
const parsed = JSON.parse(metadata);
const obj = {
content: tryParse(parsed.content || null),
font_family: parsed.font_family || null,
font_size: parsed.font_size || null,
color: parsed.color || null
};
console.log({ obj })
return obj;
} catch (e) {
return null;
}
};
const tryParse = (str: any) => {
try {
if (typeof str === 'string') {
return JSON.parse(str)
}
return str;
} catch (e) {
return str;
}
}
// 解析片段属性信息
const parseSegmentProperties = (properties: string | null) => {
if (!properties) return null;
try {
const parsed = JSON.parse(properties);
return {
speed: parsed.speed || 1.0,
source_timerange: parsed.source_timerange || null,
target_timerange: parsed.target_timerange || null,
speed_info: parsed.speed_info || null,
visible: parsed.visible,
volume: parsed.volume
};
} catch (e) {
return null;
}
};
const tabs = [
{ id: 'overview', label: '概览', icon: Monitor },
{ id: 'materials', label: '素材', icon: FileText },
{ id: 'tracks', label: '轨道', icon: Layers },
];
const getMaterialName = (material: TemplateMaterial) => {
if (material.material_type === TemplateMaterialType.Text) {
return parseTextContent(material.metadata!)?.content?.text
}
return material.name
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl mx-4 max-h-[90vh] overflow-hidden">
{/* 模态框头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div>
<h2 className="text-xl font-semibold text-gray-900">{template.name}</h2>
{template.description && (
<p className="text-sm text-gray-600 mt-1">{template.description}</p>
)}
</div>
<button
onClick={onClose}
className="p-1 rounded-full hover:bg-gray-100 transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* 标签页导航 */}
<div className="border-b border-gray-200">
<nav className="flex space-x-8 px-6">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors ${activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Icon className="w-4 h-4 mr-2" />
{tab.label}
</button>
);
})}
</nav>
</div>
{/* 标签页内容 */}
<div className="p-6 overflow-y-auto max-h-[60vh]">
{activeTab === 'overview' && (
<div className="space-y-6">
{/* 基本信息 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-2">
<Monitor className="w-4 h-4 text-gray-600 mr-2" />
<span className="text-sm font-medium text-gray-700"></span>
</div>
<div className="text-lg font-semibold text-gray-900">
{template.canvas_config.width}×{template.canvas_config.height}
</div>
<div className="text-xs text-gray-500">{template.canvas_config.ratio}</div>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-2">
<Clock className="w-4 h-4 text-gray-600 mr-2" />
<span className="text-sm font-medium text-gray-700"></span>
</div>
<div className="text-lg font-semibold text-gray-900">
{formatDuration(template.duration)}
</div>
<div className="text-xs text-gray-500">{template.fps} FPS</div>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-2">
<FileText className="w-4 h-4 text-gray-600 mr-2" />
<span className="text-sm font-medium text-gray-700"></span>
</div>
<div className="text-lg font-semibold text-gray-900">
{template.materials.length}
</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-2">
<Layers className="w-4 h-4 text-gray-600 mr-2" />
<span className="text-sm font-medium text-gray-700"></span>
</div>
<div className="text-lg font-semibold text-gray-900">
{template.tracks.length}
</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
{/* 模板信息 */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="text-sm font-medium text-gray-700 mb-3"></h3>
<div className="grid grid-cols-1 gap-3 text-sm">
<div className="flex items-center">
<FileText className="w-4 h-4 text-gray-500 mr-2" />
<span className="text-gray-600">ID</span>
<span className="ml-1 font-mono text-blue-600 text-xs">{template.id}</span>
</div>
{template.source_file_path && (
<div className="flex items-center">
<FileText className="w-4 h-4 text-gray-500 mr-2" />
<span className="text-gray-600"></span>
<span className="ml-1 text-gray-900 break-all">{template.source_file_path}</span>
</div>
)}
</div>
</div>
{/* 创建信息 */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="text-sm font-medium text-gray-700 mb-3"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div className="flex items-center">
<Calendar className="w-4 h-4 text-gray-500 mr-2" />
<span className="text-gray-600"></span>
<span className="ml-1 text-gray-900">{formatDate(template.created_at)}</span>
</div>
<div className="flex items-center">
<Calendar className="w-4 h-4 text-gray-500 mr-2" />
<span className="text-gray-600"></span>
<span className="ml-1 text-gray-900">{formatDate(template.updated_at)}</span>
</div>
</div>
</div>
</div>
)}
{activeTab === 'materials' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">
({template.materials.length})
</h3>
</div>
<div className="space-y-3">
{template.materials.map((material) => {
const uploadStatus = getUploadStatusInfo(material);
const fileExistence = getFileExistenceInfo(material);
return (
<div key={material.id} className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3">
{getMaterialIcon(material.material_type)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
{getMaterialName(material)}
</div>
<div className="text-xs text-gray-500 mt-1 space-y-1">
<div>ID: <span className="font-mono text-blue-600">{material.id}</span></div>
<div>ID: <span className="font-mono text-purple-600">{material.original_id}</span></div>
<div>: {material.material_type}</div>
{/* 文件存在状态 */}
<div className="flex items-center space-x-1">
{fileExistence.icon}
<span className={fileExistence.color}>{fileExistence.text}</span>
</div>
{/* 上传状态 */}
<div className="flex items-center space-x-1">
{uploadStatus.icon}
<span className={uploadStatus.color}>{uploadStatus.text}</span>
</div>
</div>
{/* 文字素材显示文本内容 */}
{material.material_type === 'Text' && material.metadata && (() => {
const textData = parseTextContent(material.metadata);
return textData ? (
<div className="text-xs text-gray-700 bg-gray-100 p-2 rounded mt-1">
<div className="font-medium text-gray-600 mb-1">:</div>
<div className="break-words mb-2">
{textData.content?.text || '无文本内容'}
</div>
{(textData.font_family || textData.font_size || textData.color) && (
<div className="text-xs text-gray-500 space-y-1">
{textData.font_family && (
<div>: {textData.font_family}</div>
)}
{textData.font_size && (
<div>: {textData.font_size}px</div>
)}
{textData.color && (
<div>: {textData.color}</div>
)}
</div>
)}
</div>
) : (
<div className="text-xs text-gray-500 bg-gray-100 p-2 rounded mt-1">
</div>
);
})()}
{/* 非文字素材显示文件路径 */}
{material.material_type !== 'Text' && material.original_path && (
<div className="text-xs text-gray-500 break-all mt-1">
: {material.original_path}
</div>
)}
{material.remote_url && (
<div className="text-xs text-gray-500 break-all mt-1">
URL: {material.remote_url}
</div>
)}
</div>
</div>
<div className="text-right text-xs text-gray-500">
{material.duration && (
<div>: {formatDuration(material.duration)}</div>
)}
{material.file_size && (
<div>: {formatFileSize(material.file_size)}</div>
)}
{material.width && material.height && (
<div>: {material.width}×{material.height}</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{activeTab === 'tracks' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">
({template.tracks.length})
</h3>
</div>
<div className="space-y-4">
{template.tracks.map((track) => (
<div key={track.id} className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2">
{getTrackIcon(track.track_type)}
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{track.name}
</span>
<div className="text-xs text-gray-500">
<span>ID: <span className="font-mono text-green-600">{track.id}</span></span>
<span className="ml-3">: {track.track_type}</span>
<span className="ml-3">: {track.track_index}</span>
</div>
</div>
</div>
<span className="text-xs text-gray-500">
{track.segments.length}
</span>
</div>
{track.segments.length > 0 && (
<div className="space-y-2">
{track.segments.map((segment) => {
const segmentProps = parseSegmentProperties(segment.properties || null);
return (
<div key={segment.id} className="bg-white p-3 rounded border">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-900">{segment.name}</span>
<span className="text-xs text-gray-500">
{formatDurationWithMs(segment.start_time)} - {formatDurationWithMs(segment.end_time)}
</span>
</div>
<div className="text-xs text-gray-500 space-y-1">
<div>
<span>ID: <span className="font-mono text-orange-600">{segment.id}</span></span>
<span className="ml-3">: {segment.segment_index}</span>
</div>
<div className="flex items-center space-x-4">
<span>: {formatDurationWithMs(segment.duration)}</span>
{segmentProps?.speed && segmentProps.speed !== 1.0 && (
<span className={`px-2 py-0.5 rounded text-xs ${
segmentProps.speed > 1.0
? 'bg-red-100 text-red-800'
: 'bg-green-100 text-green-800'
}`}>
: {segmentProps.speed.toFixed(3)}x {segmentProps.speed > 1.0 ? '(加速)' : '(减速)'}
</span>
)}
{segmentProps?.speed === 1.0 && (
<span className="bg-gray-100 text-gray-600 px-2 py-0.5 rounded text-xs"></span>
)}
</div>
{/* 播放速度不影响片段时长,移除实际播放时长显示 */}
{/* 时间轴位置信息 */}
{segmentProps?.target_timerange && (
<div className="bg-blue-50 p-2 rounded mt-1">
<div className="text-xs font-medium text-blue-700 mb-1">:</div>
<div className="text-xs text-blue-600 space-y-0.5">
<div>: {formatDurationWithMs(segmentProps.target_timerange.start)}</div>
<div>: {formatDurationWithMs(segmentProps.target_timerange.duration)}</div>
<div>: {formatDurationWithMs(segmentProps.target_timerange.start + segmentProps.target_timerange.duration)}</div>
</div>
</div>
)}
{/* 源素材时间范围 */}
{segmentProps?.source_timerange && (
<div className="bg-purple-50 p-2 rounded mt-1">
<div className="text-xs font-medium text-purple-700 mb-1">:</div>
<div className="text-xs text-purple-600 space-y-0.5">
<div>: {formatDurationWithMs(segmentProps.source_timerange.start)}</div>
<div>: {formatDurationWithMs(segmentProps.source_timerange.duration)}</div>
<div>: {formatDurationWithMs(segmentProps.source_timerange.start + segmentProps.source_timerange.duration)}</div>
</div>
</div>
)}
{/* 其他属性信息 */}
{(segmentProps?.visible !== undefined || segmentProps?.volume !== undefined) && (
<div className="bg-gray-50 p-2 rounded mt-1">
<div className="text-xs font-medium text-gray-700 mb-1">:</div>
<div className="text-xs text-gray-600 space-y-0.5">
{segmentProps?.visible !== undefined && (
<div>: {segmentProps.visible ? '可见' : '隐藏'}</div>
)}
{segmentProps?.volume !== undefined && (
<div>: {(segmentProps.volume * 100).toFixed(0)}%</div>
)}
</div>
</div>
)}
{/* 匹配规则编辑器 */}
<div className="bg-yellow-50 p-2 rounded mt-1">
<SegmentMatchingRuleEditor
segmentId={segment.id}
currentRule={segment.matching_rule}
onRuleUpdated={(newRule) => {
// 可以在这里添加更新后的回调逻辑
console.log('片段匹配规则已更新:', segment.id, newRule);
}}
/>
</div>
{segment.template_material_id && (
<div>
使ID: <span className="font-mono text-blue-600">{segment.template_material_id}</span>
</div>
)}
{!segment.template_material_id && (
<div className="text-gray-400"></div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* 模态框底部 */}
<div className="flex items-center justify-end p-6 border-t border-gray-200">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
</button>
</div>
</div>
</div>
);
};