import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import toast from 'react-hot-toast' import { AlertCircle, Ban, CheckCircle2, Clock, Copy, Download, Eye, File, FileText, Film, Image as ImageIcon, Loader2, MessageSquare, PlayCircle, RefreshCw, Search, Trash2, Upload, X, } from 'lucide-react' import { strmAPI } from '../api/strm' import type { StrmQueueSnapshot, StrmTask, StrmTaskStatus } from '../types/strm' import { STRM_PROVIDER_LABELS } from '../types/strm' import { apiErrorMessage, formatBytes, formatTime, taskStatusMeta } from './StrmManagePage' const FILTERS: { key: 'all' | StrmTaskStatus; label: string; icon: typeof Clock; color: string }[] = [ { key: 'all', label: '全部', icon: Clock, color: 'text-ink-600' }, { key: 'pending', label: '排队中', icon: Clock, color: 'text-gray-500' }, { key: 'running', label: '进行中', icon: PlayCircle, color: 'text-brand-500' }, { key: 'done', label: '已完成', icon: CheckCircle2, color: 'text-emerald-500' }, { key: 'failed', label: '失败', icon: AlertCircle, color: 'text-rose-500' }, { key: 'canceled', label: '已取消', icon: Ban, color: 'text-amber-500' }, ] const PAGE_SIZE = 50 function getFileIcon(filename: string): ReactNode { const ext = filename.split('.').pop()?.toLowerCase() ?? '' if (['nfo', 'txt', 'xml', 'json'].includes(ext)) { return } if (['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'svg'].includes(ext)) { return } if (['srt', 'ass', 'ssa', 'sub', 'vtt'].includes(ext)) { return } if (['mkv', 'mp4', 'avi', 'mov', 'wmv', 'ts', 'flv', 'iso', 'm4v', 'strm'].includes(ext)) { return } return } export function StrmQueuePanel({ kind, embedded = false, }: { kind: 'download' | 'upload' embedded?: boolean }) { const [snapshot, setSnapshot] = useState(null) const [filter, setFilter] = useState<'all' | StrmTaskStatus>('all') const [search, setSearch] = useState('') const [page, setPage] = useState(1) const [totalPages, setTotalPages] = useState(1) const [loading, setLoading] = useState(true) const [isRefreshing, setIsRefreshing] = useState(false) const [autoRefresh, setAutoRefresh] = useState(true) const [batchBusy, setBatchBusy] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) const [detailTask, setDetailTask] = useState(null) const isDownload = kind === 'download' const Icon = isDownload ? Download : Upload const refresh = useCallback( async (showLoading = false) => { if (showLoading) setIsRefreshing(true) try { const status = filter === 'all' ? undefined : filter const data = isDownload ? await strmAPI.downloads(status, page, PAGE_SIZE) : await strmAPI.uploads(status, page, PAGE_SIZE) const tp = Math.max(1, Math.ceil((data.total ?? data.tasks.length) / PAGE_SIZE)) if (page > tp) { setPage(tp) return } setTotalPages(tp) setSnapshot(data) } catch { /* keep existing data */ } finally { setLoading(false) if (showLoading) setIsRefreshing(false) } }, [isDownload, filter, page], ) useEffect(() => { refresh().catch(() => undefined) }, [refresh]) useEffect(() => { if (!autoRefresh) return const timer = setInterval(() => { refresh().catch(() => undefined) }, 3000) return () => clearInterval(timer) }, [autoRefresh, refresh]) // Clear selections when changing filter or page useEffect(() => { setSelectedIds(new Set()) }, [filter, page]) const copyText = (text: string, label: string) => { navigator.clipboard.writeText(text) toast.success(`已复制${label}`) } // Task actions const cancelTask = async (task: StrmTask) => { try { if (isDownload) await strmAPI.cancelDownload(task.id) else await strmAPI.cancelUpload(task.id) toast.success('已取消任务') await refresh() } catch (err) { toast.error(apiErrorMessage(err)) } } const retryTask = async (task: StrmTask) => { try { if (isDownload) await strmAPI.retryDownload(task.id) else await strmAPI.retryUpload(task.id) toast.success('已重新入队') await refresh() } catch (err) { toast.error(apiErrorMessage(err)) } } const deleteTask = async (task: StrmTask) => { try { if (isDownload) await strmAPI.deleteDownload(task.id) else await strmAPI.deleteUpload(task.id) toast.success('已删除记录') if (detailTask?.id === task.id) setDetailTask(null) await refresh() } catch (err) { toast.error(apiErrorMessage(err)) } } // Selected batch actions const runSelectedBatch = async (action: 'retry' | 'cancel' | 'delete') => { const ids = Array.from(selectedIds) if (ids.length === 0) return const actionText = action === 'retry' ? '重试' : action === 'cancel' ? '取消' : '删除' if (action === 'delete' && !window.confirm(`确定删除选中的 ${ids.length} 条记录?`)) return if (action === 'cancel' && !window.confirm(`确定取消选中的 ${ids.length} 个任务?`)) return setBatchBusy(true) try { const res = isDownload ? await strmAPI.batchActionDownloads(action, ids) : await strmAPI.batchActionUploads(action, ids) toast.success(`已成功${actionText} ${res.affected} 项`) setSelectedIds(new Set()) await refresh() } catch (err) { toast.error(apiErrorMessage(err)) } finally { setBatchBusy(false) } } // Global batch actions const runGlobalBatch = async ( action: () => Promise<{ deleted?: number; retried?: number; canceled?: number }>, confirmMsg?: string, ) => { if (confirmMsg && !window.confirm(confirmMsg)) return setBatchBusy(true) try { const res = await action() if (res.deleted !== undefined) toast.success(`已清空 ${res.deleted} 条记录`) else if (res.retried !== undefined) toast.success(`已重新入队 ${res.retried} 个任务`) else if (res.canceled !== undefined) toast.success(`已取消 ${res.canceled} 个任务`) await refresh() } catch (err) { toast.error(apiErrorMessage(err)) } finally { setBatchBusy(false) } } // Filter and search tasks in memory const tasks = snapshot?.tasks ?? [] const filteredTasks = useMemo(() => { let list = tasks if (filter !== 'all') { list = list.filter((t) => t.status === filter) } if (search.trim()) { const q = search.trim().toLowerCase() list = list.filter( (t) => t.file_name.toLowerCase().includes(q) || t.local_path.toLowerCase().includes(q) || t.remote_path.toLowerCase().includes(q) || (t.error && t.error.toLowerCase().includes(q)), ) } return list }, [tasks, filter, search]) const counts = snapshot?.counts const activeTaskCount = (counts?.pending ?? 0) + (counts?.running ?? 0) const failedCount = counts?.failed ?? 0 const allCurrentChecked = filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id)) const toggleSelectAll = () => { if (allCurrentChecked) { setSelectedIds(new Set()) } else { setSelectedIds(new Set(filteredTasks.map((t) => t.id))) } } const toggleSelectRow = (id: string) => { setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } return (
{!embedded && (

{isDownload ? '下载队列' : '上传队列'}

{autoRefresh && ( 实时同步 )}

{isDownload ? 'STRM 媒体元数据下载队列(远端网盘 → 本地存储目录)' : 'STRM 媒体元数据上传队列(本地存储目录 → 远端网盘)'}

{/* Global actions */}
{/* Quick Clean Actions */}
清理与重试
{failedCount > 0 && ( )} {activeTaskCount > 0 && ( )}
)} {/* 2. Interactive Status Cards */}
{FILTERS.map((item) => { const count = item.key === 'all' ? (counts?.pending ?? 0) + (counts?.running ?? 0) + (counts?.done ?? 0) + (counts?.failed ?? 0) + (counts?.canceled ?? 0) : counts?.[item.key] ?? 0 const isActive = filter === item.key const ItemIcon = item.icon return ( ) })}
{/* 3. Search & Batch Toolbar */}
{/* Search input */}
setSearch(e.target.value)} placeholder="搜索文件名、本地/远端路径或错误信息…" className="h-9 w-full rounded-xl border border-gray-200 bg-white pl-9 pr-8 text-xs text-ink-600 placeholder:text-gray-400 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100/60" /> {search && ( )}
{/* Selected Batch Toolbar */} {selectedIds.size > 0 && (
已选中 {selectedIds.size} 项
)}
{/* 4. Task Table */}
{loading ? (
) : filteredTasks.length === 0 ? (
{search ? '没有找到符合搜索条件的任务' : filter === 'all' ? isDownload ? '暂无元数据下载任务' : '暂无元数据上传任务' : `「${FILTERS.find((f) => f.key === filter)?.label}」状态下暂无任务`}
) : (
{filteredTasks.map((task) => { const status = taskStatusMeta(task.status) const isSelected = selectedIds.has(task.id) return ( ) })}
文件名称 提供方 {isDownload ? '本地目标目录' : '本地来源'} 大小 状态 创建时间 操作
toggleSelectRow(task.id)} className="h-3.5 w-3.5 rounded border-gray-300 text-brand-500 focus:ring-brand-400 cursor-pointer" />
{getFileIcon(task.file_name)} setDetailTask(task)} className="cursor-pointer truncate font-medium text-ink-600 hover:text-brand-500 hover:underline" title={task.file_name} > {task.file_name}
{STRM_PROVIDER_LABELS[task.provider] ?? task.provider} {task.retry_count > 0 && ( 重试 {task.retry_count} )}
{task.local_path}
{formatBytes(task.size)}
{task.status === 'running' && ( )} {status.label} {task.error && ( setDetailTask(task)} className="cursor-pointer truncate max-w-[200px] text-[10px] text-rose-500 hover:underline" title={task.error} > {task.error} )}
{formatTime(task.created_at)}
{(task.status === 'pending' || task.status === 'running') && ( )} {(task.status === 'failed' || task.status === 'canceled') && ( )} {(task.status === 'done' || task.status === 'failed' || task.status === 'canceled') && ( )}
)} {/* 5. Pagination */} {(snapshot?.total ?? 0) > 0 && (
共 {snapshot?.total ?? 0} 条 · 第 {page} / {totalPages} 页
)}
{/* 6. Task Detail Dialog */} {detailTask && ( setDetailTask(null)} onRetry={retryTask} onCancel={cancelTask} onDelete={deleteTask} onCopy={copyText} /> )}
) } function TaskDetailModal({ task, isDownload, onClose, onRetry, onCancel, onDelete, onCopy, }: { task: StrmTask isDownload: boolean onClose: () => void onRetry: (t: StrmTask) => void onCancel: (t: StrmTask) => void onDelete: (t: StrmTask) => void onCopy: (text: string, label: string) => void }) { const status = taskStatusMeta(task.status) return (
e.stopPropagation()} >
{getFileIcon(task.file_name)}

任务详情

{/* Main Info Box */}
任务 ID {task.id}
文件名称 {task.file_name}
文件大小 {formatBytes(task.size)}
当前状态 {status.label}
云盘提供方 {STRM_PROVIDER_LABELS[task.provider] ?? task.provider}
{task.retry_count > 0 && (
已重试次数 {task.retry_count} 次
)}
{/* Paths */}
{isDownload ? '本地输出目标路径' : '本地来源路径'}
{task.local_path}
远端网盘路径
{task.remote_path}
{/* Error Message Box */} {task.error && (
错误详情
{task.error}
)} {/* Timeline */}
创建时间:{formatTime(task.created_at)}
{task.started_at &&
开始时间:{formatTime(task.started_at)}
} {task.finished_at &&
结束时间:{formatTime(task.finished_at)}
}
{/* Footer Actions */}
{(task.status === 'done' || task.status === 'failed' || task.status === 'canceled') && ( )}
{(task.status === 'pending' || task.status === 'running') && ( )} {(task.status === 'failed' || task.status === 'canceled') && ( )}
) } export function StrmDownloadQueuePage() { return } export function StrmUploadQueuePage() { return }