feat: 完善模板导入功能
新增功能: - 添加详细的模板导入日志系统 - 实现全局进度存储机制 - 完善模板状态管理 修复问题: - 修复进度监控无限轮询问题 - 修复模板列表状态显示不正确问题 - 修复所有unwrap()导致的panic错误 - 修复外键约束失败问题 改进: - 优化素材上传逻辑,只上传视频/音频/图片 - 上传失败时自动跳过而不是中断导入 - 缺失文件时继续导入而不是失败 - 改进错误处理机制
This commit is contained in:
273
apps/desktop/src/components/template/BatchImportModal.tsx
Normal file
273
apps/desktop/src/components/template/BatchImportModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
325
apps/desktop/src/components/template/ImportProgressModal.tsx
Normal file
325
apps/desktop/src/components/template/ImportProgressModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
276
apps/desktop/src/components/template/ImportTemplateModal.tsx
Normal file
276
apps/desktop/src/components/template/ImportTemplateModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
351
apps/desktop/src/components/template/PerformanceMonitor.tsx
Normal file
351
apps/desktop/src/components/template/PerformanceMonitor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
217
apps/desktop/src/components/template/TemplateCard.tsx
Normal file
217
apps/desktop/src/components/template/TemplateCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
303
apps/desktop/src/components/template/TemplateDetailModal.tsx
Normal file
303
apps/desktop/src/components/template/TemplateDetailModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user