mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
优化
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
// 115 云下载 API。
|
||||
import { LONG_REQUEST_TIMEOUT, api } from './client'
|
||||
import type {
|
||||
CloudDownloadList,
|
||||
CloudDownloadQuota,
|
||||
CloudDownloadSubmitResult,
|
||||
} from '../types/cloudDownload'
|
||||
|
||||
export interface CloudDownloadListParams {
|
||||
status?: string
|
||||
sync_path_id?: string
|
||||
account_id?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export const cloudDownloadAPI = {
|
||||
list: (params: CloudDownloadListParams = {}) =>
|
||||
api.get<CloudDownloadList>('/admin/cloud-downloads', { params }).then((r) => r.data),
|
||||
|
||||
/** 提交一批链接(磁力 / ed2k / http / ftp)。部分失败也在 200 的 items 里返回。 */
|
||||
submit: (payload: { sync_path_id: string; urls: string[] }) =>
|
||||
api
|
||||
.post<CloudDownloadSubmitResult>('/admin/cloud-downloads', payload, { timeout: LONG_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data),
|
||||
|
||||
/** 上传 .torrent 并提交为 BT 任务。 */
|
||||
submitTorrent: (file: File, syncPathID: string) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('sync_path_id', syncPathID)
|
||||
return api
|
||||
.post<CloudDownloadSubmitResult>('/admin/cloud-downloads/torrent', form, {
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
.then((r) => r.data)
|
||||
},
|
||||
|
||||
quota: (accountID: string) =>
|
||||
api
|
||||
.get<CloudDownloadQuota>('/admin/cloud-downloads/quota', { params: { account_id: accountID } })
|
||||
.then((r) => r.data),
|
||||
|
||||
retry: (id: string) => api.post(`/admin/cloud-downloads/${id}/retry`).then((r) => r.data),
|
||||
|
||||
retryPipeline: (id: string) =>
|
||||
api.post(`/admin/cloud-downloads/${id}/retry-pipeline`).then((r) => r.data),
|
||||
|
||||
cancel: (id: string) => api.post(`/admin/cloud-downloads/${id}/cancel`).then((r) => r.data),
|
||||
|
||||
remove: (id: string) => api.delete(`/admin/cloud-downloads/${id}`).then((r) => r.data),
|
||||
|
||||
clearFinished: () =>
|
||||
api.post<{ deleted: number }>('/admin/cloud-downloads/clear-finished').then((r) => r.data)
|
||||
}
|
||||
@@ -48,6 +48,9 @@ const StrmDownloadQueuePage = lazy(() =>
|
||||
const StrmUploadQueuePage = lazy(() =>
|
||||
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmUploadQueuePage })),
|
||||
)
|
||||
const CloudDownloadPage = lazy(() =>
|
||||
import('./pages/CloudDownloadPage').then((m) => ({ default: m.CloudDownloadPage })),
|
||||
)
|
||||
const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })))
|
||||
const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage })))
|
||||
const MediaQualityPage = lazy(() => import('./pages/MediaQualityPage').then((m) => ({ default: m.MediaQualityPage })))
|
||||
@@ -125,6 +128,7 @@ export const appRoutes: AppRoute[] = [
|
||||
{ path: 'strm', element: <StrmManagePage />, adminOnly: true },
|
||||
{ path: 'strm/downloads', element: <StrmDownloadQueuePage />, adminOnly: true },
|
||||
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
|
||||
{ path: 'cloud-downloads', element: <CloudDownloadPage />, adminOnly: true },
|
||||
{ path: 'scraper/queue', element: <ScraperQueuePage />, adminOnly: true },
|
||||
{ path: 'queue', element: <TaskQueuePage />, adminOnly: true },
|
||||
{ path: 'quality', element: <MediaQualityPage />, adminOnly: true },
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
CloudDownload,
|
||||
ListChecks as SetupIcon,
|
||||
FileOutput,
|
||||
FolderOpen,
|
||||
@@ -49,6 +50,7 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
||||
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
||||
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
|
||||
{ to: '/strm', label: 'STRM 管理', icon: FileOutput, adminOnly: true },
|
||||
{ to: '/cloud-downloads', label: '云下载', icon: CloudDownload, adminOnly: true },
|
||||
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
// 115 云下载页面:提交磁力/ed2k/HTTP/.torrent → 115 离线下载 → 自动 STRM 同步 →
|
||||
// 自动扫描 → 自动刮削。链路各阶段在任务列表里可见。
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
CloudDownload,
|
||||
FileUp,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { cloudDownloadAPI } from '../api/cloudDownload'
|
||||
import { strmAPI } from '../api/strm'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import type { StrmSyncPath } from '../types/strm'
|
||||
import type {
|
||||
CloudDownloadQuota,
|
||||
CloudDownloadSubmitResult,
|
||||
CloudDownloadTask,
|
||||
} from '../types/cloudDownload'
|
||||
import { apiErrorMessage, formatBytes, formatTime } from './StrmManagePage'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
// 阶段与状态合并成一句人话,避免用户对着 status/stage 两个字段猜。
|
||||
function taskPhase(task: CloudDownloadTask): { label: string; cls: string; spinner?: boolean } {
|
||||
if (task.status === 'failed') return { label: '下载失败', cls: 'bg-rose-100 text-rose-600' }
|
||||
if (task.status === 'canceled') return { label: '已取消', cls: 'bg-gray-100 text-gray-500' }
|
||||
if (task.status === 'queued' || task.status === 'submitted') {
|
||||
return { label: '等待 115 受理', cls: 'bg-amber-100 text-amber-600', spinner: true }
|
||||
}
|
||||
if (task.status === 'downloading') {
|
||||
return { label: `下载中 ${task.percent ?? 0}%`, cls: 'bg-brand-100 text-brand-600', spinner: true }
|
||||
}
|
||||
// status = succeeded:看后处理阶段
|
||||
switch (task.stage) {
|
||||
case 'waiting_sync':
|
||||
return { label: '等待同步', cls: 'bg-sky-100 text-sky-600', spinner: true }
|
||||
case 'syncing':
|
||||
return { label: 'STRM 同步中', cls: 'bg-sky-100 text-sky-600', spinner: true }
|
||||
case 'scanning':
|
||||
return { label: '扫描媒体库', cls: 'bg-indigo-100 text-indigo-600', spinner: true }
|
||||
case 'scraping':
|
||||
return { label: '刮削中', cls: 'bg-violet-100 text-violet-600', spinner: true }
|
||||
case 'done':
|
||||
return { label: '完成', cls: 'bg-emerald-100 text-emerald-600' }
|
||||
case 'failed':
|
||||
return { label: '后处理失败', cls: 'bg-rose-100 text-rose-600' }
|
||||
case 'na':
|
||||
return { label: '未接入媒体库', cls: 'bg-amber-100 text-amber-600' }
|
||||
default:
|
||||
return { label: '待后处理', cls: 'bg-sky-100 text-sky-600', spinner: true }
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(task: CloudDownloadTask): string {
|
||||
if (task.source_type === 'torrent') return task.torrent_name || task.source_url || '.torrent'
|
||||
return task.source_url
|
||||
}
|
||||
|
||||
export function CloudDownloadPage() {
|
||||
const [paths, setPaths] = useState<StrmSyncPath[]>([])
|
||||
const [syncPathID, setSyncPathID] = useState('')
|
||||
const [urls, setUrls] = useState('')
|
||||
const [torrentFile, setTorrentFile] = useState<File | null>(null)
|
||||
const [quota, setQuota] = useState<CloudDownloadQuota | null>(null)
|
||||
const [quotaError, setQuotaError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [lastResult, setLastResult] = useState<CloudDownloadSubmitResult | null>(null)
|
||||
|
||||
const [tasks, setTasks] = useState<CloudDownloadTask[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [busyID, setBusyID] = useState('')
|
||||
const refreshSeq = useRef(0)
|
||||
|
||||
const targets = useMemo(
|
||||
() => paths.filter((p) => p.provider === 'cloud115' && p.enabled),
|
||||
[paths],
|
||||
)
|
||||
const selectedPath = useMemo(
|
||||
() => targets.find((p) => p.id === syncPathID) ?? null,
|
||||
[targets, syncPathID],
|
||||
)
|
||||
|
||||
const loadPaths = useCallback(async () => {
|
||||
try {
|
||||
const list = await strmAPI.listPaths()
|
||||
setPaths(list)
|
||||
setSyncPathID((prev) => {
|
||||
if (prev && list.some((p) => p.id === prev)) return prev
|
||||
const first = list.find((p) => p.provider === 'cloud115' && p.enabled)
|
||||
return first?.id ?? ''
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadTasks = useCallback(
|
||||
async (quiet = false) => {
|
||||
const seq = ++refreshSeq.current
|
||||
if (!quiet) setLoading(true)
|
||||
try {
|
||||
const data = await cloudDownloadAPI.list({
|
||||
status: statusFilter || undefined,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
})
|
||||
if (seq !== refreshSeq.current) return
|
||||
setTasks(data.items ?? [])
|
||||
setTotal(data.total ?? 0)
|
||||
} catch (err) {
|
||||
if (!quiet) toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
if (seq === refreshSeq.current) setLoading(false)
|
||||
}
|
||||
},
|
||||
[page, statusFilter],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void loadPaths()
|
||||
}, [loadPaths])
|
||||
|
||||
useEffect(() => {
|
||||
void loadTasks()
|
||||
}, [loadTasks])
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return
|
||||
const timer = window.setInterval(() => {
|
||||
void loadTasks(true)
|
||||
}, 5000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [autoRefresh, loadTasks])
|
||||
|
||||
// 切换目标目录时刷新配额(配额按账号统计)。
|
||||
useEffect(() => {
|
||||
setQuota(null)
|
||||
setQuotaError('')
|
||||
if (!selectedPath?.account_id) return
|
||||
let cancelled = false
|
||||
cloudDownloadAPI
|
||||
.quota(selectedPath.account_id)
|
||||
.then((q) => {
|
||||
if (!cancelled) setQuota(q)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setQuotaError(apiErrorMessage(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedPath?.account_id])
|
||||
|
||||
const submitLinks = async () => {
|
||||
if (!syncPathID) {
|
||||
toast.error('请选择目标同步目录')
|
||||
return
|
||||
}
|
||||
const lines = urls
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
if (lines.length === 0) {
|
||||
toast.error('请粘贴至少一条链接')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await cloudDownloadAPI.submit({ sync_path_id: syncPathID, urls: lines })
|
||||
setLastResult(res)
|
||||
if (res.accepted > 0) {
|
||||
toast.success(`已提交 ${res.accepted} 条任务`)
|
||||
setUrls('')
|
||||
} else {
|
||||
toast.error('没有任务被 115 受理,请查看下方明细')
|
||||
}
|
||||
await loadTasks(true)
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submitTorrent = async () => {
|
||||
if (!syncPathID) {
|
||||
toast.error('请选择目标同步目录')
|
||||
return
|
||||
}
|
||||
if (!torrentFile) {
|
||||
toast.error('请选择 .torrent 文件')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await cloudDownloadAPI.submitTorrent(torrentFile, syncPathID)
|
||||
setLastResult(res)
|
||||
toast.success('种子已提交')
|
||||
setTorrentFile(null)
|
||||
await loadTasks(true)
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const act = async (id: string, fn: () => Promise<unknown>, okText: string) => {
|
||||
setBusyID(id)
|
||||
try {
|
||||
await fn()
|
||||
toast.success(okText)
|
||||
await loadTasks(true)
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setBusyID('')
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6 px-4 py-6">
|
||||
<PageHeader
|
||||
title="云下载"
|
||||
description="提交磁力 / ed2k / 直链或 .torrent,115 离线完成后自动同步 STRM、扫描媒体库并刮削"
|
||||
backTo="/strm"
|
||||
backLabel="STRM 管理"
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadTasks()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-ink-600 hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 提交面板 */}
|
||||
<section className="space-y-4 rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-ink-50">目标同步目录(115)</span>
|
||||
<select
|
||||
value={syncPathID}
|
||||
onChange={(e) => setSyncPathID(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">请选择…</option>
|
||||
{targets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{p.remote_display_path ? ` · ${p.remote_display_path}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-ink-50">云下载配额</span>
|
||||
<div className="flex h-[38px] items-center gap-2 rounded-lg border border-gray-100 bg-gray-50 px-3 text-sm text-ink-600">
|
||||
{quota ? (
|
||||
<>
|
||||
<span className="font-semibold text-ink-600">{quota.surplus}</span>
|
||||
<span className="text-ink-50">/ {quota.count} 剩余</span>
|
||||
{quota.surplus <= 0 ? (
|
||||
<span className="ml-auto text-xs text-rose-500">配额已用尽</span>
|
||||
) : null}
|
||||
</>
|
||||
) : quotaError ? (
|
||||
<span className="truncate text-xs text-amber-600">{quotaError}</span>
|
||||
) : (
|
||||
<span className="text-xs text-ink-50">选择目录后加载</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-ink-50">
|
||||
链接(每行一条,支持磁力 / ed2k / http(s) / ftp,单批最多 115 条)
|
||||
</span>
|
||||
<textarea
|
||||
value={urls}
|
||||
onChange={(e) => setUrls(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={'magnet:?xt=urn:btih:…\ned2k://|file|…\nhttps://example.com/file.mkv'}
|
||||
className="w-full resize-y rounded-lg border border-gray-200 px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting || !syncPathID}
|
||||
onClick={() => void submitLinks()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-500 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />}
|
||||
提交链接
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-ink-50">或</span>
|
||||
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-2 text-sm text-ink-600 hover:bg-gray-50">
|
||||
<FileUp className="h-4 w-4" />
|
||||
{torrentFile ? torrentFile.name : '选择 .torrent'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".torrent"
|
||||
className="hidden"
|
||||
onChange={(e) => setTorrentFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting || !syncPathID || !torrentFile}
|
||||
onClick={() => void submitTorrent()}
|
||||
className="rounded-lg border border-brand-200 px-3 py-2 text-sm text-brand-600 disabled:opacity-40"
|
||||
>
|
||||
上传种子
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-ink-50">
|
||||
说明:115 开放平台的磁力链路不支持选择部分文件,磁力任务会整包下载;.torrent 目前也按全选提交。
|
||||
</p>
|
||||
|
||||
{lastResult ? (
|
||||
<div className="space-y-1 rounded-lg bg-gray-50 p-3 text-xs">
|
||||
<div className="font-medium text-ink-600">
|
||||
成功 {lastResult.accepted} 条,失败 {lastResult.rejected} 条
|
||||
</div>
|
||||
{lastResult.items?.map((item, idx) => (
|
||||
<div key={`${item.source}-${idx}`} className="flex gap-2">
|
||||
{item.ok ? (
|
||||
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-rose-500" />
|
||||
)}
|
||||
<span className="break-all text-ink-600">{item.source}</span>
|
||||
{item.error ? <span className="text-rose-500">{item.error}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
{lastResult.skipped?.map((item, idx) => (
|
||||
<div key={`skip-${idx}`} className="flex gap-2 text-ink-50">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<span className="break-all">{item.raw}</span>
|
||||
<span>{item.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<section className="space-y-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="submitted">已提交</option>
|
||||
<option value="downloading">下载中</option>
|
||||
<option value="succeeded">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="canceled">已取消</option>
|
||||
</select>
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-ink-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
/>
|
||||
自动刷新
|
||||
</label>
|
||||
<span className="ml-auto text-xs text-ink-50">共 {total} 条</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void act('__clear__', () => cloudDownloadAPI.clearFinished(), '已清空失败/取消任务')
|
||||
}
|
||||
className="rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs text-ink-600 hover:bg-gray-50"
|
||||
>
|
||||
清空失败/已取消
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[860px] text-left text-sm">
|
||||
<thead className="text-xs text-ink-50">
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="py-2 pr-3 font-medium">来源</th>
|
||||
<th className="py-2 pr-3 font-medium">目标目录</th>
|
||||
<th className="py-2 pr-3 font-medium">大小</th>
|
||||
<th className="py-2 pr-3 font-medium">状态</th>
|
||||
<th className="py-2 pr-3 font-medium">提交时间</th>
|
||||
<th className="py-2 pr-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-sm text-ink-50">
|
||||
{loading ? '加载中…' : '暂无任务'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tasks.map((task) => {
|
||||
const phase = taskPhase(task)
|
||||
const busy = busyID === task.id
|
||||
const terminal =
|
||||
task.status === 'succeeded' ||
|
||||
task.status === 'failed' ||
|
||||
task.status === 'canceled'
|
||||
return (
|
||||
<tr key={task.id} className="border-b border-gray-50 align-top">
|
||||
<td className="max-w-[320px] py-2.5 pr-3">
|
||||
<div className="truncate text-ink-600" title={sourceLabel(task)}>
|
||||
{sourceLabel(task)}
|
||||
</div>
|
||||
<div className="text-xs text-ink-50">
|
||||
{task.source_type.toUpperCase()}
|
||||
{task.info_hash ? ` · ${task.info_hash.slice(0, 12)}…` : ''}
|
||||
</div>
|
||||
{task.error ? (
|
||||
<div className="mt-0.5 text-xs text-rose-500">{task.error}</div>
|
||||
) : null}
|
||||
{task.stage_error ? (
|
||||
<div className="mt-0.5 text-xs text-amber-600">{task.stage_error}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-xs text-ink-600">
|
||||
<div>{task.sync_path_name || task.sync_path_id.slice(0, 8)}</div>
|
||||
{task.remote_display_path ? (
|
||||
<div className="text-ink-50">{task.remote_display_path}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-xs text-ink-600">
|
||||
{task.size_bytes ? formatBytes(task.size_bytes) : '-'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs ${phase.cls}`}
|
||||
>
|
||||
{phase.spinner ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||||
{phase.label}
|
||||
</span>
|
||||
{task.stage === 'done' ? (
|
||||
<div className="mt-0.5 text-[11px] text-ink-50">
|
||||
新增 {task.scan_added} · 刮削 {task.scrape_queued}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-xs text-ink-50">{formatTime(task.created_at)}</td>
|
||||
<td className="py-2.5 pr-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{task.status === 'failed' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(task.id, () => cloudDownloadAPI.retry(task.id), '已重新提交')
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-ink-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
重试
|
||||
</button>
|
||||
) : null}
|
||||
{(task.stage === 'failed' || task.stage === 'na') ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(
|
||||
task.id,
|
||||
() => cloudDownloadAPI.retryPipeline(task.id),
|
||||
'已重新推进后处理',
|
||||
)
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-ink-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
重做后处理
|
||||
</button>
|
||||
) : null}
|
||||
{!terminal ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(task.id, () => cloudDownloadAPI.cancel(task.id), '已取消')
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-amber-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
取消
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(task.id, () => cloudDownloadAPI.remove(task.id), '已删除')
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-rose-500 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<div className="flex items-center justify-end gap-2 text-xs text-ink-50">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
className="rounded-md border border-gray-200 px-2 py-1 disabled:opacity-40"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
className="rounded-md border border-gray-200 px-2 py-1 disabled:opacity-40"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
Ban,
|
||||
Cloud,
|
||||
CloudDownload,
|
||||
Download,
|
||||
FolderPlus,
|
||||
HardDrive,
|
||||
@@ -206,6 +208,10 @@ export function StrmManagePage() {
|
||||
<FolderPlus size={16} />
|
||||
添加同步目录
|
||||
</button>
|
||||
<Link to="/cloud-downloads" className="neon-button">
|
||||
<CloudDownload size={16} />
|
||||
云下载
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// 115 云下载(离线下载)相关类型定义(与后端 model.CloudDownloadTask 对应)。
|
||||
|
||||
export type CloudDownloadStatus =
|
||||
| 'queued'
|
||||
| 'submitted'
|
||||
| 'downloading'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'canceled'
|
||||
|
||||
export type CloudDownloadStage =
|
||||
| 'pending'
|
||||
| 'waiting_sync'
|
||||
| 'syncing'
|
||||
| 'scanning'
|
||||
| 'scraping'
|
||||
| 'done'
|
||||
| 'na'
|
||||
| 'failed'
|
||||
|
||||
export type CloudDownloadSourceType = 'magnet' | 'torrent' | 'ed2k' | 'http' | 'ftp'
|
||||
|
||||
export interface CloudDownloadTask {
|
||||
id: string
|
||||
account_id: string
|
||||
sync_path_id: string
|
||||
library_id?: string
|
||||
provider: string
|
||||
source_type: CloudDownloadSourceType
|
||||
source_url: string
|
||||
source_key: string
|
||||
info_hash?: string
|
||||
torrent_name?: string
|
||||
torrent_sha1?: string
|
||||
pick_code?: string
|
||||
wp_path_id: string
|
||||
save_path?: string
|
||||
status: CloudDownloadStatus
|
||||
percent: number
|
||||
size_bytes: number
|
||||
error: string
|
||||
stage: CloudDownloadStage
|
||||
stage_error: string
|
||||
strm_record_id?: string
|
||||
scan_added: number
|
||||
scrape_queued: number
|
||||
retry_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
completed_at?: string | null
|
||||
// 列表接口补充的同步目录信息
|
||||
sync_path_name?: string
|
||||
remote_display_path?: string
|
||||
local_path?: string
|
||||
}
|
||||
|
||||
export interface CloudDownloadList {
|
||||
items: CloudDownloadTask[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface CloudDownloadSubmitItem {
|
||||
task_id?: string
|
||||
source: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface CloudDownloadSkipped {
|
||||
raw: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface CloudDownloadSubmitResult {
|
||||
accepted: number
|
||||
rejected: number
|
||||
items: CloudDownloadSubmitItem[]
|
||||
skipped?: CloudDownloadSkipped[]
|
||||
}
|
||||
|
||||
export interface CloudDownloadQuotaPackage {
|
||||
surplus: number
|
||||
used: number
|
||||
count: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface CloudDownloadQuota {
|
||||
package?: CloudDownloadQuotaPackage[]
|
||||
count: number
|
||||
surplus: number
|
||||
used: number
|
||||
}
|
||||
Reference in New Issue
Block a user