feat: 完善模板导入功能

新增功能:
- 添加详细的模板导入日志系统
- 实现全局进度存储机制
- 完善模板状态管理

 修复问题:
- 修复进度监控无限轮询问题
- 修复模板列表状态显示不正确问题
- 修复所有unwrap()导致的panic错误
- 修复外键约束失败问题

 改进:
- 优化素材上传逻辑,只上传视频/音频/图片
- 上传失败时自动跳过而不是中断导入
- 缺失文件时继续导入而不是失败
- 改进错误处理机制
This commit is contained in:
imeepos
2025-07-14 21:34:07 +08:00
parent b3321114ac
commit 939efd70d4
39 changed files with 9152 additions and 6 deletions

View File

@@ -0,0 +1,273 @@
import React, { useState } from 'react';
import { X, FolderOpen, AlertCircle, CheckCircle, Settings } from 'lucide-react';
import { BatchImportRequest } from '../../types/template';
import { CustomSelect } from '../CustomSelect';
import { useProjectStore } from '../../store/projectStore';
import { invoke } from '@tauri-apps/api/core';
interface BatchImportModalProps {
onClose: () => void;
onImport: (request: BatchImportRequest) => Promise<void>;
isLoading: boolean;
}
export const BatchImportModal: React.FC<BatchImportModalProps> = ({
onClose,
onImport,
isLoading,
}) => {
const [formData, setFormData] = useState<Partial<BatchImportRequest>>({
folder_path: '',
project_id: '',
auto_upload: true,
max_concurrent: 3,
});
const [selectedFolder, setSelectedFolder] = useState<string>('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [showAdvanced, setShowAdvanced] = useState(false);
const { projects } = useProjectStore();
// 处理文件夹选择
const handleFolderSelect = async () => {
try {
// 使用 Tauri 的文件夹选择对话框
const selected = await invoke<string | null>('select_directory');
if (selected) {
setSelectedFolder(selected);
setFormData(prev => ({
...prev,
folder_path: selected,
}));
setErrors(prev => ({ ...prev, folder_path: '' }));
}
} catch (error) {
console.error('文件夹选择失败:', error);
}
};
// 表单验证
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.folder_path) {
newErrors.folder_path = '请选择包含剪映草稿的文件夹';
}
if (formData.max_concurrent && (formData.max_concurrent < 1 || formData.max_concurrent > 10)) {
newErrors.max_concurrent = '并发数量应在 1-10 之间';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 处理提交
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
await onImport(formData as BatchImportRequest);
} catch (error) {
console.error('批量导入失败:', error);
}
};
const projectOptions = [
{ value: '', label: '不关联项目' },
...projects.map(project => ({
value: project.id,
label: project.name,
})),
];
const concurrentOptions = [
{ value: `1`, label: '1 个(慢速)' },
{ value: `2`, label: '2 个' },
{ value: `3`, label: '3 个(推荐)' },
{ value: `5`, label: '5 个' },
{ value: `10`, label: '10 个(快速)' },
];
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-lg mx-4">
{/* 模态框头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900"></h2>
<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>
{/* 模态框内容 */}
<form onSubmit={handleSubmit} className="p-6">
{/* 文件夹选择区域 */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
errors.folder_path
? 'border-red-300 bg-red-50'
: 'border-gray-300 hover:border-gray-400'
}`}
>
{selectedFolder ? (
<div className="flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-500 mr-3" />
<div className="text-left">
<div className="text-sm font-medium text-gray-900">
</div>
<div className="text-xs text-gray-500 break-all">
{selectedFolder}
</div>
</div>
</div>
) : (
<>
<FolderOpen className="w-8 h-8 text-gray-400 mx-auto mb-2" />
<div className="text-sm text-gray-600 mb-2">
稿
</div>
<div className="text-xs text-gray-500 mb-3">
draft_content.json
</div>
</>
)}
<button
type="button"
onClick={handleFolderSelect}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors"
>
<FolderOpen className="w-4 h-4 mr-2" />
</button>
</div>
{errors.folder_path && (
<div className="flex items-center mt-2 text-sm text-red-600">
<AlertCircle className="w-4 h-4 mr-1" />
{errors.folder_path}
</div>
)}
</div>
{/* 关联项目 */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<CustomSelect
value={formData.project_id || ''}
onChange={(value) => setFormData(prev => ({ ...prev, project_id: value }))}
options={projectOptions}
placeholder="选择项目(可选)"
className="w-full"
/>
</div>
{/* 自动上传选项 */}
<div className="mb-6">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.auto_upload}
onChange={(e) => setFormData(prev => ({ ...prev, auto_upload: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">
</span>
</label>
<p className="text-xs text-gray-500 mt-1">
</p>
</div>
{/* 高级设置 */}
<div className="mb-6">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center text-sm text-blue-600 hover:text-blue-700 transition-colors"
>
<Settings className="w-4 h-4 mr-1" />
</button>
{showAdvanced && (
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<CustomSelect
value={`${formData.max_concurrent || 3}`}
onChange={(value) => setFormData(prev => ({ ...prev, max_concurrent: parseInt(value) }))}
options={concurrentOptions}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
</p>
{errors.max_concurrent && (
<div className="flex items-center mt-1 text-sm text-red-600">
<AlertCircle className="w-4 h-4 mr-1" />
{errors.max_concurrent}
</div>
)}
</div>
</div>
)}
</div>
{/* 提示信息 */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start">
<AlertCircle className="w-5 h-5 text-blue-600 mr-2 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800">
<div className="font-medium mb-1"></div>
<ul className="text-xs space-y-1">
<li> draft_content.json </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-end space-x-3">
<button
type="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>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? '导入中...' : '开始批量导入'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,282 @@
import React, { useEffect, useState } from 'react';
import { X, CheckCircle, Clock, AlertCircle, Pause, Square } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
interface BatchImportProgressModalProps {
onClose: () => void;
onComplete?: () => void;
}
interface BatchProgress {
total_items: number;
completed_items: number;
failed_items: number;
current_item?: string;
overall_progress: number;
is_running: boolean;
}
export const BatchImportProgressModal: React.FC<BatchImportProgressModalProps> = ({
onClose,
onComplete,
}) => {
const [progress, setProgress] = useState<BatchProgress | null>(null);
const [isCompleted, setIsCompleted] = useState(false);
const [queueStatus, setQueueStatus] = useState<[number, number, number, number] | null>(null);
useEffect(() => {
const fetchProgress = async () => {
try {
const progressData = await invoke<BatchProgress>('get_batch_import_progress');
setProgress(progressData);
const status = await invoke<[number, number, number, number]>('get_queue_status');
setQueueStatus(status);
if (progressData && !progressData.is_running &&
progressData.completed_items + progressData.failed_items >= progressData.total_items) {
setIsCompleted(true);
if (onComplete) {
setTimeout(() => {
onComplete();
}, 3000); // 3秒后自动关闭
}
}
} catch (error) {
console.error('获取批量导入进度失败:', error);
}
};
// 立即获取一次进度
fetchProgress();
// 定期轮询进度
const interval = setInterval(() => {
if (!isCompleted) {
fetchProgress();
}
}, 1000);
return () => clearInterval(interval);
}, [isCompleted, onComplete]);
const handleStopImport = async () => {
try {
await invoke('stop_batch_import');
setProgress(prev => prev ? { ...prev, is_running: false } : null);
} catch (error) {
console.error('停止批量导入失败:', error);
}
};
const handleClearQueue = async () => {
try {
await invoke('clear_import_queue');
setProgress(null);
setQueueStatus(null);
onClose();
} catch (error) {
console.error('清空队列失败:', error);
}
};
if (!progress && !queueStatus) {
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-md mx-4 p-6">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">...</span>
</div>
</div>
</div>
);
}
const totalItems = progress?.total_items || 0;
const completedItems = progress?.completed_items || 0;
const failedItems = progress?.failed_items || 0;
const overallProgress = progress?.overall_progress || 0;
const isRunning = progress?.is_running || false;
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-lg mx-4">
{/* 模态框头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900"></h2>
<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="p-6">
{/* 状态指示器 */}
<div className="flex items-center mb-6">
{isRunning ? (
<Clock className="w-6 h-6 text-blue-500 animate-spin" />
) : isCompleted ? (
<CheckCircle className="w-6 h-6 text-green-500" />
) : (
<Pause className="w-6 h-6 text-yellow-500" />
)}
<div className="ml-3">
<div className={`text-lg font-medium ${
isRunning ? 'text-blue-600' :
isCompleted ? 'text-green-600' :
'text-yellow-600'
}`}>
{isRunning ? '正在批量导入' :
isCompleted ? '批量导入完成' :
'批量导入已暂停'}
</div>
{progress?.current_item && (
<div className="text-sm text-gray-600">
: {progress.current_item.split(/[/\\]/).pop()}
</div>
)}
</div>
</div>
{/* 进度条 */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700"></span>
<span className="text-sm text-gray-600">
{Math.round(overallProgress)}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all duration-300 ${
isCompleted ? 'bg-green-500' : 'bg-blue-500'
}`}
style={{ width: `${Math.min(100, Math.max(0, overallProgress))}%` }}
/>
</div>
</div>
{/* 统计信息 */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="text-center">
<div className="text-lg font-semibold text-gray-900">
{totalItems}
</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-blue-600">
{totalItems - completedItems - failedItems}
</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-green-600">
{completedItems}
</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-red-600">
{failedItems}
</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
{/* 队列状态 */}
{queueStatus && (
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-2"></div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">:</span>
<span className="font-medium">{queueStatus[0]}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">:</span>
<span className="font-medium text-yellow-600">{queueStatus[1]}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">:</span>
<span className="font-medium text-blue-600">{queueStatus[2]}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">:</span>
<span className="font-medium text-green-600">{queueStatus[3]}</span>
</div>
</div>
</div>
)}
{/* 完成信息 */}
{isCompleted && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center">
<CheckCircle className="w-5 h-5 text-green-500 mr-2" />
<div className="text-sm text-green-800">
<div className="font-medium"></div>
<div>
{completedItems}
{failedItems > 0 && `${failedItems} 个失败`}
</div>
</div>
</div>
</div>
)}
{/* 操作提示 */}
{isRunning && (
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start">
<AlertCircle className="w-5 h-5 text-blue-500 mr-2 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800">
<div className="font-medium mb-1"></div>
<div>
使
</div>
</div>
</div>
</div>
)}
</div>
{/* 模态框底部 */}
<div className="flex items-center justify-between p-6 border-t border-gray-200">
<div className="flex items-center space-x-2">
{isRunning && (
<button
onClick={handleStopImport}
className="flex items-center px-3 py-2 text-sm font-medium text-red-600 bg-red-50 border border-red-200 rounded-lg hover:bg-red-100 transition-colors"
>
<Square className="w-4 h-4 mr-1" />
</button>
)}
{!isRunning && totalItems > 0 && (
<button
onClick={handleClearQueue}
className="flex items-center px-3 py-2 text-sm font-medium text-gray-600 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors"
>
</button>
)}
</div>
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
{isRunning ? '后台运行' : '关闭'}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,325 @@
import React, { useEffect, useState } from 'react';
import { X, CheckCircle, XCircle, Clock, Upload, AlertCircle } from 'lucide-react';
import { ImportProgress, ImportStatus } from '../../types/template';
import { useTemplateStore } from '../../stores/templateStore';
interface ImportProgressModalProps {
templateId: string;
onClose: () => void;
onComplete?: () => void;
}
export const ImportProgressModal: React.FC<ImportProgressModalProps> = ({
templateId,
onClose,
onComplete,
}) => {
const [progress, setProgress] = useState<ImportProgress | null>(null);
const { getImportProgress } = useTemplateStore();
useEffect(() => {
let interval: NodeJS.Timeout | null = null;
let isMounted = true;
const fetchProgress = async () => {
if (!isMounted) return;
try {
const progressData = await getImportProgress(templateId);
if (!isMounted) return;
setProgress(progressData);
// 如果进度数据为空(已被清除),说明导入已完成,停止轮询
if (!progressData) {
// 设置一个完成状态的进度对象
const completedProgress: ImportProgress = {
template_id: templateId,
template_name: "模板",
status: ImportStatus.Completed,
total_materials: 0,
uploaded_materials: 0,
failed_materials: 0,
current_operation: "导入完成",
error_message: undefined,
progress_percentage: 100
};
setProgress(completedProgress);
// 清除轮询
if (interval) {
clearInterval(interval);
interval = null;
}
// 触发完成回调
if (onComplete) {
setTimeout(() => {
if (isMounted) {
onComplete();
}
}, 2000); // 2秒后自动关闭
}
return;
}
// 如果进度状态为完成或失败,也停止轮询
if (progressData.status === ImportStatus.Completed ||
progressData.status === ImportStatus.Failed) {
// 清除轮询
if (interval) {
clearInterval(interval);
interval = null;
}
if (progressData.status === ImportStatus.Completed && onComplete) {
setTimeout(() => {
if (isMounted) {
onComplete();
}
}, 2000); // 2秒后自动关闭
}
return; // 重要:完成后直接返回,不再继续轮询
}
} catch (error) {
console.error('获取导入进度失败:', error);
// 如果出错,也停止轮询
if (interval) {
clearInterval(interval);
interval = null;
}
}
};
// 立即获取一次进度
fetchProgress();
// 开始轮询
interval = setInterval(fetchProgress, 1000);
return () => {
isMounted = false;
if (interval) {
clearInterval(interval);
}
};
}, [templateId, getImportProgress, onComplete]);
const getStatusIcon = (status: ImportStatus) => {
switch (status) {
case ImportStatus.Completed:
return <CheckCircle className="w-6 h-6 text-green-500" />;
case ImportStatus.Failed:
return <XCircle className="w-6 h-6 text-red-500" />;
case ImportStatus.Uploading:
return <Upload className="w-6 h-6 text-blue-500 animate-pulse" />;
case ImportStatus.Processing:
case ImportStatus.Parsing:
return <Clock className="w-6 h-6 text-blue-500 animate-spin" />;
default:
return <AlertCircle className="w-6 h-6 text-yellow-500" />;
}
};
const getStatusText = (status: ImportStatus) => {
switch (status) {
case ImportStatus.Pending:
return '等待开始';
case ImportStatus.Parsing:
return '解析模板文件';
case ImportStatus.Uploading:
return '上传素材文件';
case ImportStatus.Processing:
return '处理模板数据';
case ImportStatus.Completed:
return '导入完成';
case ImportStatus.Failed:
return '导入失败';
default:
return '未知状态';
}
};
const getStatusColor = (status: ImportStatus) => {
switch (status) {
case ImportStatus.Completed:
return 'text-green-600';
case ImportStatus.Failed:
return 'text-red-600';
case ImportStatus.Uploading:
case ImportStatus.Processing:
case ImportStatus.Parsing:
return 'text-blue-600';
default:
return 'text-yellow-600';
}
};
if (!progress) {
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-md mx-4 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900"></h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="flex items-center justify-center py-8">
<CheckCircle className="w-12 h-12 text-green-500 mr-4" />
<div>
<div className="text-lg font-medium text-green-600"></div>
<div className="text-sm text-gray-600"></div>
</div>
</div>
<div className="flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
</div>
</div>
</div>
);
}
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-md mx-4">
{/* 模态框头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900"></h2>
<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="p-6">
{/* 模板信息 */}
<div className="mb-6">
<h3 className="text-lg font-medium text-gray-900 mb-2">
{progress.template_name}
</h3>
<div className="text-sm text-gray-600">
ID: {progress.template_id}
</div>
</div>
{/* 状态指示器 */}
<div className="flex items-center mb-6">
{getStatusIcon(progress.status)}
<div className="ml-3">
<div className={`text-lg font-medium ${getStatusColor(progress.status)}`}>
{getStatusText(progress.status)}
</div>
<div className="text-sm text-gray-600">
{progress.current_operation}
</div>
</div>
</div>
{/* 进度条 */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700"></span>
<span className="text-sm text-gray-600">
{Math.round(progress.progress_percentage)}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${
progress.status === ImportStatus.Failed
? 'bg-red-500'
: progress.status === ImportStatus.Completed
? 'bg-green-500'
: 'bg-blue-500'
}`}
style={{ width: `${Math.min(100, Math.max(0, progress.progress_percentage))}%` }}
/>
</div>
</div>
{/* 素材统计 */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="text-center">
<div className="text-lg font-semibold text-gray-900">
{progress.total_materials}
</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-green-600">
{progress.uploaded_materials}
</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-red-600">
{progress.failed_materials}
</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
{/* 错误信息 */}
{progress.error_message && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start">
<XCircle className="w-5 h-5 text-red-500 mr-2 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-800">
<div className="font-medium mb-1"></div>
<div>{progress.error_message}</div>
</div>
</div>
</div>
)}
{/* 成功信息 */}
{progress.status === ImportStatus.Completed && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center">
<CheckCircle className="w-5 h-5 text-green-500 mr-2" />
<div className="text-sm text-green-800">
<div className="font-medium"></div>
<div></div>
</div>
</div>
</div>
)}
</div>
{/* 模态框底部 */}
<div className="flex items-center justify-end p-6 border-t border-gray-200">
{progress.status === ImportStatus.Completed || progress.status === ImportStatus.Failed ? (
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
) : (
<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>
);
};

View File

@@ -0,0 +1,276 @@
import React, { useState } from 'react';
import { X, Upload, File, AlertCircle, CheckCircle } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { ImportTemplateRequest } from '../../types/template';
import { CustomSelect } from '../CustomSelect';
import { useProjectStore } from '../../store/projectStore';
interface ImportTemplateModalProps {
onClose: () => void;
onImport: (request: ImportTemplateRequest) => Promise<void>;
isLoading: boolean;
}
export const ImportTemplateModal: React.FC<ImportTemplateModalProps> = ({
onClose,
onImport,
isLoading,
}) => {
const [formData, setFormData] = useState<Partial<ImportTemplateRequest>>({
file_path: '',
template_name: '',
project_id: '',
auto_upload: true,
});
const [dragOver, setDragOver] = useState(false);
const [selectedFile, setSelectedFile] = useState<string>('');
const [errors, setErrors] = useState<Record<string, string>>({});
const { projects } = useProjectStore();
// 处理文件选择
const handleFileSelect = async () => {
try {
// 使用 Tauri 命令选择文件
const selected = await invoke<string | null>('select_file', {
filters: [['剪映草稿文件', ['json']]]
});
if (selected) {
setSelectedFile(selected);
setFormData(prev => ({
...prev,
file_path: selected,
template_name: prev.template_name || extractTemplateNameFromPath(selected),
}));
setErrors(prev => ({ ...prev, file_path: '' }));
}
} catch (error) {
console.error('文件选择失败:', error);
}
};
// 从文件路径提取模板名称
const extractTemplateNameFromPath = (filePath: string) => {
const parts = filePath.split(/[/\\]/);
const fileName = parts[parts.length - 1];
return fileName.replace('.json', '').replace('draft_content', '模板');
};
// 处理拖拽
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const files = Array.from(e.dataTransfer.files);
const jsonFile = files.find(file => file.name.endsWith('.json'));
if (jsonFile) {
// 注意:在 Tauri 中,我们需要使用文件路径而不是 File 对象
// 这里需要根据实际的 Tauri API 来处理文件路径获取
console.log('拖拽文件:', jsonFile.name);
// 实际实现中需要获取文件的完整路径
}
};
// 表单验证
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.file_path) {
newErrors.file_path = '请选择剪映草稿文件';
}
if (!formData.template_name?.trim()) {
newErrors.template_name = '请输入模板名称';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 处理提交
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
await onImport(formData as ImportTemplateRequest);
} catch (error) {
console.error('导入失败:', error);
}
};
const projectOptions = [
{ value: '', label: '不关联项目' },
...projects.map(project => ({
value: project.id,
label: project.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-md mx-4">
{/* 模态框头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900"></h2>
<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>
{/* 模态框内容 */}
<form onSubmit={handleSubmit} className="p-6">
{/* 文件选择区域 */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
稿 *
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
dragOver
? 'border-blue-400 bg-blue-50'
: errors.file_path
? 'border-red-300 bg-red-50'
: 'border-gray-300 hover:border-gray-400'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{selectedFile ? (
<div className="flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-500 mr-3" />
<div className="text-left">
<div className="text-sm font-medium text-gray-900">
{selectedFile.split(/[/\\]/).pop()}
</div>
<div className="text-xs text-gray-500">
{selectedFile}
</div>
</div>
</div>
) : (
<>
<Upload className="w-8 h-8 text-gray-400 mx-auto mb-2" />
<div className="text-sm text-gray-600 mb-2">
draft_content.json
</div>
<div className="text-xs text-gray-500 mb-3">
</div>
</>
)}
<button
type="button"
onClick={handleFileSelect}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors"
>
<File className="w-4 h-4 mr-2" />
</button>
</div>
{errors.file_path && (
<div className="flex items-center mt-2 text-sm text-red-600">
<AlertCircle className="w-4 h-4 mr-1" />
{errors.file_path}
</div>
)}
</div>
{/* 模板名称 */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<input
type="text"
value={formData.template_name || ''}
onChange={(e) => setFormData(prev => ({ ...prev, template_name: e.target.value }))}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors.template_name ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="输入模板名称"
/>
{errors.template_name && (
<div className="flex items-center mt-1 text-sm text-red-600">
<AlertCircle className="w-4 h-4 mr-1" />
{errors.template_name}
</div>
)}
</div>
{/* 关联项目 */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<CustomSelect
value={formData.project_id || ''}
onChange={(value) => setFormData(prev => ({ ...prev, project_id: value }))}
options={projectOptions}
placeholder="选择项目(可选)"
className="w-full"
/>
</div>
{/* 自动上传选项 */}
<div className="mb-6">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.auto_upload}
onChange={(e) => setFormData(prev => ({ ...prev, auto_upload: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">
</span>
</label>
<p className="text-xs text-gray-500 mt-1">
便访
</p>
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-end space-x-3">
<button
type="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>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? '导入中...' : '开始导入'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,351 @@
import React, { useState, useEffect } from 'react';
import { Activity, Database, Clock, TrendingUp, RefreshCw, Trash2 } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
interface PerformanceStats {
template_import?: {
avg_duration_ms: number;
p95_duration_ms: number;
total_imports: number;
};
file_upload?: {
avg_duration_ms: number;
p95_duration_ms: number;
total_uploads: number;
};
database?: {
avg_query_ms: number;
p95_query_ms: number;
total_queries: number;
};
memory?: {
avg_usage_mb: number;
max_usage_mb: number;
current_usage_mb: number;
};
}
interface CacheStats {
total_items: number;
hit_count: number;
miss_count: number;
hit_rate: number;
eviction_count: number;
memory_usage_bytes: number;
}
interface PerformanceMonitorProps {
onClose: () => void;
}
export const PerformanceMonitor: React.FC<PerformanceMonitorProps> = ({ onClose }) => {
const [performanceStats, setPerformanceStats] = useState<PerformanceStats>({});
const [cacheStats, setCacheStats] = useState<CacheStats | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// 加载性能数据
const loadPerformanceData = async () => {
try {
setRefreshing(true);
// 获取性能报告
const perfReport = await invoke<PerformanceStats>('get_template_performance_report');
setPerformanceStats(perfReport);
// 获取缓存统计
const cacheReport = await invoke<CacheStats>('get_cache_stats');
setCacheStats(cacheReport);
} catch (error) {
console.error('加载性能数据失败:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
// 清理性能数据
const handleCleanupData = async () => {
if (window.confirm('确定要清理7天前的性能数据吗')) {
try {
await invoke('cleanup_template_performance_data', { olderThanHours: 24 * 7 });
await loadPerformanceData();
} catch (error) {
console.error('清理性能数据失败:', error);
}
}
};
// 预热缓存
const handleWarmCache = async () => {
try {
// 这里可以传入需要预热的模板ID列表
await invoke('warm_template_cache', { templateIds: [] });
await loadPerformanceData();
} catch (error) {
console.error('预热缓存失败:', error);
}
};
useEffect(() => {
loadPerformanceData();
// 定期刷新数据
const interval = setInterval(loadPerformanceData, 30000); // 30秒刷新一次
return () => clearInterval(interval);
}, []);
const formatDuration = (ms: number) => {
if (ms < 1000) {
return `${Math.round(ms)}ms`;
} else {
return `${(ms / 1000).toFixed(1)}s`;
}
};
const formatBytes = (bytes: number) => {
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 formatPercentage = (value: number) => {
return `${(value * 100).toFixed(1)}%`;
};
if (loading) {
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 p-6">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">...</span>
</div>
</div>
</div>
);
}
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-6xl mx-4 max-h-[90vh] overflow-hidden">
{/* 头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div className="flex items-center">
<Activity className="w-6 h-6 text-blue-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900"></h2>
</div>
<div className="flex items-center space-x-3">
<button
onClick={loadPerformanceData}
disabled={refreshing}
className="flex items-center px-3 py-2 text-sm font-medium text-gray-600 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 disabled:opacity-50 transition-colors"
>
<RefreshCw className={`w-4 h-4 mr-1 ${refreshing ? 'animate-spin' : ''}`} />
</button>
<button
onClick={handleCleanupData}
className="flex items-center px-3 py-2 text-sm font-medium text-red-600 bg-red-50 border border-red-200 rounded-lg hover:bg-red-100 transition-colors"
>
<Trash2 className="w-4 h-4 mr-1" />
</button>
<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 className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 模板导入性能 */}
{performanceStats.template_import && (
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-3">
<Clock className="w-5 h-5 text-blue-600 mr-2" />
<h3 className="text-lg font-medium text-gray-900"></h3>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-gray-600">:</span>
<span className="text-sm font-medium text-gray-900">
{formatDuration(performanceStats.template_import.avg_duration_ms)}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">95%:</span>
<span className="text-sm font-medium text-gray-900">
{formatDuration(performanceStats.template_import.p95_duration_ms)}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">:</span>
<span className="text-sm font-medium text-gray-900">
{performanceStats.template_import.total_imports}
</span>
</div>
</div>
</div>
)}
{/* 文件上传性能 */}
{performanceStats.file_upload && (
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-3">
<TrendingUp className="w-5 h-5 text-green-600 mr-2" />
<h3 className="text-lg font-medium text-gray-900"></h3>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-gray-600">:</span>
<span className="text-sm font-medium text-gray-900">
{formatDuration(performanceStats.file_upload.avg_duration_ms)}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">95%:</span>
<span className="text-sm font-medium text-gray-900">
{formatDuration(performanceStats.file_upload.p95_duration_ms)}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">:</span>
<span className="text-sm font-medium text-gray-900">
{performanceStats.file_upload.total_uploads}
</span>
</div>
</div>
</div>
)}
{/* 数据库性能 */}
{performanceStats.database && (
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-3">
<Database className="w-5 h-5 text-purple-600 mr-2" />
<h3 className="text-lg font-medium text-gray-900"></h3>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-gray-600">:</span>
<span className="text-sm font-medium text-gray-900">
{formatDuration(performanceStats.database.avg_query_ms)}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">95%:</span>
<span className="text-sm font-medium text-gray-900">
{formatDuration(performanceStats.database.p95_query_ms)}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">:</span>
<span className="text-sm font-medium text-gray-900">
{performanceStats.database.total_queries}
</span>
</div>
</div>
</div>
)}
{/* 内存使用 */}
{performanceStats.memory && (
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-3">
<Activity className="w-5 h-5 text-orange-600 mr-2" />
<h3 className="text-lg font-medium text-gray-900">使</h3>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-gray-600">使:</span>
<span className="text-sm font-medium text-gray-900">
{performanceStats.memory.current_usage_mb.toFixed(1)} MB
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">使:</span>
<span className="text-sm font-medium text-gray-900">
{performanceStats.memory.avg_usage_mb.toFixed(1)} MB
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">使:</span>
<span className="text-sm font-medium text-gray-900">
{performanceStats.memory.max_usage_mb.toFixed(1)} MB
</span>
</div>
</div>
</div>
)}
</div>
{/* 缓存统计 */}
{cacheStats && (
<div className="mt-6">
<div className="bg-blue-50 p-4 rounded-lg">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center">
<Database className="w-5 h-5 text-blue-600 mr-2" />
<h3 className="text-lg font-medium text-gray-900"></h3>
</div>
<button
onClick={handleWarmCache}
className="px-3 py-1 text-xs font-medium text-blue-600 bg-blue-100 rounded-md hover:bg-blue-200 transition-colors"
>
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{cacheStats.total_items}</div>
<div className="text-xs text-gray-600"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{formatPercentage(cacheStats.hit_rate)}
</div>
<div className="text-xs text-gray-600"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-600">
{formatBytes(cacheStats.memory_usage_bytes)}
</div>
<div className="text-xs text-gray-600"></div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-gray-700">{cacheStats.hit_count}</div>
<div className="text-xs text-gray-600"></div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-gray-700">{cacheStats.miss_count}</div>
<div className="text-xs text-gray-600"></div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-gray-700">{cacheStats.eviction_count}</div>
<div className="text-xs text-gray-600"></div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,217 @@
import React, { useState } from 'react';
import { MoreVertical, Eye, Trash2, Calendar, FileText, Image, Video, Music } from 'lucide-react';
import { Template, ImportStatus, TemplateMaterialType } from '../../types/template';
interface TemplateCardProps {
template: Template;
onView: () => void;
onDelete: () => void;
getStatusIcon: (status: ImportStatus) => React.ReactNode;
getStatusText: (status: ImportStatus) => string;
}
export const TemplateCard: React.FC<TemplateCardProps> = ({
template,
onView,
onDelete,
getStatusIcon,
getStatusText,
}) => {
const [showMenu, setShowMenu] = useState(false);
// 格式化时长
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 formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
// 获取素材统计
const getMaterialStats = () => {
const stats = {
video: 0,
audio: 0,
image: 0,
text: 0,
other: 0,
};
template.materials.forEach((material) => {
switch (material.material_type) {
case TemplateMaterialType.Video:
stats.video++;
break;
case TemplateMaterialType.Audio:
stats.audio++;
break;
case TemplateMaterialType.Image:
stats.image++;
break;
case TemplateMaterialType.Text:
stats.text++;
break;
default:
stats.other++;
break;
}
});
return stats;
};
const materialStats = getMaterialStats();
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow duration-200">
{/* 卡片头部 */}
<div className="p-4 border-b border-gray-100">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate" title={template.name}>
{template.name}
</h3>
{template.description && (
<p className="text-sm text-gray-600 mt-1 line-clamp-2" title={template.description}>
{template.description}
</p>
)}
</div>
<div className="relative ml-2">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 rounded-full hover:bg-gray-100 transition-colors"
>
<MoreVertical className="w-4 h-4 text-gray-500" />
</button>
{showMenu && (
<div className="absolute right-0 top-8 w-32 bg-white border border-gray-200 rounded-lg shadow-lg z-10">
<button
onClick={() => {
onView();
setShowMenu(false);
}}
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center"
>
<Eye className="w-3 h-3 mr-2" />
</button>
<button
onClick={() => {
onDelete();
setShowMenu(false);
}}
className="w-full px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center"
>
<Trash2 className="w-3 h-3 mr-2" />
</button>
</div>
)}
</div>
</div>
{/* 状态指示器 */}
<div className="flex items-center mt-3">
{getStatusIcon(template.import_status)}
<span className="ml-2 text-sm text-gray-600">
{getStatusText(template.import_status)}
</span>
</div>
</div>
{/* 卡片内容 */}
<div className="p-4">
{/* 基本信息 */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="text-center">
<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"></div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-gray-900">
{formatDuration(template.duration)}
</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
{/* 素材统计 */}
<div className="mb-4">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="flex items-center justify-between text-xs">
{materialStats.video > 0 && (
<div className="flex items-center text-blue-600">
<Video className="w-3 h-3 mr-1" />
{materialStats.video}
</div>
)}
{materialStats.audio > 0 && (
<div className="flex items-center text-green-600">
<Music className="w-3 h-3 mr-1" />
{materialStats.audio}
</div>
)}
{materialStats.image > 0 && (
<div className="flex items-center text-purple-600">
<Image className="w-3 h-3 mr-1" />
{materialStats.image}
</div>
)}
{materialStats.text > 0 && (
<div className="flex items-center text-orange-600">
<FileText className="w-3 h-3 mr-1" />
{materialStats.text}
</div>
)}
</div>
</div>
{/* 轨道信息 */}
<div className="mb-4">
<div className="text-xs text-gray-500 mb-1"></div>
<div className="text-sm font-medium text-gray-900">
{template.tracks.length}
</div>
</div>
{/* 创建时间 */}
<div className="flex items-center text-xs text-gray-500">
<Calendar className="w-3 h-3 mr-1" />
{formatDate(template.created_at)}
</div>
</div>
{/* 卡片底部操作 */}
<div className="px-4 py-3 bg-gray-50 border-t border-gray-100 rounded-b-lg">
<button
onClick={onView}
className="w-full px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors"
>
</button>
</div>
{/* 点击外部关闭菜单 */}
{showMenu && (
<div
className="fixed inset-0 z-0"
onClick={() => setShowMenu(false)}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,303 @@
import React, { useState } from 'react';
import { X, Calendar, Clock, Monitor, Layers, FileText, Image, Video, Music, Type, Sparkles } from 'lucide-react';
import { Template, TemplateMaterialType, TrackType } from '../../types/template';
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 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 tabs = [
{ id: 'overview', label: '概览', icon: Monitor },
{ id: 'materials', label: '素材', icon: FileText },
{ id: 'tracks', label: '轨道', icon: Layers },
];
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 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>
{template.source_file_path && (
<div className="flex items-center md:col-span-2">
<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>
)}
{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) => (
<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">
{material.name}
</div>
<div className="text-xs text-gray-500 mt-1">
: {material.material_type}
</div>
{material.original_path && (
<div className="text-xs text-gray-500 break-all">
: {material.original_path}
</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)}
<span className="text-sm font-medium text-gray-900">
{track.name}
</span>
<span className="text-xs text-gray-500">
({track.track_type})
</span>
</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) => (
<div key={segment.id} className="bg-white p-3 rounded border">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-900">{segment.name}</span>
<span className="text-xs text-gray-500">
{formatDuration(segment.start_time)} - {formatDuration(segment.end_time)}
</span>
</div>
<div className="text-xs text-gray-500 mt-1">
: {formatDuration(segment.duration)}
</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>
);
};