import { FormEvent, useEffect, useState } from 'react' import { Link } from 'react-router-dom' import toast from 'react-hot-toast' import { ArrowDown, ArrowUp, Download, Film, HardDrive, Rss, ShieldCheck, Trash2 } from 'lucide-react' import { imageURL } from '../api/client' import { downloadsAPI } from '../api/downloads' import { useAuthStore } from '../stores/auth' import { confirmAction } from '../components/ConfirmDialog' import type { DownloadTask, QBitTorrent } from '../types' type DownloadCardItem = { id?: string hash?: string title: string poster_url?: string backdrop_url?: string overview?: string save_path?: string status?: string state?: string progress: number dlspeed?: number upspeed?: number num_seeds?: number num_leechs?: number size?: number downloaded?: number created_at?: string } function fmtBytes(n?: number): string { if (!n || n <= 0) return '0 B' const u = ['B', 'KB', 'MB', 'GB', 'TB'] let v = n let i = 0 while (v >= 1024 && i < u.length - 1) { v /= 1024 i++ } return `${v.toFixed(v >= 100 ? 0 : 1)} ${u[i]}` } function fmtSpeed(n?: number): string { return `${fmtBytes(n)}/s` } function pct(progress?: number): number { if (!Number.isFinite(progress)) return 0 return Math.min(100, Math.max(0, Math.round((progress ?? 0) * 1000) / 10)) } function stateLabel(item: DownloadCardItem): string { const state = (item.state || item.status || 'queued').toLowerCase() if (state.includes('down') || state.includes('meta')) return '下载中' if (state.includes('up') || state.includes('seed')) return '做种中' if (state.includes('pause')) return '已暂停' if (state.includes('error')) return '出错' if (state.includes('complete') || pct(item.progress) >= 100) return '已完成' if (state.includes('queue')) return '排队中' return item.state || item.status || '等待中' } function statusTone(item: DownloadCardItem): string { const state = stateLabel(item) if (state === '已完成' || state === '做种中') return 'bg-emerald-50 text-emerald-600' if (state === '出错') return 'bg-red-50 text-red-500' if (state === '已暂停') return 'bg-amber-50 text-amber-600' return 'bg-primary-400/10 text-brand-500' } function toLiveCard(t: QBitTorrent): DownloadCardItem { return { hash: t.hash, title: t.title || t.name || '下载任务', poster_url: t.poster_url, backdrop_url: t.backdrop_url, overview: t.overview, save_path: t.save_path, state: t.state, progress: t.progress, dlspeed: t.dlspeed, upspeed: t.upspeed, num_seeds: t.num_seeds, num_leechs: t.num_leechs, size: t.size, downloaded: t.downloaded, } } function toTaskCard(t: DownloadTask): DownloadCardItem { return { id: t.id, title: t.title || '下载任务', poster_url: t.poster_url, backdrop_url: t.backdrop_url, overview: t.overview, save_path: t.save_path, status: t.status, state: t.state, progress: t.progress, dlspeed: t.dlspeed, upspeed: t.upspeed, num_seeds: t.num_seeds, num_leechs: t.num_leechs, size: t.size, downloaded: t.downloaded, created_at: t.created_at, } } function DownloadCard({ item, removable, onRemove, }: { item: DownloadCardItem removable?: boolean onRemove?: () => Promise }) { const progress = pct(item.progress) const visual = item.poster_url || item.backdrop_url const downloaded = item.downloaded || (item.size ? Math.round(item.size * (item.progress || 0)) : 0) return (
{visual ? ( {item.title} ) : (
{item.title}
)} {stateLabel(item)}

{item.title}

{item.overview || item.save_path || '已隐藏原始种子 URL,避免泄露私有 Token。'}

进度 {progress.toFixed(1)}% {fmtBytes(downloaded)} / {fmtBytes(item.size)}
{fmtSpeed(item.dlspeed)}
{fmtSpeed(item.upspeed)}
{item.num_seeds ?? 0} / {item.num_leechs ?? 0}
{fmtBytes(item.size)}
{item.save_path || '默认下载目录'} {item.created_at && {new Date(item.created_at).toLocaleString()}}
{removable && onRemove && (
)}
) } export function DownloadsPage() { const role = useAuthStore((s) => s.user?.role) const [tasks, setTasks] = useState([]) const [torrents, setTorrents] = useState(null) const [url, setURL] = useState('') const [savePath, setSavePath] = useState('') const refresh = () => downloadsAPI.list().then((d) => { setTasks(d.tasks) setTorrents(d.torrents) }) useEffect(() => { void refresh().catch(() => undefined) const id = window.setInterval(() => void refresh().catch(() => undefined), 5_000) return () => window.clearInterval(id) }, []) const onAdd = async (e: FormEvent) => { e.preventDefault() try { await downloadsAPI.add(url, savePath) toast.success('已加入下载队列') setURL('') setSavePath('') await refresh() } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '提交失败' toast.error(msg) } } return (

下载管理

页面仅展示安全标题、海报和进度信息,不再暴露种子原始 URL 或私有 Token。

setURL(e.target.value)} /> setSavePath(e.target.value)} />

当前下载

每 5 秒自动刷新
{torrents === null && (
尚未连接到下载器 —{' '} {role === 'admin' ? ( <> 请到{' '} 下载器 {' '} 页面添加并测试连接。 ) : ( '请联系管理员添加并测试下载器连接。' )}
)} {torrents && torrents.length === 0 && (
暂无运行中任务。
)} {torrents && torrents.length > 0 && (
{torrents.map((torrent) => ( { if (!(await confirmAction({ title: '删除下载任务', message: `删除「${torrent.title || torrent.name}」?`, confirmText: '删除' }))) return await downloadsAPI.remove(torrent.hash, false) toast.success('已删除任务') await refresh() }} /> ))}
)}

下载历史

{tasks.length === 0 ? (
暂无历史下载。
) : (
{tasks.map((task) => ( ))}
)}
) }