mxivideo/src/pages/TemplateManagePage.tsx

710 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import React, { useState, useEffect } from 'react'
import { Upload, FolderOpen, Trash2, Eye, Download, Search, Filter, Grid, List } from 'lucide-react'
import { invoke } from '@tauri-apps/api/core'
import { TemplateService } from '../services/tauri'
import { useTemplateProgress } from '../hooks/useProgressCommand'
interface TemplateInfo {
id: string
name: string
description: string
thumbnail_path: string
draft_content_path: string
resources_path: string
created_at: string
updated_at: string
canvas_config: any
duration: number
material_count: number
track_count: number
tags: string[]
}
// 轨道和片段的数据结构
interface TrackSegment {
id: string
type: 'video' | 'audio' | 'image' | 'text' | 'effect'
name: string
start_time: number
end_time: number
duration: number
resource_path?: string
properties?: any
effects?: any[]
}
interface Track {
id: string
name: string
type: 'video' | 'audio' | 'subtitle'
index: number
segments: TrackSegment[]
properties?: any
}
interface TemplateDetail {
id: string
name: string
description: string
canvas_config: any
tracks: Track[]
duration: number
fps: number
sample_rate?: number
}
// Import the progress interface from the hook
import type { ProgressState } from '../hooks/useProgressCommand'
interface ImportResult {
status: boolean
msg: string
imported_count: number
failed_count: number
imported_templates: TemplateInfo[]
failed_templates: Array<{
name: string
error: string
}>
}
const TemplateManagePage: React.FC = () => {
const [templates, setTemplates] = useState<TemplateInfo[]>([])
const [loading, setLoading] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [selectedTemplate, setSelectedTemplate] = useState<TemplateInfo | null>(null)
const [templateDetail, setTemplateDetail] = useState<TemplateDetail | null>(null)
const [loadingDetail, setLoadingDetail] = useState(false)
const [showImportModal, setShowImportModal] = useState(false)
// Use the progress hook for template operations
const {
isExecuting: importing,
progress: importProgress,
result: importResult,
logs: importLogs,
batchImport,
reset: resetImport
} = useTemplateProgress({
onSuccess: async (result) => {
if (result.status && result.imported_count > 0) {
await loadTemplates() // Reload templates after successful import
}
}
})
// Load templates on component mount
useEffect(() => {
loadTemplates()
}, [])
const loadTemplates = async () => {
try {
setLoading(true)
const result = await TemplateService.getTemplates()
if (result.status) {
setTemplates(result.templates || [])
} else {
console.error('Failed to load templates:', result.msg)
setTemplates([])
}
} catch (error) {
console.error('Error loading templates:', error)
setTemplates([])
} finally {
setLoading(false)
}
}
// 加载模板详情(包含轨道和片段信息)
const loadTemplateDetail = async (template: TemplateInfo) => {
try {
setLoadingDetail(true)
setSelectedTemplate(template)
// 调用后端API获取模板详情
const detail = await TemplateService.getTemplateDetail(template.id)
setTemplateDetail(detail)
} catch (error) {
console.error('Failed to load template detail:', error)
// 如果加载详情失败,至少显示基本信息
setTemplateDetail(null)
} finally {
setLoadingDetail(false)
}
}
const handleBatchImport = async () => {
try {
// Select folder using Tauri dialog
const folderResult = await invoke<string>('select_folder')
if (!folderResult) return
// Show import modal and start import
setShowImportModal(true)
await batchImport(folderResult)
} catch (error) {
console.error('Import failed:', error)
}
}
const handleDeleteTemplate = async (templateId: string) => {
if (!confirm('确定要删除这个模板吗?此操作不可撤销。')) return
try {
const result = await TemplateService.deleteTemplate(templateId)
if (result.status) {
setTemplates(templates.filter(t => t.id !== templateId))
if (selectedTemplate?.id === templateId) {
setSelectedTemplate(null)
}
} else {
alert('删除失败: ' + result.msg)
}
} catch (error) {
console.error('Delete failed:', error)
alert('删除失败: ' + (error instanceof Error ? error.message : 'Unknown error'))
}
}
const filteredTemplates = templates.filter(template =>
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.description.toLowerCase().includes(searchTerm.toLowerCase())
)
const formatDuration = (duration: number) => {
const seconds = Math.floor(duration / 1000000)
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
// Helper function to format time (for segments, in seconds)
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60)
const secs = (seconds % 60).toFixed(2)
return `${minutes}:${secs.padStart(5, '0')}`
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-CN')
}
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600"></p>
</div>
{/* Actions Bar */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={handleBatchImport}
disabled={importing}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{importing ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
...
</>
) : (
<>
<Upload size={16} />
</>
)}
</button>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={16} />
<input
type="text"
placeholder="搜索模板..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
>
<Grid size={16} />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded ${viewMode === 'list' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
>
<List size={16} />
</button>
</div>
</div>
</div>
{/* Import Result */}
{importResult && (
<div className={`mb-6 p-4 rounded-lg ${importResult.status ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<div className="flex items-center justify-between">
<div>
<h3 className={`font-medium ${importResult.status ? 'text-green-800' : 'text-red-800'}`}>
</h3>
<p className={`text-sm ${importResult.status ? 'text-green-600' : 'text-red-600'}`}>
{importResult.msg}
</p>
{importResult.imported_count > 0 && (
<p className="text-sm text-green-600 mt-1">
{importResult.imported_count}
</p>
)}
{importResult.failed_count > 0 && (
<details className="mt-2">
<summary className="text-sm text-red-600 cursor-pointer">
{importResult.failed_count} ()
</summary>
<div className="mt-2 space-y-1">
{importResult.failed_templates.map((failed: any, index: number) => (
<div key={index} className="text-xs text-red-500">
{failed.name}: {failed.error}
</div>
))}
</div>
</details>
)}
</div>
<button
onClick={() => setImportResult(null)}
className="text-gray-400 hover:text-gray-600"
>
×
</button>
</div>
</div>
)}
{/* Templates Grid/List */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-600 border-t-transparent"></div>
<span className="ml-2 text-gray-600">...</span>
</div>
) : filteredTemplates.length === 0 ? (
<div className="text-center py-12">
<FolderOpen className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-gray-600 mb-4">
{searchTerm ? '没有找到匹配的模板' : '点击上方按钮开始导入模板'}
</p>
{!searchTerm && (
<button
onClick={handleBatchImport}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Upload size={16} />
</button>
)}
</div>
) : (
<div className={viewMode === 'grid' ? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6' : 'space-y-4'}>
{filteredTemplates.map((template) => (
<div
key={template.id}
className={`bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow ${
viewMode === 'list' ? 'flex items-center p-4' : 'overflow-hidden'
}`}
>
{viewMode === 'grid' ? (
<>
{/* Thumbnail */}
<div className="h-32 bg-gray-100 flex items-center justify-center">
{template.thumbnail_path ? (
<img
src={template.thumbnail_path}
alt={template.name}
className="w-full h-full object-cover"
/>
) : (
<div className="text-gray-400">
<Grid size={32} />
</div>
)}
</div>
{/* Content */}
<div className="p-4">
<h3 className="font-medium text-gray-900 mb-1 truncate" title={template.name}>
{template.name}
</h3>
<p className="text-sm text-gray-600 mb-2 line-clamp-2">
{template.description}
</p>
<div className="text-xs text-gray-500 space-y-1">
<div>: {formatDuration(template.duration)}</div>
<div>: {template.material_count} </div>
<div>: {template.track_count} </div>
<div>: {formatDate(template.created_at)}</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100">
<button
onClick={() => loadTemplateDetail(template)}
className="flex items-center gap-1 text-blue-600 hover:text-blue-700 text-sm"
>
<Eye size={14} />
</button>
<button
onClick={() => handleDeleteTemplate(template.id)}
className="flex items-center gap-1 text-red-600 hover:text-red-700 text-sm"
>
<Trash2 size={14} />
</button>
</div>
</div>
</>
) : (
<>
{/* List View */}
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-gray-900">{template.name}</h3>
<p className="text-sm text-gray-600">{template.description}</p>
</div>
<div className="flex items-center space-x-4 text-sm text-gray-500">
<span>{formatDuration(template.duration)}</span>
<span>{template.material_count} </span>
<span>{template.track_count} </span>
<span>{formatDate(template.created_at)}</span>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => loadTemplateDetail(template)}
className="p-2 text-blue-600 hover:text-blue-700"
>
<Eye size={16} />
</button>
<button
onClick={() => handleDeleteTemplate(template.id)}
className="p-2 text-red-600 hover:text-red-700"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
</>
)}
</div>
))}
</div>
)}
{/* Import Progress Modal */}
{showImportModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-gray-900"></h2>
{!importing && (
<button
onClick={() => setShowImportModal(false)}
className="text-gray-400 hover:text-gray-600"
>
×
</button>
)}
</div>
{/* Progress Bar */}
{importProgress && (
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">{importProgress.step}</span>
<span className="text-sm text-gray-500">
{importProgress.progress >= 0 ? `${Math.round(importProgress.progress)}%` : '处理中...'}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{
width: importProgress.progress >= 0 ? `${importProgress.progress}%` : '50%'
}}
></div>
</div>
<p className="text-sm text-gray-600 mt-2">{importProgress.message}</p>
{importProgress.details?.processed_templates !== undefined && importProgress.details?.total_templates !== undefined && (
<p className="text-xs text-gray-500 mt-1">
: {importProgress.details.processed_templates} / {importProgress.details.total_templates}
</p>
)}
</div>
)}
{/* Real-time Logs */}
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-2"></h3>
<div className="bg-gray-50 rounded-lg p-4 h-64 overflow-y-auto">
<div className="space-y-1">
{importLogs.map((log, index) => (
<div key={index} className="text-xs text-gray-600 font-mono">
{log}
</div>
))}
{importLogs.length === 0 && (
<div className="text-xs text-gray-400 italic">...</div>
)}
</div>
</div>
</div>
{/* Import Result */}
{importResult && (
<div className={`p-4 rounded-lg ${importResult.status ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<div className="flex items-center justify-between">
<div>
<h3 className={`font-medium ${importResult.status ? 'text-green-800' : 'text-red-800'}`}>
</h3>
<p className={`text-sm ${importResult.status ? 'text-green-600' : 'text-red-600'}`}>
{importResult.msg}
</p>
{importResult.imported_count > 0 && (
<p className="text-sm text-green-600 mt-1">
{importResult.imported_count}
</p>
)}
{importResult.failed_count > 0 && (
<details className="mt-2">
<summary className="text-sm text-red-600 cursor-pointer">
{importResult.failed_count} ()
</summary>
<div className="mt-2 space-y-1">
{importResult.failed_templates.map((failed: any, index: number) => (
<div key={index} className="text-xs text-red-500">
{failed.name}: {failed.error}
</div>
))}
</div>
</details>
)}
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-6">
{importing ? (
<div className="flex items-center text-blue-600">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-600 border-t-transparent mr-2"></div>
...
</div>
) : (
<button
onClick={() => setShowImportModal(false)}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
)}
</div>
</div>
</div>
</div>
)}
{/* Template Detail Modal */}
{selectedTemplate && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-gray-900"></h2>
<button
onClick={() => {
setSelectedTemplate(null)
setTemplateDetail(null)
}}
className="text-gray-400 hover:text-gray-600"
>
×
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<p className="text-gray-900">{selectedTemplate.name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<p className="text-gray-900">{selectedTemplate.description}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<p className="text-gray-900">{formatDuration(selectedTemplate.duration)}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<p className="text-gray-900">{selectedTemplate.material_count}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<p className="text-gray-900">{selectedTemplate.track_count}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<p className="text-gray-900">
{selectedTemplate.canvas_config?.width || 0} × {selectedTemplate.canvas_config?.height || 0}
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<p className="text-gray-900">{new Date(selectedTemplate.created_at).toLocaleString('zh-CN')}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<p className="text-gray-900">{new Date(selectedTemplate.updated_at).toLocaleString('zh-CN')}</p>
</div>
{/* 轨道和片段信息 */}
<div className="border-t border-gray-200 pt-4">
<label className="block text-sm font-medium text-gray-700 mb-3"></label>
{loadingDetail ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-2 text-gray-600">...</span>
</div>
) : templateDetail ? (
<div className="space-y-4">
{/* 画布信息 */}
<div className="bg-gray-50 rounded-lg p-3">
<h4 className="font-medium text-gray-900 mb-2"></h4>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>: {templateDetail.canvas_config?.width || 0} × {templateDetail.canvas_config?.height || 0}</div>
<div>: {templateDetail.fps || 30} FPS</div>
<div>: {formatDuration(templateDetail.duration)}</div>
{templateDetail.sample_rate && (
<div>: {templateDetail.sample_rate} Hz</div>
)}
</div>
</div>
{/* 轨道列表 */}
<div className="space-y-3">
{templateDetail.tracks.map((track, trackIndex) => (
<div key={track.id} className="border border-gray-200 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-gray-900">
{track.index + 1}: {track.name}
</h4>
<span className={`px-2 py-1 rounded text-xs font-medium ${
track.type === 'video' ? 'bg-blue-100 text-blue-800' :
track.type === 'audio' ? 'bg-green-100 text-green-800' :
'bg-purple-100 text-purple-800'
}`}>
{track.type === 'video' ? '视频' : track.type === 'audio' ? '音频' : '字幕'}
</span>
</div>
{/* 片段列表 */}
<div className="space-y-2">
{track.segments.length > 0 ? (
track.segments.map((segment, segmentIndex) => (
<div key={segment.id} className="bg-gray-50 rounded p-2 text-sm">
<div className="flex items-center justify-between mb-1">
<span className="font-medium">{segment.name}</span>
<span className={`px-1.5 py-0.5 rounded text-xs ${
segment.type === 'video' ? 'bg-blue-100 text-blue-700' :
segment.type === 'audio' ? 'bg-green-100 text-green-700' :
segment.type === 'image' ? 'bg-yellow-100 text-yellow-700' :
segment.type === 'text' ? 'bg-purple-100 text-purple-700' :
'bg-gray-100 text-gray-700'
}`}>
{segment.type === 'video' ? '视频' :
segment.type === 'audio' ? '音频' :
segment.type === 'image' ? '图片' :
segment.type === 'text' ? '文本' : '特效'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-600">
<div>: {formatTime(segment.start_time)}</div>
<div>: {formatTime(segment.end_time)}</div>
<div>: {formatTime(segment.duration)}</div>
</div>
{segment.resource_path && (
<div className="text-xs text-gray-500 mt-1 truncate">
: {segment.resource_path}
</div>
)}
</div>
))
) : (
<div className="text-sm text-gray-500 italic"></div>
)}
</div>
</div>
))}
</div>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<p></p>
<p className="text-sm"></p>
</div>
)}
</div>
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
onClick={() => {
setSelectedTemplate(null)
setTemplateDetail(null)
}}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
<button
onClick={() => handleDeleteTemplate(selectedTemplate.id)}
className="px-4 py-2 text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)
}
export default TemplateManagePage