fix: 重构 模板管理

This commit is contained in:
root
2025-07-10 23:18:20 +08:00
parent 629b4dd42b
commit 776a0afba9
9 changed files with 726 additions and 535 deletions

View File

@@ -555,11 +555,7 @@ def main():
detail = manager.get_template_detail(args.template_id)
if detail:
result = {
"status": True,
"detail": detail
}
rpc.success(result)
rpc.success(detail)
else:
rpc.error(JSONRPCError.TEMPLATE_NOT_FOUND, f"Template detail not found: {args.template_id}")

View File

@@ -0,0 +1,125 @@
import React from 'react'
import type { ProgressState, ImportResult } from '../../hooks/useProgressCommand'
interface ImportProgressModalProps {
isOpen: boolean
isExecuting: boolean
progress: ProgressState | null
result: ImportResult | null
logs: string[]
onClose: () => void
}
export const ImportProgressModal: React.FC<ImportProgressModalProps> = ({
isOpen,
isExecuting,
progress,
result,
logs,
onClose
}) => {
if (!isOpen) return null
return (
<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-hidden">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-gray-900"></h2>
{!isExecuting && (
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
×
</button>
)}
</div>
{/* Progress Bar */}
{progress && (
<div className="mb-4">
<div className="flex justify-between mb-2">
<span className="text-sm font-medium text-gray-700">{progress.step}</span>
<span className="text-sm text-gray-500">
{progress.progress >= 0 ? `${progress.progress.toFixed(1)}%` : '处理中...'}
</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: `${Math.max(0, progress.progress)}%` }}
></div>
</div>
<p className="text-sm text-gray-600 mt-2">{progress.message}</p>
{progress.details?.processed_templates !== undefined && progress.details?.total_templates !== undefined && (
<p className="text-xs text-gray-500 mt-1">
: {progress.details.processed_templates} / {progress.details.total_templates}
</p>
)}
</div>
)}
{/* Import Result */}
{result && (
<div className={`mb-4 p-4 rounded-lg ${result.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 ${result.status ? 'text-green-800' : 'text-red-800'}`}>
</h3>
<p className={`text-sm ${result.status ? 'text-green-600' : 'text-red-600'}`}>
{result.msg}
</p>
{result.imported_count > 0 && (
<p className="text-sm text-green-600 mt-1">
{result.imported_count}
</p>
)}
{result.failed_count > 0 && (
<details className="mt-2">
<summary className="text-sm text-red-600 cursor-pointer">
{result.failed_count} ()
</summary>
<div className="mt-2 space-y-1">
{result.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>
)}
{/* Logs */}
<div className="bg-gray-50 rounded-lg p-4 h-64 overflow-y-auto">
<h4 className="font-medium text-gray-900 mb-2"></h4>
<div className="space-y-1">
{logs.map((log, index) => (
<div key={index} className="text-xs text-gray-600 font-mono">
{log}
</div>
))}
</div>
</div>
{/* Actions */}
<div className="flex justify-end mt-4">
{!isExecuting && (
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
</button>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,68 @@
import React from 'react'
import { Upload, FolderOpen, Search, Grid, List } from 'lucide-react'
interface SearchAndFiltersProps {
searchTerm: string
setSearchTerm: (term: string) => void
viewMode: 'grid' | 'list'
setViewMode: (mode: 'grid' | 'list') => void
onBatchImport: () => void
loading: boolean
}
export const SearchAndFilters: React.FC<SearchAndFiltersProps> = ({
searchTerm,
setSearchTerm,
viewMode,
setViewMode,
onBatchImport,
loading
}) => {
return (
<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">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<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>
{/* View Mode Toggle */}
<div className="flex items-center bg-gray-100 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-white shadow-sm' : 'text-gray-600'}`}
>
<Grid size={16} />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded ${viewMode === 'list' ? 'bg-white shadow-sm' : 'text-gray-600'}`}
>
<List size={16} />
</button>
</div>
</div>
{/* Actions */}
<div className="flex items-center space-x-3">
<button
onClick={onBatchImport}
disabled={loading}
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"
>
<FolderOpen size={16} />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
import React from 'react'
import { Eye, Trash2 } from 'lucide-react'
import type { TemplateInfo } from '../../hooks/useProgressCommand'
interface TemplateCardProps {
template: TemplateInfo
onView: (template: TemplateInfo) => void
onDelete: (templateId: string) => void
formatDuration: (duration: number) => string
formatDate: (date: string) => string
}
export const TemplateCard: React.FC<TemplateCardProps> = ({
template,
onView,
onDelete,
formatDuration,
formatDate
}) => {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow">
{/* Thumbnail */}
<div className="aspect-video 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">
<Eye size={32} />
</div>
)}
</div>
{/* Content */}
<div className="p-4">
<h3 className="font-medium text-gray-900 mb-2 line-clamp-1">
{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={() => onView(template)}
className="flex items-center gap-1 text-blue-600 hover:text-blue-700 text-sm"
>
<Eye size={14} />
</button>
<button
onClick={() => onDelete(template.id)}
className="flex items-center gap-1 text-red-600 hover:text-red-700 text-sm"
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,225 @@
import React from 'react'
import type { TemplateInfo } from '../../hooks/useProgressCommand'
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
}
interface TemplateDetailModalProps {
isOpen: boolean
template: TemplateInfo | null
templateDetail: TemplateDetail | null
loadingDetail: boolean
onClose: () => void
onDelete: (templateId: string) => void
formatDuration: (duration: number) => string
formatTime: (seconds: number) => string
}
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
isOpen,
template,
templateDetail,
loadingDetail,
onClose,
onDelete,
formatDuration,
formatTime
}) => {
if (!isOpen || !template) return null
return (
<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-[90vh] 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={onClose}
className="text-gray-400 hover:text-gray-600"
>
×
</button>
</div>
<div className="space-y-4">
{/* Basic Info */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<p className="text-gray-900">{template.name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<p className="text-gray-900">{template.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(template.duration)}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<p className="text-gray-900">{template.material_count}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<p className="text-gray-900">{template.track_count}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<p className="text-gray-900">
{template.canvas_config?.width || 0} × {template.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(template.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(template.updated_at).toLocaleString('zh-CN')}</p>
</div>
{/* Track and Segment Details */}
<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">
{/* Canvas Config */}
<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>
{/* Tracks */}
<div className="space-y-3">
{templateDetail.tracks.map((track) => (
<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>
{/* Segments */}
<div className="space-y-2">
{track.segments.length > 0 ? (
track.segments.map((segment) => (
<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>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
<button
onClick={() => onDelete(template.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>
)
}

View File

@@ -0,0 +1,79 @@
import React from 'react'
import { TemplateCard } from './TemplateCard'
import { TemplateListItem } from './TemplateListItem'
import type { TemplateInfo } from '../../hooks/useProgressCommand'
interface TemplateGridProps {
templates: TemplateInfo[]
viewMode: 'grid' | 'list'
loading: boolean
onView: (template: TemplateInfo) => void
onDelete: (templateId: string) => void
formatDuration: (duration: number) => string
formatDate: (date: string) => string
}
export const TemplateGrid: React.FC<TemplateGridProps> = ({
templates,
viewMode,
loading,
onView,
onDelete,
formatDuration,
formatDate
}) => {
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<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>
)
}
if (templates.length === 0) {
return (
<div className="text-center py-12">
<div className="text-gray-400 mb-4">
<svg className="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2m-9 0h10m-10 0a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V6a2 2 0 00-2-2" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-gray-600">"批量导入"</p>
</div>
)
}
if (viewMode === 'grid') {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{templates.map((template) => (
<TemplateCard
key={template.id}
template={template}
onView={onView}
onDelete={onDelete}
formatDuration={formatDuration}
formatDate={formatDate}
/>
))}
</div>
)
}
return (
<div className="space-y-4">
{templates.map((template) => (
<TemplateListItem
key={template.id}
template={template}
onView={onView}
onDelete={onDelete}
formatDuration={formatDuration}
formatDate={formatDate}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,68 @@
import React from 'react'
import { Eye, Trash2 } from 'lucide-react'
import type { TemplateInfo } from '../../hooks/useProgressCommand'
interface TemplateListItemProps {
template: TemplateInfo
onView: (template: TemplateInfo) => void
onDelete: (templateId: string) => void
formatDuration: (duration: number) => string
formatDate: (date: string) => string
}
export const TemplateListItem: React.FC<TemplateListItemProps> = ({
template,
onView,
onDelete,
formatDuration,
formatDate
}) => {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
{/* Thumbnail */}
<div className="w-16 h-12 bg-gray-100 rounded flex items-center justify-center flex-shrink-0">
{template.thumbnail_path ? (
<img
src={template.thumbnail_path}
alt={template.name}
className="w-full h-full object-cover rounded"
/>
) : (
<Eye size={16} className="text-gray-400" />
)}
</div>
{/* Info */}
<div>
<h3 className="font-medium text-gray-900">{template.name}</h3>
<p className="text-sm text-gray-600">{template.description}</p>
</div>
</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={() => onView(template)}
className="p-2 text-blue-600 hover:text-blue-700"
>
<Eye size={16} />
</button>
<button
onClick={() => onDelete(template.id)}
className="p-2 text-red-600 hover:text-red-700"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,6 @@
export { SearchAndFilters } from './SearchAndFilters'
export { TemplateCard } from './TemplateCard'
export { TemplateListItem } from './TemplateListItem'
export { TemplateGrid } from './TemplateGrid'
export { ImportProgressModal } from './ImportProgressModal'
export { TemplateDetailModal } from './TemplateDetailModal'

View File

@@ -1,9 +1,14 @@
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'
import type { TemplateInfo } from '../hooks/useProgressCommand'
import {
SearchAndFilters,
TemplateGrid,
ImportProgressModal,
TemplateDetailModal
} from '../components/template'
// 轨道和片段的数据结构
interface TrackSegment {
@@ -38,11 +43,8 @@ interface TemplateDetail {
sample_rate?: number
}
// Import the progress interface from the hook
import type { ProgressState, TemplateInfo } from '../hooks/useProgressCommand'
const TemplateManagePage: React.FC = () => {
// State
const [templates, setTemplates] = useState<TemplateInfo[]>([])
const [loading, setLoading] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
@@ -73,6 +75,31 @@ const TemplateManagePage: React.FC = () => {
loadTemplates()
}, [])
// Helper functions
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')}`
}
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')
}
// Filter templates based on search term
const filteredTemplates = templates.filter(template =>
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.description.toLowerCase().includes(searchTerm.toLowerCase())
)
// API functions
const loadTemplates = async () => {
try {
setLoading(true)
@@ -91,18 +118,15 @@ const TemplateManagePage: React.FC = () => {
}
}
// 加载模板详情(包含轨道和片段信息)
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)
@@ -111,11 +135,9 @@ const TemplateManagePage: React.FC = () => {
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) {
@@ -132,6 +154,7 @@ const TemplateManagePage: React.FC = () => {
setTemplates(templates.filter(t => t.id !== templateId))
if (selectedTemplate?.id === templateId) {
setSelectedTemplate(null)
setTemplateDetail(null)
}
} else {
alert('删除失败: ' + result.msg)
@@ -142,539 +165,67 @@ const TemplateManagePage: React.FC = () => {
}
}
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')}`
const handleCloseDetailModal = () => {
setSelectedTemplate(null)
setTemplateDetail(null)
}
// 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')
const handleCloseImportModal = () => {
setShowImportModal(false)
resetImport()
}
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2"></h1>
<h1 className="text-3xl font-bold text-gray-900"></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>
{/* Search and Filters */}
<SearchAndFilters
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
viewMode={viewMode}
setViewMode={setViewMode}
onBatchImport={handleBatchImport}
loading={loading}
/>
<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={() => resetImport()}
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>
)}
{/* Template Grid */}
<TemplateGrid
templates={filteredTemplates}
viewMode={viewMode}
loading={loading}
onView={loadTemplateDetail}
onDelete={handleDeleteTemplate}
formatDuration={formatDuration}
formatDate={formatDate}
/>
{/* 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>
)}
<ImportProgressModal
isOpen={showImportModal}
isExecuting={importing}
progress={importProgress}
result={importResult}
logs={importLogs}
onClose={handleCloseImportModal}
/>
{/* 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>
)}
<TemplateDetailModal
isOpen={!!selectedTemplate}
template={selectedTemplate}
templateDetail={templateDetail}
loadingDetail={loadingDetail}
onClose={handleCloseDetailModal}
onDelete={handleDeleteTemplate}
formatDuration={formatDuration}
formatTime={formatTime}
/>
</div>
</div>
)