- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示) - Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话 - 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别 - 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务; 哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS - 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随 Co-Authored-By: Claude Opus 5.5 <[email protected]>
292 lines
12 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|