Files
UHD_Media_Tool/apps/web/app/routes/console/subscriptions.$id.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

292 lines
12 KiB
TypeScript

import { ArrowLeft, Pause, Pencil, Play, RefreshCw, RotateCcw, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { Link, useParams } from "react-router";
import { toast } from "sonner";
import {
useRefreshCoverage,
useSubscription,
useSubscriptionCoverage,
useSubscriptionOverviewItem,
} from "~/api/subscriptions.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { PRIORITY, SEASON_SCOPE, SUB_MODE, SUB_ORIGIN, SUB_STATUS } from "~/components/library/labels.ts";
import { CoverPlaceholder, Panel, relTime, Tone } from "~/components/library/parts.tsx";
import { SlotsPanel } from "~/components/library/subscriptions-slots.tsx";
import { SubscriptionMenu, useSubscriptionOps } from "~/components/subscriptions/actions.tsx";
import {
CellSheet,
CoverageLegend,
CoverageMatrix,
type SelectedCell,
} from "~/components/subscriptions/coverage-matrix.tsx";
import {
autoFillText,
foldNames,
GROUP_LABEL,
SUB_KIND_LABEL,
seasonLabel,
seasonSlices,
seasonTitle,
} from "~/components/subscriptions/model.ts";
import { ProgressBlock, SlotBadges } from "~/components/subscriptions/overview-item.tsx";
import { SlotPanel } from "~/components/subscriptions/slot-panel.tsx";
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { actorText } from "~/lib/labels.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
// 订阅对比视图(FE-5,docs/design/subscriptions.md §2):季页签 → 集 × 版本目标矩阵;版本目标排序与自动补全;订阅信息。
export const meta = () => [{ title: "订阅详情 · UHD 媒体自动化控制台" }];
const TOTAL_SOURCE: Record<string, string> = {
tmdb: "TMDB",
injected: "注入",
manual: "手动",
covered_floor: "按已入库",
previous: "沿用",
};
export default function SubscriptionDetailPage() {
const { id } = useParams();
const subQ = useSubscription(id);
const sub = subQ.data;
const itemQ = useSubscriptionOverviewItem(sub);
const item = itemQ.data;
const covQ = useSubscriptionCoverage(id);
const cov = covQ.data;
const refresh = useRefreshCoverage();
const ops = useSubscriptionOps();
const [season, setSeason] = useState(0);
const [sel, setSel] = useState<SelectedCell | null>(null);
const [editing, setEditing] = useState(false);
const seasonTabs = useMemo(() => {
if (!cov) return [];
const slices = seasonSlices(cov);
const any = cov.rows.find((r) => r.slotId === null);
return cov.seasons.map((s, i) => {
const sl = slices[i]!;
const have = any ? any.cells.slice(sl.start, sl.end).filter((c) => c.state === "have").length : 0;
return {
title: seasonTitle(s, cov.mediaKind),
stat: `${have}/${s.totalKnown ? s.total : "?"}`,
source: s.source ? (TOTAL_SOURCE[s.source] ?? s.source) : null,
locked: s.locked,
};
});
}, [cov]);
if (subQ.isPending) return <LoadingState rows={6} />;
if (subQ.isError || !sub) return <ErrorState error={subQ.error} onRetry={() => subQ.refetch()} />;
const title = sub.mediaEntityTitle ?? item?.mediaEntityTitle ?? sub.titleQuery ?? "订阅";
const tracking = sub.status === "active" || sub.status === "pending";
const ended = sub.status === "completed" || sub.status === "cancelled";
const sLabel = item ? seasonLabel(item) : null;
const doRefresh = () =>
refresh.mutate(sub.id, {
onSuccess: () => toast.success("对比已刷新"),
onError: (e) => toast.error(errorMessage(e)),
});
const seasonIdx = Math.min(season, Math.max(0, (cov?.seasons.length ?? 1) - 1));
return (
<>
<Link
to={consolePath("subscriptions")}
className="mb-3 inline-flex items-center gap-1 text-caption text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
订阅
</Link>
<header className="mb-5 flex flex-wrap items-start gap-4">
<CoverPlaceholder title={title} kind={sub.mediaKind} className="h-24 w-16 max-sm:hidden" />
<div className="min-w-0 flex-1 space-y-2">
<div className="min-w-0">
<h1 className="text-title-lg font-semibold tracking-tight break-words text-foreground">{title}</h1>
<p className="text-caption text-muted-foreground">
{[item?.year, SUB_KIND_LABEL[sub.mediaKind], sLabel, SUB_MODE[sub.mode]].filter(Boolean).join(" · ")}
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Tone v={SUB_STATUS[sub.status]} />
{item?.group ? <StatusBadge>{GROUP_LABEL[item.group]}</StatusBadge> : null}
{item?.upgrading ? <StatusBadge tone="warning">洗版中</StatusBadge> : null}
{item?.awaitingNewSeason ? <StatusBadge tone="success">等待新季</StatusBadge> : null}
{!sub.mediaEntityId ? <StatusBadge tone="warning">尚未建档</StatusBadge> : null}
</div>
{item ? (
<div className="grid max-w-xl gap-2">
<ProgressBlock x={item} />
<SlotBadges slots={item.slots} max={6} />
</div>
) : null}
</div>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
{tracking && sub.mediaEntityId ? (
<Button variant="outline" size="sm" onClick={() => ops.search(sub.id)} disabled={ops.pending}>
<Search />
立即搜索
</Button>
) : null}
{!ended ? (
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil />
编辑
</Button>
) : null}
{tracking ? (
<Button variant="outline" size="sm" onClick={() => ops.act(sub.id, "pause")} disabled={ops.pending}>
<Pause />
暂停
</Button>
) : sub.status === "paused" ? (
<Button variant="outline" size="sm" onClick={() => ops.act(sub.id, "resume")} disabled={ops.pending}>
<Play />
恢复
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => ops.act(sub.id, "resubscribe")} disabled={ops.pending}>
<RotateCcw />
重新订阅
</Button>
)}
<SubscriptionMenu sub={sub} onEdit={() => setEditing(true)} showDetail={false} />
</div>
</header>
<div className="space-y-5">
<Panel
title="覆盖对比"
description={cov ? `按版本目标逐集对比 · 计算于 ${relTime(cov.computedAt)}` : "按版本目标逐集对比"}
actions={
<Button variant="outline" size="sm" onClick={doRefresh} disabled={refresh.isPending || !sub.mediaEntityId}>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
刷新对比
</Button>
}
>
{!sub.mediaEntityId ? (
<p className="text-caption text-muted-foreground">作品建档后才能对比。建档后自动关联到这个订阅。</p>
) : covQ.isPending ? (
<LoadingState rows={3} />
) : covQ.isError ? (
<ErrorState plain error={covQ.error} onRetry={() => covQ.refetch()} />
) : !cov || cov.seasons.length === 0 ? (
<p className="text-caption text-muted-foreground">
还没有季和集信息。元数据刷新后出现;也可以在编辑里设置总集数。
</p>
) : (
<div className="space-y-4">
{cov.seasons.length > 1 || cov.mediaKind !== "movie" ? (
<div role="tablist" aria-label="季" className="flex gap-1 overflow-x-auto scroll-bleed">
{seasonTabs.map((t, i) => (
<button
key={t.title + i}
type="button"
role="tab"
aria-selected={i === seasonIdx}
onClick={() => setSeason(i)}
className={cn(
"flex shrink-0 cursor-pointer flex-col items-start rounded-lg border px-3 py-1.5 text-left transition-colors duration-micro",
i === seasonIdx ? "pill-selected" : "border-transparent hover:bg-foreground/[0.06]",
)}
>
<span className="text-sm font-medium text-foreground">{t.title}</span>
<span className="text-meta text-muted-foreground tabular-nums">
{t.stat}
{t.source ? ` · ${t.source}` : ""}
{t.locked ? " · 锁定" : ""}
</span>
</button>
))}
</div>
) : null}
<CoverageMatrix cov={cov} seasonIndex={seasonIdx} onSelect={setSel} />
<CoverageLegend />
</div>
)}
</Panel>
{sub.mediaEntityId ? <SlotPanel sub={sub} cov={cov} item={item} canFill={tracking} /> : null}
<div className="grid gap-5 lg:grid-cols-2">
<Panel title="订阅信息">
<KvList
items={[
{ label: "方式", value: SUB_MODE[sub.mode] },
...(sub.mediaKind !== "movie" ? [{ label: "季范围", value: SEASON_SCOPE[sub.seasonScope] }] : []),
{ label: "优先级", value: PRIORITY[sub.priority] },
{ label: "来源", value: SUB_ORIGIN[sub.origin] ?? sub.origin },
{
label: "站点",
value: item
? foldNames(
item.sites.map((s) => s.name),
4,
)
: null,
},
{
label: "规则组",
value: item
? `${foldNames(
item.ruleGroups.map((g) => g.name),
4,
"未设置",
)}${item.defaultRuleGroups && item.ruleGroups.length ? "(未单独设置)" : ""}`
: null,
},
{ label: "自动补全", value: item ? autoFillText(item.autoFill) : null },
{ label: "上次更新", value: item ? formatDateTime(item.lastUpdatedAt) : null },
{
label: "主动搜索",
value: sub.lastSearchedAt
? `上次 ${relTime(sub.lastSearchedAt)}${sub.nextSearchAt ? ` · 下次 ${relTime(sub.nextSearchAt)}` : item?.searchEnabled === false ? " · 周期搜索已关" : ""}`
: "还没搜索过",
},
{ label: "状态说明", value: sub.statusReason },
{ label: "创建", value: `${formatDateTime(sub.createdAt)} · ${actorText(sub.createdBy)}`, wide: true },
...(sub.mediaEntityId
? [
{
label: "作品",
value: (
<Link
className="underline-offset-4 hover:underline"
to={consolePath("library", sub.mediaEntityId)}
>
查看作品
</Link>
),
},
]
: []),
]}
/>
</Panel>
{sub.mediaEntityId ? (
<Panel title="版本目标规则" description="条件、阶梯与升级;新建的版本目标排在最后">
<SlotsPanel mediaEntityId={sub.mediaEntityId} subscriptionId={sub.id} />
</Panel>
) : null}
</div>
</div>
<CellSheet
sel={sel}
subscriptionId={sub.id}
mediaEntityId={sub.mediaEntityId}
canFill={tracking}
onClose={() => setSel(null)}
/>
{editing ? <SubscriptionFormDialog sub={sub} title={title} onClose={() => setEditing(false)} /> : null}
</>
);
}