Files
UHD_Media_Tool/apps/web/app/routes/console/subscriptions.tsx
T
rootandClaude Opus 5.5 fb84a71f80 style(copy): fix remaining punctuation violations and make check:copy strict
Auto-fix of 602 string literals across 74 files (half-width punctuation next to
Chinese, parenthesised Chinese, quotes); biome reflow; e2/e6 assertions follow
the new full-width summaries. check:copy now runs with --strict in ci.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 21:34:51 +08:00

464 lines
16 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}
/>
) : (
<>
<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 : "从媒体库、搜索或 Agent 添加,也可以点「新建订阅」。"
}
/>
) : 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}
/>
</>
);
}