feat: improve subscription and download cards

This commit is contained in:
ShukeBta
2026-05-28 23:27:34 +08:00
parent f457d0050e
commit c1b5dac784
20 changed files with 1239 additions and 204 deletions
+11 -2
View File
@@ -6,12 +6,21 @@ export interface DownloadsState {
torrents: QBitTorrent[] | null
}
export interface AddDownloadInput {
url: string
save_path?: string
title?: string
poster_url?: string
backdrop_url?: string
overview?: string
}
export const downloadsAPI = {
list: () => api.get<DownloadsState>('/downloads').then((r) => r.data),
add: (url: string, savePath = '') =>
add: (url: string, savePath = '', meta: Omit<AddDownloadInput, 'url' | 'save_path'> = {}) =>
api
.post<DownloadTask>('/downloads', { url, save_path: savePath })
.post<DownloadTask>('/downloads', { url, save_path: savePath, ...meta })
.then((r) => r.data),
remove: (hash: string, deleteFiles = false) =>
+5
View File
@@ -14,11 +14,16 @@ export const subscriptionsAPI = {
save_path?: string
search_mode?: string
imdb_id?: string
source?: string
poster_url?: string
backdrop_url?: string
overview?: string
resolution?: string
quality?: string
effects?: string
release_groups?: string
exclude_words?: string
wash_enabled?: boolean
wash_priority?: string
priority?: number
enabled?: boolean
+4
View File
@@ -232,6 +232,10 @@ export function AIAssistantPage() {
feed_url: feed,
filter: keyword,
media_type: item.media_type,
source: item.source,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url,
overview: item.overview,
enabled: true,
})
const run = await subscriptionsAPI.runNow(sub.id)
+11 -1
View File
@@ -247,6 +247,7 @@ function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: (
effects: '',
release_groups: '',
exclude_words: 'cam,ts,tc,枪版',
wash_enabled: false,
wash_priority: 'balanced',
save_path: '',
media_category: '',
@@ -269,11 +270,16 @@ function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: (
save_path: form.save_path || undefined,
search_mode: form.search_mode,
imdb_id: form.imdb_id || undefined,
source,
poster_url: item.poster_url || undefined,
backdrop_url: item.backdrop_url || undefined,
overview: item.overview || undefined,
resolution: form.resolution === 'best' ? 'best' : form.resolution,
quality: form.quality || undefined,
effects: form.effects || undefined,
release_groups: form.release_groups || undefined,
exclude_words: form.exclude_words || undefined,
wash_enabled: form.wash_enabled,
wash_priority: form.wash_priority,
priority: form.priority,
enabled: true,
@@ -389,7 +395,7 @@ function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: (
</label>
<label className="text-xs text-sand-500">
洗版优先级
<select className="input-base mt-1" value={form.wash_priority} onChange={(e) => setForm((f) => ({ ...f, wash_priority: e.target.value }))}>
<select className="input-base mt-1 disabled:opacity-50" disabled={!form.wash_enabled} value={form.wash_priority} onChange={(e) => setForm((f) => ({ ...f, wash_priority: e.target.value }))}>
<option value="balanced">均衡</option>
<option value="resolution">分辨率优先</option>
<option value="quality">片源质量优先</option>
@@ -397,6 +403,10 @@ function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: (
<option value="seeders">做种数优先</option>
</select>
</label>
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs text-ink-100">
<input type="checkbox" checked={form.wash_enabled} onChange={(e) => setForm((f) => ({ ...f, wash_enabled: e.target.checked }))} />
启用洗版择优
</label>
<label className="text-xs text-sand-500">
发布组
<input className="input-base mt-1" placeholder="如 FRDS,OurTV" value={form.release_groups} onChange={(e) => setForm((f) => ({ ...f, release_groups: e.target.value }))} />
+236 -94
View File
@@ -1,12 +1,33 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Download, Trash2 } from 'lucide-react'
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 type { DownloadTask, QBitTorrent } from '../types'
function fmtBytes(n: number): string {
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
@@ -15,14 +36,178 @@ function fmtBytes(n: number): string {
v /= 1024
i++
}
return `${v.toFixed(2)} ${u[i]}`
return `${v.toFixed(v >= 100 ? 0 : 1)} ${u[i]}`
}
function fmtSpeed(n: number): string {
function fmtSpeed(n?: number): string {
return `${fmtBytes(n)}/s`
}
// DownloadsPage: shows running torrents (live) + persisted task rows.
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<void>
}) {
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 (
<article className="group overflow-hidden rounded-3xl border border-white/70 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-xl">
<div className="relative flex gap-4 p-4">
<div className="relative h-40 w-28 flex-shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-primary-400/15 via-white to-surface-200 shadow-inner">
{visual ? (
<img
src={imageURL(visual)}
alt={item.title}
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-3 text-center text-xs font-semibold text-brand-500">
<Film size={24} />
<span className="line-clamp-3">{item.title}</span>
</div>
)}
<span className={`absolute left-2 top-2 rounded-full px-2 py-0.5 text-[10px] font-semibold ${statusTone(item)}`}>
{stateLabel(item)}
</span>
</div>
<div className="min-w-0 flex-1 space-y-3">
<div>
<h2 className="line-clamp-2 font-display text-lg font-semibold leading-snug text-ink-600" title={item.title}>
{item.title}
</h2>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-ink-50">
{item.overview || item.save_path || '已隐藏原始种子 URL,避免泄露私有 Token。'}
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs font-semibold text-ink-100">
<span>进度 {progress.toFixed(1)}%</span>
<span>{fmtBytes(downloaded)} / {fmtBytes(item.size)}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-gradient-to-r from-primary-400 to-brand-500 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-ink-100">
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<ArrowDown size={13} className="mr-1 inline text-brand-500" />
{fmtSpeed(item.dlspeed)}
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<ArrowUp size={13} className="mr-1 inline text-brand-500" />
{fmtSpeed(item.upspeed)}
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<Rss size={13} className="mr-1 inline text-brand-500" />
{item.num_seeds ?? 0} / {item.num_leechs ?? 0}
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<HardDrive size={13} className="mr-1 inline text-brand-500" />
{fmtBytes(item.size)}
</div>
</div>
<div className="flex items-center justify-between gap-2 text-xs text-ink-50">
<span className="truncate" title={item.save_path || ''}>
{item.save_path || '默认下载目录'}
</span>
{item.created_at && <span>{new Date(item.created_at).toLocaleString()}</span>}
</div>
</div>
</div>
{removable && onRemove && (
<div className="flex justify-end border-t border-gray-100 bg-gray-50/70 px-4 py-3">
<button
className="rounded-xl border border-red-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-red-400 hover:bg-red-400/10"
onClick={() => void onRemove()}
>
<Trash2 size={13} className="mr-1 inline" />
删除任务
</button>
</div>
)}
</article>
)
}
export function DownloadsPage() {
const role = useAuthStore((s) => s.user?.role)
const [tasks, setTasks] = useState<DownloadTask[]>([])
@@ -60,13 +245,19 @@ export function DownloadsPage() {
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-ink-600">下载</h1>
<header className="space-y-2">
<h1 className="font-display text-3xl font-bold text-ink-600">下载管理</h1>
<p className="flex items-center gap-2 text-sm text-ink-50">
<ShieldCheck size={16} className="text-brand-500" />
页面仅展示安全标题、海报和进度信息,不再暴露种子原始 URL 或私有 Token。
</p>
</header>
<form onSubmit={onAdd} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
<input
required
className="input-base md:col-span-2"
placeholder="磁力链接 / .torrent URL"
placeholder="磁力链接 / .torrent URL(提交后不会在页面公开显示)"
value={url}
onChange={(e) => setURL(e.target.value)}
/>
@@ -81,103 +272,54 @@ export function DownloadsPage() {
</button>
</form>
<section className="glass-panel">
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">实时状态</h2>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-display text-xl font-semibold text-ink-600">当前下载</h2>
<span className="text-xs text-ink-50">每 5 秒自动刷新</span>
</div>
{torrents === null && (
<p className="text-sand-500">
<div className="glass-panel text-sand-500">
尚未连接到下载器 — 请到{' '}
<a href="/download-clients" className="text-brand-500 hover:underline">
下载器
</a>{' '}
页面添加并测试连接(qBittorrent / Aria2 / Transmission)。
</p>
页面添加并测试连接。
</div>
)}
{torrents && torrents.length === 0 && (
<div className="glass-panel text-sand-500">暂无运行中任务。</div>
)}
{torrents && torrents.length === 0 && <p className="text-sand-500">暂无运行中任务。</p>}
{torrents && torrents.length > 0 && (
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">名称</th>
<th>状态</th>
<th>进度</th>
<th>速度</th>
<th>体积</th>
{role === 'admin' && <th />}
</tr>
</thead>
<tbody>
{torrents.map((t) => (
<tr key={t.hash} className="border-t border-gray-200 align-top">
<td className="max-w-md truncate py-2 text-ink-600" title={t.name}>
{t.name}
</td>
<td className="text-ink-100">{t.state}</td>
<td className="text-ink-100">
<div className="flex items-center gap-2">
<div className="h-1 w-24 overflow-hidden rounded-lg bg-gray-200">
<div
className="h-full bg-primary-400"
style={{ width: `${Math.round(t.progress * 100)}%` }}
/>
</div>
{(t.progress * 100).toFixed(1)}%
</div>
</td>
<td className="text-ink-100">
↓ {fmtSpeed(t.dlspeed)} / ↑ {fmtSpeed(t.upspeed)}
</td>
<td className="text-ink-100">{fmtBytes(t.size)}</td>
{role === 'admin' && (
<td className="py-2 text-right">
<button
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
onClick={async () => {
if (!confirm(`删除「${t.name}」?`)) return
await downloadsAPI.remove(t.hash, false)
toast.success('已删除任务')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
<div className="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
{torrents.map((torrent) => (
<DownloadCard
key={torrent.hash}
item={toLiveCard(torrent)}
removable={role === 'admin'}
onRemove={async () => {
if (!confirm(`删除「${torrent.title || torrent.name}」?`)) return
await downloadsAPI.remove(torrent.hash, false)
toast.success('已删除任务')
await refresh()
}}
/>
))}
</div>
)}
</section>
{tasks.length > 0 && (
<section className="glass-panel">
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">历史记录</h2>
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">来源</th>
<th>URL</th>
<th>保存路径</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{tasks.map((t) => (
<tr key={t.id} className="border-t border-gray-200">
<td className="py-2 text-ink-100">{t.source}</td>
<td className="max-w-md truncate text-ink-100" title={t.url}>
{t.url}
</td>
<td className="text-ink-100">{t.save_path || '—'}</td>
<td className="text-sand-500">
{new Date(t.created_at).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</section>
)}
<section className="space-y-3">
<h2 className="font-display text-xl font-semibold text-ink-600">下载历史</h2>
{tasks.length === 0 ? (
<div className="glass-panel text-sand-500">暂无历史下载。</div>
) : (
<div className="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
{tasks.map((task) => (
<DownloadCard key={task.id} item={toTaskCard(task)} />
))}
</div>
)}
</section>
</div>
)
}
+4
View File
@@ -186,6 +186,10 @@ export function SearchPage() {
feed_url: feed,
filter: keyword,
media_type: item.media_type,
source: item.source,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url,
overview: item.overview,
enabled: true,
})
const run = await subscriptionsAPI.runNow(sub.id)
+1 -1
View File
@@ -53,7 +53,7 @@ export function SiteSearchPage() {
return
}
try {
await downloadsAPI.add(url)
await downloadsAPI.add(url, '', { title: item.title })
toast.success(`已加入下载: ${item.title.substring(0, 40)}...`)
} catch (err: unknown) {
const msg =
+135 -66
View File
@@ -1,8 +1,9 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Pencil, Play, Plus, Save, Trash2 } from 'lucide-react'
import { CalendarClock, Film, Pencil, Play, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react'
import { subscriptionsAPI } from '../api/subscriptions'
import { imageURL } from '../api/client'
import type { Subscription } from '../types'
export function SubscriptionsPage() {
@@ -20,6 +21,7 @@ export function SubscriptionsPage() {
const [effects, setEffects] = useState('')
const [releaseGroups, setReleaseGroups] = useState('')
const [excludeWords, setExcludeWords] = useState('cam,ts,tc,枪版')
const [washEnabled, setWashEnabled] = useState(false)
const [washPriority, setWashPriority] = useState('balanced')
const [editingId, setEditingId] = useState('')
const [loading, setLoading] = useState(true)
@@ -51,6 +53,7 @@ export function SubscriptionsPage() {
effects: effects || undefined,
release_groups: releaseGroups || undefined,
exclude_words: excludeWords || undefined,
wash_enabled: washEnabled,
wash_priority: washPriority,
priority: 50,
}
@@ -85,6 +88,7 @@ export function SubscriptionsPage() {
setEffects('')
setReleaseGroups('')
setExcludeWords('cam,ts,tc,枪版')
setWashEnabled(false)
setWashPriority('balanced')
}
@@ -103,6 +107,7 @@ export function SubscriptionsPage() {
setEffects(s.effects || '')
setReleaseGroups(s.release_groups || '')
setExcludeWords(s.exclude_words || '')
setWashEnabled(Boolean(s.wash_enabled))
setWashPriority(s.wash_priority || 'balanced')
window.scrollTo({ top: 0, behavior: 'smooth' })
}
@@ -173,7 +178,11 @@ export function SubscriptionsPage() {
<option value="hdtv">HDTV</option>
</select>
<input className="input-base" placeholder="特效/音轨 hdr,dolby-vision,atmos" value={effects} onChange={(e) => setEffects(e.target.value)} />
<select className="input-base" value={washPriority} onChange={(e) => setWashPriority(e.target.value)}>
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-ink-100">
<input type="checkbox" checked={washEnabled} onChange={(e) => setWashEnabled(e.target.checked)} />
启用洗版择优
</label>
<select className="input-base disabled:opacity-50" disabled={!washEnabled} value={washPriority} onChange={(e) => setWashPriority(e.target.value)}>
<option value="balanced">洗版:均衡</option>
<option value="resolution">洗版:分辨率优先</option>
<option value="quality">洗版:片源质量优先</option>
@@ -197,72 +206,132 @@ export function SubscriptionsPage() {
{!loading && items.length === 0 && <p className="text-ink-50">暂无订阅。</p>}
{items.length > 0 && (
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">名称</th>
<th>RSS</th>
<th>过滤器</th>
<th>分类</th>
<th>规则</th>
<th>最近运行</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{items.map((s) => (
<tr key={s.id} className="border-t border-gray-200">
<td className="py-2 text-ink-600">{s.name}</td>
<td className="max-w-md truncate text-ink-100" title={s.feed_url}>
{s.feed_url}
</td>
<td className="text-ink-100">{s.filter || '—'}</td>
<td className="text-ink-100">
{[s.media_type, s.media_category].filter(Boolean).join(' / ') || '自动'}
</td>
<td className="max-w-xs text-xs text-ink-100">
{[s.search_mode === 'imdb' ? `IMDB:${s.imdb_id || '未填'}` : '', s.resolution, s.quality, s.effects, s.wash_priority]
.filter(Boolean)
.join(' · ') || '默认'}
</td>
<td className="text-sand-500">
{s.last_run_at ? new Date(s.last_run_at).toLocaleString() : '—'}
</td>
<td className="space-x-2 py-2 text-right">
<button
className="rounded-lg border border-gray-300 px-2 py-1 text-xs text-ink-100 hover:bg-gray-50"
onClick={() => startEdit(s)}
>
<Pencil size={12} />
</button>
<button
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={async () => {
const r = await subscriptionsAPI.runNow(s.id)
toast.success(`已加入 ${r.queued} 项`)
}}
>
<Play size={12} />
</button>
<button
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
onClick={async () => {
if (!confirm(`删除订阅「${s.name}」?`)) return
await subscriptionsAPI.remove(s.id)
toast.success('已删除')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{items.map((subscription) => (
<article
key={subscription.id}
className="group overflow-hidden rounded-3xl border border-white/70 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-xl"
>
<div className="relative flex gap-4 p-4">
<div className="relative h-36 w-24 flex-shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-primary-400/15 to-surface-200 shadow-inner">
{subscription.poster_url ? (
<img
src={imageURL(subscription.poster_url)}
alt={subscription.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-2 text-center text-xs font-semibold text-brand-500">
<Film size={22} />
<span className="line-clamp-3">{subscription.name}</span>
</div>
)}
</div>
<div className="min-w-0 flex-1 space-y-3">
<div>
<div className="mb-1 flex flex-wrap gap-1.5">
<span className="rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-brand-500">
{subscription.source || 'RSS'}
</span>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-semibold text-sand-500">
{[subscription.media_type, subscription.media_category].filter(Boolean).join(' / ') || '自动分类'}
</span>
<span className={'rounded-full px-2 py-0.5 text-[10px] font-semibold ' + (subscription.enabled ? 'bg-emerald-50 text-emerald-600' : 'bg-gray-100 text-gray-500')}>
{subscription.enabled ? '启用中' : '已停用'}
</span>
</div>
<h2 className="truncate font-display text-lg font-semibold text-ink-600" title={subscription.name}>
{subscription.name}
</h2>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-ink-50">
{subscription.overview || subscription.filter || '已隐藏订阅源地址,避免多用户场景泄露私有 RSS Token。'}
</p>
</div>
<div className="space-y-1.5 text-xs text-ink-100">
<div className="flex items-center gap-1.5">
<ShieldCheck size={13} className="text-brand-500" />
<span>订阅源已脱敏</span>
</div>
<div className="flex items-center gap-1.5">
<CalendarClock size={13} className="text-brand-500" />
<span>{subscription.last_run_at ? new Date(subscription.last_run_at).toLocaleString() : '尚未运行'}</span>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{subscriptionRuleBadges(subscription).map((label) => (
<span key={label} className="rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] text-ink-100">
{label}
</span>
))}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-gray-100 bg-gray-50/70 px-4 py-3">
<button
className="rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 hover:bg-gray-50"
onClick={() => startEdit(subscription)}
>
<Pencil size={13} className="mr-1 inline" />
编辑
</button>
<button
className="rounded-xl border border-primary-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-primary-400/10"
onClick={async () => {
const result = await subscriptionsAPI.runNow(subscription.id)
toast.success(`已加入 ${result.queued} 项`)
}}
>
<Play size={13} className="mr-1 inline" />
运行
</button>
<button
className="rounded-xl border border-red-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-red-400 hover:bg-red-400/10"
onClick={async () => {
if (!confirm(`删除订阅「${subscription.name}」?`)) return
await subscriptionsAPI.remove(subscription.id)
toast.success('已删除')
await refresh()
}}
>
<Trash2 size={13} className="mr-1 inline" />
删除
</button>
</div>
</article>
))}
</div>
)}
</div>
)
}
function subscriptionRuleBadges(subscription: Subscription): string[] {
const labels = [
subscription.search_mode === 'imdb' ? `IMDB ${subscription.imdb_id || '未填'}` : '关键词搜索',
subscription.resolution && subscription.resolution !== 'best' ? subscription.resolution : '分辨率不限',
subscription.quality || '质量不限',
subscription.effects || '',
subscription.release_groups ? `发布组 ${subscription.release_groups}` : '',
subscription.wash_enabled ? `洗版 ${washPriorityLabel(subscription.wash_priority)}` : '未启用洗版',
]
return labels.filter(Boolean)
}
function washPriorityLabel(priority?: string): string {
switch (priority) {
case 'resolution':
return '分辨率优先'
case 'quality':
return '片源质量优先'
case 'effects':
return '特效优先'
case 'seeders':
return '做种数优先'
default:
return '均衡'
}
}
+21 -2
View File
@@ -178,11 +178,16 @@ export interface Subscription {
save_path?: string
search_mode?: string
imdb_id?: string
source?: string
poster_url?: string
backdrop_url?: string
overview?: string
resolution?: string
quality?: string
effects?: string
release_groups?: string
exclude_words?: string
wash_enabled?: boolean
wash_priority?: string
priority?: number
enabled: boolean
@@ -193,12 +198,21 @@ export interface Subscription {
export interface DownloadTask {
id: string
user_id: string
source: string
url: string
title: string
poster_url?: string
backdrop_url?: string
overview?: string
save_path: string
status: string
progress: number
state?: string
dlspeed?: number
upspeed?: number
num_seeds?: number
num_leechs?: number
size?: number
downloaded?: number
created_at: string
updated_at: string
}
@@ -206,6 +220,10 @@ export interface DownloadTask {
export interface QBitTorrent {
hash: string
name: string
title: string
poster_url?: string
backdrop_url?: string
overview?: string
state: string
progress: number
dlspeed: number
@@ -213,6 +231,7 @@ export interface QBitTorrent {
num_seeds: number
num_leechs: number
size: number
downloaded: number
save_path: string
}