- 下载面板:下载盘只显示「已用 x / 总量」与进度条,暂停线 / 在途 / 预留 / 可清理 / 做种保留收进详情; 阈值文案统一「暂停线 / 恢复线 / 下载盘满暂停」 - RSS 页去掉与站点页重复的站点表,节标题「RSS 地址」;用途只在非通用时显示 - 订阅:一条都没有时不铺筛选;表单里「空 = 24;0 = 关」等等式改成 placeholder + 一句说明 - 设置:浏览器服务、元数据识别、MCP 说明去掉组件名与实现细节;会话目录与 fake 脚本只在开发构建显示; API Key scope 前加中文名;命名模板变量去掉「MP 无」 - 待处理顶部计数卡改成一行筛选按钮 - 智能体悬浮按钮只在没有侧栏的窄屏出现,抽屉打开时不再显示「收起」胶囊 Co-Authored-By: Claude Opus 5.5 <[email protected]>
469 lines
17 KiB
TypeScript
469 lines
17 KiB
TypeScript
import type { SubscriptionGroup, SubscriptionOverviewItem, SubscriptionSort } from "@umt/contracts";
|
|
import {
|
|
CalendarClock,
|
|
CalendarDays,
|
|
ChevronDown,
|
|
LayoutGrid,
|
|
List,
|
|
ListChecks,
|
|
Pause,
|
|
Play,
|
|
Plus,
|
|
Search,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { Link } from "react-router";
|
|
import { useSiteOptions } from "~/api/instances.ts";
|
|
import { useRuleGroups } from "~/api/rules.ts";
|
|
import { type OverviewFilters, useSubscriptionOverview } from "~/api/subscriptions.ts";
|
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
|
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
|
import { consolePath } from "~/components/console/nav.ts";
|
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
|
import { useQueryState } from "~/components/console/use-query-state.ts";
|
|
import { opts, SUB_ORIGIN } from "~/components/library/labels.ts";
|
|
import { DeleteSubscriptionsDialog, useSubscriptionOps } from "~/components/subscriptions/actions.tsx";
|
|
import { SubscriptionCalendarView } from "~/components/subscriptions/calendar.tsx";
|
|
import { GROUP_LABEL, GROUP_ORDER, SUB_KIND_LABEL } from "~/components/subscriptions/model.ts";
|
|
import { SubscriptionCard, SubscriptionRow } from "~/components/subscriptions/overview-item.tsx";
|
|
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { cn } from "~/lib/utils.ts";
|
|
import { legacyRedirectLoader } from "./redirects.ts";
|
|
|
|
// 订阅总览(FE-5,docs/design/subscriptions.md §1):类型页签 → 五组分段(计数来自 counts)→ 卡片 / 列表。
|
|
// 筛选、分组、视图都进 URL;组内按上次更新倒序(服务端 sort=lastUpdated)。
|
|
|
|
export const meta = () => [{ title: "订阅 · UHD 媒体自动化控制台" }];
|
|
|
|
// 旧的 ?tab=policies 跳到收录策略
|
|
export const clientLoader = legacyRedirectLoader;
|
|
|
|
const SORT_LABEL: Record<SubscriptionSort, string> = {
|
|
lastUpdated: "上次更新",
|
|
nextSearch: "下次搜索",
|
|
missing: "缺集数",
|
|
created: "添加时间",
|
|
name: "名称",
|
|
};
|
|
|
|
const VIEW_KEY = "umt.subscriptions.view";
|
|
|
|
function useDebounced(v: string, ms = 300) {
|
|
const [d, setD] = useState(v);
|
|
useEffect(() => {
|
|
const t = setTimeout(() => setD(v), ms);
|
|
return () => clearTimeout(t);
|
|
}, [v, ms]);
|
|
return d;
|
|
}
|
|
|
|
function Pills<T extends string>({
|
|
label,
|
|
value,
|
|
onChange,
|
|
items,
|
|
}: {
|
|
label: string;
|
|
value: T | "";
|
|
onChange: (v: T | "") => void;
|
|
items: { value: T | ""; label: string; count?: number }[];
|
|
}) {
|
|
return (
|
|
<div role="tablist" aria-label={label} className="flex min-w-0 gap-1 overflow-x-auto scroll-bleed">
|
|
{items.map((it) => (
|
|
<button
|
|
key={it.value || "all"}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={value === it.value}
|
|
onClick={() => onChange(it.value)}
|
|
className={cn(
|
|
"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-sm font-medium whitespace-nowrap transition-colors duration-micro",
|
|
value === it.value
|
|
? "pill-selected text-foreground"
|
|
: "border-transparent text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
|
)}
|
|
>
|
|
{it.label}
|
|
{it.count !== undefined ? (
|
|
<span className="text-meta text-muted-foreground tabular-nums">{it.count}</span>
|
|
) : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function SubscriptionsPage() {
|
|
const f = useQueryState(["kind", "group", "q", "site", "rg", "origin", "up", "sort", "view", "month"] as const);
|
|
const v = f.values;
|
|
const [q, setQ] = useState(v.q);
|
|
const dq = useDebounced(q.trim());
|
|
useEffect(() => {
|
|
if (dq !== v.q) f.set("q", dq);
|
|
}, [dq, v.q, f.set]);
|
|
const [view, setViewState] = useState<"card" | "list">("card");
|
|
useEffect(() => {
|
|
try {
|
|
if (localStorage.getItem(VIEW_KEY) === "list") setViewState("list");
|
|
} catch {
|
|
/* 无存储时用卡片 */
|
|
}
|
|
}, []);
|
|
const calendar = v.view === "calendar";
|
|
const setView = (x: "card" | "list") => {
|
|
if (calendar) f.set("view", "");
|
|
setViewState(x);
|
|
try {
|
|
localStorage.setItem(VIEW_KEY, x);
|
|
} catch {
|
|
/* 忽略 */
|
|
}
|
|
};
|
|
const [creating, setCreating] = useState(false);
|
|
const [editing, setEditing] = useState<SubscriptionOverviewItem | null>(null);
|
|
const [selecting, setSelecting] = useState(false);
|
|
const [sel, setSel] = useState<Set<string>>(new Set());
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
const [collapsed, setCollapsed] = useState<Set<SubscriptionGroup>>(new Set());
|
|
const ops = useSubscriptionOps();
|
|
|
|
const filters: OverviewFilters = {
|
|
mediaKind: (v.kind || undefined) as OverviewFilters["mediaKind"],
|
|
group: (v.group || undefined) as OverviewFilters["group"],
|
|
q: v.q || undefined,
|
|
siteId: v.site || undefined,
|
|
ruleGroupId: v.rg || undefined,
|
|
origin: (v.origin || undefined) as OverviewFilters["origin"],
|
|
upgrading: (v.up || undefined) as OverviewFilters["upgrading"],
|
|
sort: (v.sort || undefined) as OverviewFilters["sort"],
|
|
};
|
|
const query = useSubscriptionOverview(filters);
|
|
const items = useMemo(() => query.data?.pages.flatMap((p) => p.items) ?? [], [query.data]);
|
|
const first = query.data?.pages[0];
|
|
const counts = first?.counts;
|
|
const allCount = counts ? GROUP_ORDER.reduce((n, g) => n + (counts[g] ?? 0), 0) : undefined;
|
|
const sites = useSiteOptions();
|
|
const groups = useRuleGroups({});
|
|
const filtered = !!(v.q || v.site || v.rg || v.origin || v.up);
|
|
const grouped = !v.group && !v.sort;
|
|
|
|
const toggle = (id: string, on: boolean) =>
|
|
setSel((s) => {
|
|
const n = new Set(s);
|
|
if (on) n.add(id);
|
|
else n.delete(id);
|
|
return n;
|
|
});
|
|
const selIds = [...sel].filter((id) => items.some((x) => x.id === id));
|
|
const exitSelect = () => {
|
|
setSelecting(false);
|
|
setSel(new Set());
|
|
};
|
|
|
|
const renderItems = (list: SubscriptionOverviewItem[]) =>
|
|
view === "card" ? (
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
|
{list.map((x) => (
|
|
<SubscriptionCard
|
|
key={x.id}
|
|
x={x}
|
|
onEdit={() => setEditing(x)}
|
|
selected={sel.has(x.id)}
|
|
onSelect={selecting ? (on) => toggle(x.id, on) : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<ul className="pg-slab divide-y divide-border/60 overflow-hidden rounded-xl">
|
|
{list.map((x) => (
|
|
<SubscriptionRow
|
|
key={x.id}
|
|
x={x}
|
|
onEdit={() => setEditing(x)}
|
|
selected={sel.has(x.id)}
|
|
onSelect={selecting ? (on) => toggle(x.id, on) : undefined}
|
|
/>
|
|
))}
|
|
</ul>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="订阅"
|
|
actions={
|
|
<>
|
|
<Button asChild variant="ghost" size="sm">
|
|
<Link to={consolePath("rules")}>
|
|
<ListChecks />
|
|
规则
|
|
</Link>
|
|
</Button>
|
|
<Button onClick={() => setCreating(true)}>
|
|
<Plus />
|
|
新建订阅
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
|
<Pills
|
|
label="类型"
|
|
value={v.kind as "" | "series" | "movie" | "variety"}
|
|
onChange={(x) => f.set("kind", x)}
|
|
items={[
|
|
{ value: "", label: "全部" },
|
|
{ value: "series", label: SUB_KIND_LABEL.series },
|
|
{ value: "movie", label: SUB_KIND_LABEL.movie },
|
|
{ value: "variety", label: SUB_KIND_LABEL.variety },
|
|
]}
|
|
/>
|
|
<div className="flex items-center gap-1">
|
|
{calendar ? null : (
|
|
<Button
|
|
variant={selecting ? "secondary" : "ghost"}
|
|
size="sm"
|
|
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
|
|
>
|
|
{selecting ? <X /> : null}
|
|
{selecting ? "退出批量" : "批量"}
|
|
</Button>
|
|
)}
|
|
<fieldset className="m-0 flex rounded-full border border-border p-0.5" aria-label="视图">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="卡片视图"
|
|
aria-pressed={!calendar && view === "card"}
|
|
className={cn("rounded-full", !calendar && view === "card" && "bg-foreground/[0.08]")}
|
|
onClick={() => setView("card")}
|
|
>
|
|
<LayoutGrid />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="列表视图"
|
|
aria-pressed={!calendar && view === "list"}
|
|
className={cn("rounded-full", !calendar && view === "list" && "bg-foreground/[0.08]")}
|
|
onClick={() => setView("list")}
|
|
>
|
|
<List />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="日历"
|
|
aria-pressed={calendar}
|
|
className={cn("rounded-full", calendar && "bg-foreground/[0.08]")}
|
|
onClick={() => f.set("view", "calendar")}
|
|
>
|
|
<CalendarDays />
|
|
</Button>
|
|
</fieldset>
|
|
</div>
|
|
</div>
|
|
{calendar ? (
|
|
<SubscriptionCalendarView
|
|
month={v.month}
|
|
onMonth={(m) => f.set("month", m)}
|
|
mediaKind={(v.kind || undefined) as "series" | "movie" | "variety" | undefined}
|
|
/>
|
|
) : (
|
|
<>
|
|
{/* 一条订阅都没有时只留空态和「新建订阅」,不铺筛选 */}
|
|
{allCount === 0 && !filtered && !v.group && !v.kind ? null : (
|
|
<>
|
|
<div className="mb-3">
|
|
<Pills
|
|
label="分组"
|
|
value={v.group as SubscriptionGroup | ""}
|
|
onChange={(x) => f.set("group", x)}
|
|
items={[
|
|
{ value: "", label: "全部", count: allCount },
|
|
...GROUP_ORDER.map((g) => ({ value: g, label: GROUP_LABEL[g], count: counts?.[g] })),
|
|
]}
|
|
/>
|
|
</div>
|
|
<FilterBar
|
|
search={{ value: q, onChange: setQ, placeholder: "作品标题 / 别名 / 检索词" }}
|
|
onReset={
|
|
filtered
|
|
? () => {
|
|
setQ("");
|
|
for (const k of ["q", "site", "rg", "origin", "up"] as const) f.set(k, "");
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
<FilterSelect
|
|
label="站点"
|
|
value={v.site}
|
|
onChange={(x) => f.set("site", x)}
|
|
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
|
|
/>
|
|
<FilterSelect
|
|
label="规则组"
|
|
value={v.rg}
|
|
onChange={(x) => f.set("rg", x)}
|
|
options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))}
|
|
/>
|
|
<FilterSelect
|
|
label="来源"
|
|
value={v.origin}
|
|
onChange={(x) => f.set("origin", x)}
|
|
options={opts(SUB_ORIGIN)}
|
|
/>
|
|
<FilterSelect
|
|
label="洗版"
|
|
value={v.up}
|
|
onChange={(x) => f.set("up", x)}
|
|
options={[
|
|
{ value: "true", label: "洗版中" },
|
|
{ value: "false", label: "未洗版" },
|
|
]}
|
|
/>
|
|
<FilterSelect
|
|
label="排序"
|
|
value={v.sort}
|
|
onChange={(x) => f.set("sort", x)}
|
|
allLabel="按分组"
|
|
options={opts(SORT_LABEL)}
|
|
/>
|
|
</FilterBar>
|
|
</>
|
|
)}
|
|
|
|
{selecting ? (
|
|
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
|
|
<span className="text-sm text-foreground tabular-nums">已选 {selIds.length}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSel(new Set(sel.size === items.length ? [] : items.map((x) => x.id)))}
|
|
>
|
|
{sel.size === items.length && items.length ? "取消全选" : "全选"}
|
|
</Button>
|
|
<span className="ml-auto flex flex-wrap gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={!selIds.length || ops.pending}
|
|
onClick={() => ops.batch(selIds, "search")}
|
|
>
|
|
<Search />
|
|
立即搜索
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={!selIds.length || ops.pending}
|
|
onClick={() => ops.batch(selIds, "pause")}
|
|
>
|
|
<Pause />
|
|
暂停
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={!selIds.length || ops.pending}
|
|
onClick={() => ops.batch(selIds, "resume")}
|
|
>
|
|
<Play />
|
|
恢复
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
disabled={!selIds.length}
|
|
onClick={() => setConfirmDelete(true)}
|
|
>
|
|
<Trash2 />
|
|
删除
|
|
</Button>
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
|
|
{query.isPending ? (
|
|
<LoadingState rows={4} />
|
|
) : query.isError ? (
|
|
<ErrorState error={query.error} onRetry={() => query.refetch()} />
|
|
) : items.length === 0 ? (
|
|
<EmptyState
|
|
icon={CalendarClock}
|
|
title={filtered || v.group || v.kind ? "没有符合条件的订阅" : "还没有订阅"}
|
|
description={
|
|
filtered || v.group || v.kind ? undefined : "在搜索或媒体库里点「订阅」,或点右上角「新建订阅」。"
|
|
}
|
|
/>
|
|
) : grouped ? (
|
|
<div className="space-y-6">
|
|
{GROUP_ORDER.map((g) => {
|
|
const list = items.filter((x) => x.group === g);
|
|
if (!list.length) return null;
|
|
const closed = collapsed.has(g);
|
|
return (
|
|
<section key={g} aria-labelledby={`grp-${g}`}>
|
|
<button
|
|
type="button"
|
|
id={`grp-${g}`}
|
|
aria-expanded={!closed}
|
|
onClick={() =>
|
|
setCollapsed((s) => {
|
|
const n = new Set(s);
|
|
if (closed) n.delete(g);
|
|
else n.add(g);
|
|
return n;
|
|
})
|
|
}
|
|
className="mb-2 flex cursor-pointer items-center gap-1.5 text-body font-semibold text-foreground"
|
|
>
|
|
<ChevronDown
|
|
className={cn("h-4 w-4 text-muted-foreground transition-transform", closed && "-rotate-90")}
|
|
/>
|
|
{GROUP_LABEL[g]}
|
|
<span className="text-meta font-normal text-muted-foreground tabular-nums">
|
|
{counts?.[g] ?? list.length}
|
|
</span>
|
|
</button>
|
|
{closed ? null : renderItems(list)}
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
renderItems(items)
|
|
)}
|
|
{query.hasNextPage ? (
|
|
<div className="mt-4 flex justify-center">
|
|
<Button variant="outline" onClick={() => query.fetchNextPage()} disabled={query.isFetchingNextPage}>
|
|
{query.isFetchingNextPage ? "加载中…" : "加载更多"}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)}
|
|
|
|
{creating ? <SubscriptionFormDialog onClose={() => setCreating(false)} /> : null}
|
|
{editing ? (
|
|
<SubscriptionFormDialog
|
|
sub={editing}
|
|
title={editing.mediaEntityTitle ?? editing.titleQuery ?? undefined}
|
|
onClose={() => setEditing(null)}
|
|
/>
|
|
) : null}
|
|
<DeleteSubscriptionsDialog
|
|
open={confirmDelete}
|
|
onOpenChange={setConfirmDelete}
|
|
ids={selIds}
|
|
onDone={exitSelect}
|
|
/>
|
|
</>
|
|
);
|
|
}
|