import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import toast from 'react-hot-toast' import { AlertCircle, Ban, CheckCircle2, Clock, 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' import { PROVIDER_LABELS, ScrapeDetailModal } from './scraper-queue/ScrapeDetailModal' import { copyToClipboard, useTaskSelection } from './queue-shared' 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 TYPE_ICONS: Record = { movie: , tv: , anime: , adult: , } const PAGE_SIZE = 50 export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) { 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, reset: clearSelection, toggleRow: toggleSelectRow, toggleAll: toggleAllIds } = useTaskSelection() const [detailTask, setDetailTask] = useState(null) // 轮询/翻页/切筛选并发时用递增序号丢弃过期响应 const refreshSeqRef = useRef(0) const [pollFailures, setPollFailures] = useState(0) const refresh = useCallback( async (showLoading = false) => { if (showLoading) setIsRefreshing(true) const seq = ++refreshSeqRef.current try { const status = filter === 'all' ? undefined : filter const data = await scraperAPI.queue(status, page, PAGE_SIZE) // 序号不符说明已有更新的请求发出(翻页/切筛选/轮询并发),丢弃旧响应 if (seq !== refreshSeqRef.current) return setPollFailures(0) 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 { // 保留旧数据;连续失败 ≥3 次时页头徽标切换为「连接失败,重试中」 if (seq === refreshSeqRef.current) setPollFailures((n) => n + 1) } finally { setLoading(false) if (showLoading) setIsRefreshing(false) } }, [filter, page], ) useEffect(() => { refresh().catch(() => undefined) }, [refresh]) useEffect(() => { if (!autoRefresh) return const timer = setInterval(() => { if (document.hidden) return refresh().catch(() => undefined) }, 3000) return () => clearInterval(timer) }, [autoRefresh, refresh]) useEffect(() => { clearSelection() }, [filter, page, clearSelection]) const copyText = copyToClipboard // 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 pendingCount = counts?.pending ?? 0 const runningCount = counts?.running ?? 0 const activeTaskCount = pendingCount + runningCount const doneCount = counts?.done ?? 0 const failedCount = counts?.failed ?? 0 const canceledCount = counts?.canceled ?? 0 const finishedCount = doneCount + failedCount + canceledCount const allCurrentChecked = filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id)) const toggleSelectAll = () => toggleAllIds(filteredTasks.map((t) => t.id)) return (
{!embedded && (

刮削队列

{autoRefresh && (pollFailures >= 3 ? ( 连接失败,重试中 ) : ( 实时同步 ))}

媒体元数据在线识别与海报/剧照下载进度(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} 项
) : (
{/* 当前状态专属快捷批量按钮 */} {filter === 'all' && ( <> {failedCount > 0 && ( )} {activeTaskCount > 0 && ( )} {finishedCount > 0 && ( )} )} {(filter === 'pending' || filter === 'running') && ( )} {filter === 'done' && ( )} {filter === 'failed' && ( <> )} {filter === 'canceled' && ( )} {/* 下拉批量操作菜单:随时可做任意全局操作 */}
批量清理
{failedCount > 0 && ( )} {activeTaskCount > 0 && ( )}
)}
{/* 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} /> )}
) }