import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' import toast from 'react-hot-toast' import { AlertCircle, Ban, CheckCircle2, Clock, Copy, ExternalLink, Eye, Film, Image as ImageIcon, Layers, Loader2, PlayCircle, RefreshCw, Search, Sparkles, Trash2, Tv, X, } from 'lucide-react' import { imageURL } from '../api/client' import { scraperAPI } from '../api/scraper' import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper' import { apiErrorMessage, formatTime, taskStatusMeta } from './StrmManagePage' const FILTERS: { key: 'all' | ScrapeTaskStatus; label: string; icon: typeof Clock; color: string }[] = [ { key: 'all', label: '全部', icon: Sparkles, 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 PROVIDER_LABELS: Record = { tmdb: 'TheMovieDB', douban: '豆瓣 Douban', bangumi: 'Bangumi 番组计划', thetvdb: 'TheTVDB', metatube: 'MetaTube', } const TYPE_ICONS: Record = { movie: , tv: , anime: , adult: , } const TYPE_LABELS: Record = { movie: '电影', tv: '剧集', anime: '动漫', adult: 'Adult', } const PAGE_SIZE = 50 export function ScraperQueuePage() { const [snapshot, setSnapshot] = useState(null) const [filter, setFilter] = useState<'all' | ScrapeTaskStatus>('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 refresh = useCallback( async (showLoading = false) => { if (showLoading) setIsRefreshing(true) try { const status = filter === 'all' ? undefined : filter const data = await scraperAPI.queue(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 */ } finally { setLoading(false) if (showLoading) setIsRefreshing(false) } }, [filter, page], ) useEffect(() => { refresh().catch(() => undefined) }, [refresh]) useEffect(() => { if (!autoRefresh) return const timer = setInterval(() => { refresh().catch(() => undefined) }, 3000) return () => clearInterval(timer) }, [autoRefresh, refresh]) useEffect(() => { setSelectedIds(new Set()) }, [filter, page]) const copyText = (text: string, label: string) => { navigator.clipboard.writeText(text) toast.success(`已复制${label}`) } // Row actions const cancelTask = async (task: ScrapeTask) => { try { await scraperAPI.cancelTask(task.id) toast.success('已取消刮削任务') await refresh() } catch (err) { toast.error(apiErrorMessage(err)) } } const retryTask = async (task: ScrapeTask) => { try { await scraperAPI.retryTask(task.id) toast.success('已重新推入刮削队列') await refresh() } catch (err) { toast.error(apiErrorMessage(err)) } } const deleteTask = async (task: ScrapeTask) => { try { await scraperAPI.deleteTask(task.id) toast.success('已删除刮削记录') if (detailTask?.id === task.id) setDetailTask(null) await refresh() } catch (err) { toast.error(apiErrorMessage(err)) } } // 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 = await scraperAPI.batchAction(action, ids) toast.success(`已成功${actionText} ${res.affected} 项`) setSelectedIds(new Set()) await refresh() } catch (err) { toast.error(apiErrorMessage(err)) } finally { setBatchBusy(false) } } 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) } } // Enqueue all libraries const handleEnqueueAll = async () => { if (!window.confirm('确定将全库所有未匹配或需要更新的媒体重新推入刮削队列?')) return setBatchBusy(true) try { const res = await scraperAPI.enqueueAll({ include_matched: false, refresh_matched: false, episode_images: true }) toast.success(`已将 ${res.enqueued} 个媒体项推入刮削队列`) await refresh() } catch (err) { toast.error(apiErrorMessage(err)) } finally { setBatchBusy(false) } } // Filter and search 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.media_title.toLowerCase().includes(q) || t.matched_title.toLowerCase().includes(q) || t.library_name.toLowerCase().includes(q) || t.media_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 (
{/* 1. Header */}

刮削队列

{autoRefresh && ( 实时同步 )}

媒体元数据在线识别与海报/剧照下载进度(TMDb / 豆瓣 / Bangumi / TheTVDB)

批量操作
{failedCount > 0 && ( )} {activeTaskCount > 0 && ( )}
{/* 2. 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 Actions */}
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 && ( )}
{selectedIds.size > 0 && (
已选中 {selectedIds.size} 项
)}
{/* 4. Table */}
{loading ? (
) : filteredTasks.length === 0 ? (
{search ? '没有找到符合搜索条件的刮削任务' : filter === 'all' ? '刮削队列为空,暂无进行或排队中的任务' : `「${FILTERS.find((f) => f.key === filter)?.label}」状态下暂无任务`}
) : (
{filteredTasks.map((task) => { const status = taskStatusMeta(task.status) const isSelected = selectedIds.has(task.id) return ( {/* Media title & path */} {/* Library */} {/* Scraped matched result */} {/* Provider */} {/* Status & Error */} {/* Time */} {/* Actions */} ) })}
媒体文件 所属媒体库 刮削匹配结果 识别源 状态 时间 操作
toggleSelectRow(task.id)} className="h-3.5 w-3.5 rounded border-gray-300 text-brand-500 focus:ring-brand-400 cursor-pointer" />
{TYPE_ICONS[task.media_type] || }
setDetailTask(task)} className="cursor-pointer truncate font-medium text-ink-600 hover:text-brand-500 hover:underline block" title={task.media_title} > {task.media_title} {task.media_path}
{task.library_name || '媒体库'} {task.matched_title ? (
{task.poster_url ? ( { e.currentTarget.style.display = 'none' }} /> ) : (
)}
{task.matched_title} {task.matched_year > 0 && ( {task.matched_year} 年 )}
) : ( {task.status === 'pending' || task.status === 'running' ? '等待识别…' : '未匹配到结果'} )}
{task.provider ? ( {PROVIDER_LABELS[task.provider] ?? task.provider} ) : ( — )}
{task.status === 'running' && ( )} {task.status === 'done' ? '已匹配' : task.status === 'failed' ? '未匹配' : status.label} {task.error && ( setDetailTask(task)} className="cursor-pointer truncate max-w-[180px] 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 === 'done' || task.status === 'failed' || task.status === 'canceled') && ( )}
)} {/* 5. Pagination */} {(snapshot?.total ?? 0) > 0 && (
共 {snapshot?.total ?? 0} 条 · 第 {page} / {totalPages} 页
)}
{/* 6. Task Detail Modal */} {detailTask && ( setDetailTask(null)} onRetry={retryTask} onCancel={cancelTask} onDelete={deleteTask} onCopy={copyText} /> )}
) } function ScrapeDetailModal({ task, onClose, onRetry, onCancel, onDelete, onCopy, }: { task: ScrapeTask onClose: () => void onRetry: (t: ScrapeTask) => void onCancel: (t: ScrapeTask) => void onDelete: (t: ScrapeTask) => void onCopy: (text: string, label: string) => void }) { const status = taskStatusMeta(task.status) return (
e.stopPropagation()} >

刮削任务详情

{/* Matched Poster / Info Banner */} {task.matched_title ? (
{task.poster_url && ( )}
已匹配 {task.provider && ( {PROVIDER_LABELS[task.provider] ?? task.provider} )}

{task.matched_title}

{task.matched_year > 0 && 年份:{task.matched_year}} 类型:{TYPE_LABELS[task.media_type] ?? task.media_type}
{task.media_id && ( 在媒体详情中查看 )}
) : null} {/* Media Info Box */}
原始媒体标题 {task.media_title}
所属媒体库 {task.library_name}
媒体库类型 {TYPE_LABELS[task.media_type] ?? task.media_type}
当前状态 {task.status === 'done' ? '已匹配' : task.status === 'failed' ? '未匹配' : status.label}
剧照/海报刮削 {task.episode_images ? '开启' : '关闭'}
{/* File path */}
磁盘文件路径
{task.media_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') && ( )}
) }