Files
UHD_Media_Tool/apps/web/app/routes/console/subscriptions.tsx
T
rootandClaude Opus 5.5 ef73abe3df feat(web): W3-2 library, subscriptions and rules, preferences, decisions inbox, identity and metadata
- library list (kind/status/search, cursor paging) and detail: paths and roots, external identity
  mapping with lock, field-level metadata sources and locks, slot cards with rejection reasons,
  content units (provisional flag), downloads with verification; remap/lock/refresh/merge actions
- subscriptions: list/create/update, slot rules editor (hard/soft/upgrade/completion),
  policy revision draft -> preview -> publish (grant warning) -> rollback
- preferences: tag CRUD, disable/enable, suppression records and lift, history timeline
- inbox: grouped by entity and kind, evidence/suggestion/impact/preview, resolve options, SSE refresh
- identity: pool assign/dismiss/retry, external identity revisions and lock, title alias management
- all responses validated against contracts zod schemas

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 20:39:02 +08:00

625 lines
23 KiB
TypeScript

import {
AcquisitionPriority,
type MediaEntity,
SeasonScope,
type Subscription,
SubscriptionCreate,
SubscriptionMediaKind,
SubscriptionMode,
type SubscriptionStatus,
} from "@umt/contracts";
import { CalendarClock, Check, FilePlus2, Pause, Play, Plus, Search, XCircle } from "lucide-react";
import { useId, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useEntitySearch } from "~/api/library.ts";
import {
type SubscriptionFilters,
useCreateSubscription,
useEntityTitle,
useSubscriptions,
useUpdateSubscription,
} from "~/api/subscriptions.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import {
MEDIA_KIND,
opts,
PRIORITY,
SEASON_SCOPE,
SUB_MODE,
SUB_ORIGIN,
SUB_STATUS,
} from "~/components/library/labels.ts";
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
import { PolicyWorkspace } from "~/components/library/subscriptions-policy.tsx";
import { SlotsPanel } from "~/components/library/subscriptions-slots.tsx";
import { Button, buttonVariants } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
// 订阅与收录规则:订阅(单次/持续、仅本季/接续)+ 各作品的收录目标 Slot + 收录策略版本(REQ §6.1、§6.4、§6.5)
const SUB_KIND_LABEL: Record<SubscriptionMediaKind, string> = { movie: "电影", series: "剧集", variety: "综艺" };
export default function SubscriptionsPage() {
const [params, setParams] = useSearchParams();
const tab = params.get("tab") === "policies" ? "policies" : "subscriptions";
const [creatingSub, setCreatingSub] = useState(false);
const [creatingPolicy, setCreatingPolicy] = useState(false);
return (
<>
<PageHeader
title="订阅与收录规则"
description="订阅决定追什么,收录目标决定要哪种版本,策略决定默认怎么做;启用即授权范围内的持续操作。"
actions={
tab === "subscriptions" ? (
<Button onClick={() => setCreatingSub(true)}>
<Plus />
新建订阅
</Button>
) : (
<Button onClick={() => setCreatingPolicy(true)}>
<FilePlus2 />
新建策略草案
</Button>
)
}
/>
<Tabs
value={tab}
onValueChange={(v) =>
setParams(
(p) => {
if (v === "policies") p.set("tab", "policies");
else p.delete("tab");
return p;
},
{ replace: true },
)
}
>
<TabsList aria-label="订阅与收录规则">
<TabsTrigger value="subscriptions">订阅</TabsTrigger>
<TabsTrigger value="policies">收录策略</TabsTrigger>
</TabsList>
<TabsContent value="subscriptions">
<SubscriptionsTab />
</TabsContent>
<TabsContent value="policies">
<PolicyWorkspace creating={creatingPolicy} onCreatingChange={setCreatingPolicy} />
</TabsContent>
</Tabs>
{creatingSub ? <CreateSubscriptionDialog onClose={() => setCreatingSub(false)} /> : null}
</>
);
}
// ── 订阅列表 ──
function EntityTitle({ sub }: { sub: Subscription }) {
const q = useEntityTitle(sub.mediaEntityId);
if (!sub.mediaEntityId)
return (
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="break-all text-foreground">{sub.titleQuery ?? "—"}</span>
<Chip>尚未建档</Chip>
</span>
);
if (q.isPending) return <span className="text-muted-foreground">加载中…</span>;
if (q.isError)
return <span className="font-mono text-meta text-muted-foreground">{sub.mediaEntityId.slice(0, 8)}(无法读取)</span>;
return (
<span className="min-w-0 break-all text-foreground">
{q.data.title}
{q.data.year ? <span className="text-muted-foreground"> ({q.data.year})</span> : null}
</span>
);
}
function SubscriptionsTab() {
const [status, setStatus] = useState("");
const filters: SubscriptionFilters = { status: (status || undefined) as SubscriptionFilters["status"] };
const q = useSubscriptions(filters);
const rows = useMemo(() => q.data?.pages.flatMap((p) => p.items), [q.data]);
const [openId, setOpenId] = useState<string | null>(null);
const open = rows?.find((r) => r.id === openId) ?? null;
const columns: DataColumn<Subscription>[] = [
{ key: "title", header: "作品", cell: (r) => <EntityTitle sub={r} />, className: "min-w-40" },
{
key: "kind",
header: "类型",
cell: (r) => SUB_KIND_LABEL[r.mediaKind],
hideBelow: "sm",
className: "whitespace-nowrap",
},
{
key: "mode",
header: "追踪方式",
cell: (r) => (
<span className="whitespace-nowrap">
{SUB_MODE[r.mode]}
{r.mediaKind !== "movie" ? (
<span className="text-muted-foreground"> · {SEASON_SCOPE[r.seasonScope]}</span>
) : null}
</span>
),
hideBelow: "md",
},
{
key: "priority",
header: "优先级",
cell: (r) => PRIORITY[r.priority],
hideBelow: "lg",
className: "whitespace-nowrap",
},
{ key: "status", header: "状态", cell: (r) => <Tone v={SUB_STATUS[r.status]} /> },
{
key: "checked",
header: "最近检查",
cell: (r) => (
<span className="whitespace-nowrap text-muted-foreground tabular-nums">{relTime(r.lastCheckedAt)}</span>
),
hideBelow: "md",
align: "right",
},
];
return (
<>
<FilterBar onReset={status ? () => setStatus("") : undefined}>
<FilterSelect label="状态" value={status} onChange={setStatus} options={opts(SUB_STATUS)} />
</FilterBar>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => r.id}
loading={q.isPending}
error={q.error}
onRetry={() => q.refetch()}
onRowClick={(r) => setOpenId(r.id)}
selectedKey={openId}
hasMore={q.hasNextPage}
onLoadMore={() => q.fetchNextPage()}
loadingMore={q.isFetchingNextPage}
empty={{
icon: CalendarClock,
title: status ? "没有符合条件的订阅" : "还没有订阅",
description: "订阅一部作品后,系统按收录目标持续补齐与升级;也可以只填标题,等作品建档后自动关联。",
}}
/>
<SubscriptionSheet sub={open} onClose={() => setOpenId(null)} />
</>
);
}
// ── 订阅详情:修改 + 收录目标 ──
function SubscriptionSheet({ sub, onClose }: { sub: Subscription | null; onClose: () => void }) {
const update = useUpdateSubscription();
const title = useEntityTitle(sub?.mediaEntityId);
const setStatus = (status: SubscriptionStatus, done: string) =>
sub
? update
.mutateAsync({ id: sub.id, status })
.then(() => toast.success(done))
.catch((e) => {
toast.error(errorMessage(e));
throw e;
})
: Promise.resolve();
return (
<DetailSheet
open={!!sub}
onOpenChange={(o) => !o && onClose()}
width="lg"
title={sub ? (sub.mediaEntityId ? (title.data?.title ?? "订阅") : (sub.titleQuery ?? "订阅")) : ""}
description={sub ? `${SUB_KIND_LABEL[sub.mediaKind]} · 来源:${SUB_ORIGIN[sub.origin] ?? sub.origin}` : null}
footer={
sub && sub.status !== "cancelled" && sub.status !== "completed" ? (
<>
{sub.status === "active" ? (
<Button
variant="outline"
onClick={() => setStatus("paused", "已暂停,不再发起新下载")}
disabled={update.isPending}
>
<Pause />
暂停
</Button>
) : (
<Button variant="outline" onClick={() => setStatus("active", "已恢复追踪")} disabled={update.isPending}>
<Play />
恢复
</Button>
)}
<ConfirmDialog
trigger={
<Button variant="outline" className="hover:border-destructive/40 hover:text-destructive">
<XCircle />
取消订阅
</Button>
}
title="取消这个订阅?"
description="不再追踪后续内容;已入库文件、已有收录目标与下载历史都保留。"
confirmLabel="取消订阅"
destructive
onConfirm={() => setStatus("cancelled", "订阅已取消")}
/>
</>
) : null
}
>
{sub ? (
<>
<DetailSection title="概况">
<KvList
items={[
{ label: "状态", value: <Tone v={SUB_STATUS[sub.status]} /> },
{
label: "作品",
value: sub.mediaEntityId ? (
<Link className="underline-offset-4 hover:underline" to={consolePath("library", sub.mediaEntityId)}>
打开媒体详情
</Link>
) : (
`按标题追踪:${sub.titleQuery ?? "—"}(尚未建档)`
),
},
{ label: "状态说明", value: sub.statusReason },
{ label: "最近检查", value: formatDateTime(sub.lastCheckedAt) },
{ label: "创建", value: `${formatDateTime(sub.createdAt)} · ${sub.createdBy ?? "—"}` },
{ label: "策略版本", value: sub.policyRevisionId ?? "按当前已发布策略", mono: !!sub.policyRevisionId },
]}
/>
</DetailSection>
<DetailSection title="追踪方式">
<SubscriptionEditForm key={`${sub.id}-${sub.updatedAt}`} sub={sub} />
</DetailSection>
<DetailSection title="收录目标">
{sub.mediaEntityId ? (
<SlotsPanel mediaEntityId={sub.mediaEntityId} subscriptionId={sub.id} />
) : (
<p className="text-caption text-muted-foreground">作品建档并关联后才能设置收录目标。</p>
)}
</DetailSection>
</>
) : null}
</DetailSheet>
);
}
function SubscriptionEditForm({ sub }: { sub: Subscription }) {
const id = useId();
const update = useUpdateSubscription();
const [mode, setMode] = useState<SubscriptionMode>(sub.mode);
const [scope, setScope] = useState<SeasonScope>(sub.seasonScope);
const [priority, setPriority] = useState<AcquisitionPriority>(sub.priority);
const dirty = mode !== sub.mode || scope !== sub.seasonScope || priority !== sub.priority;
const disabled = sub.status === "cancelled" || sub.status === "completed";
const save = () =>
update.mutate(
{ id: sub.id, mode, seasonScope: scope, priority },
{ onSuccess: () => toast.success("追踪方式已更新"), onError: (e) => toast.error(errorMessage(e)) },
);
return (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<FormField label="方式" htmlFor={`${id}-mode`}>
<EnumSelect
id={`${id}-mode`}
value={mode}
onChange={setMode}
options={SubscriptionMode.options}
labels={SUB_MODE}
disabled={disabled}
/>
</FormField>
<FormField label="季范围" htmlFor={`${id}-scope`} hint={sub.mediaKind === "movie" ? "电影不适用" : undefined}>
<EnumSelect
id={`${id}-scope`}
value={scope}
onChange={setScope}
options={SeasonScope.options}
labels={SEASON_SCOPE}
disabled={disabled || sub.mediaKind === "movie"}
/>
</FormField>
<FormField label="优先级" htmlFor={`${id}-prio`}>
<EnumSelect
id={`${id}-prio`}
value={priority}
onChange={setPriority}
options={AcquisitionPriority.options}
labels={PRIORITY}
disabled={disabled}
/>
</FormField>
</div>
{mode === "continuous" && scope === "following_seasons" && sub.mediaKind !== "movie" ? (
<p className="text-meta text-muted-foreground">特别篇、衍生节目和同名重启不直接视作下一季,按证据识别。</p>
) : null}
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={save} disabled={!dirty || update.isPending}>
<Check />
{update.isPending ? "保存中…" : "保存"}
</Button>
</div>
</div>
);
}
function EnumSelect<T extends string>({
id,
value,
onChange,
options,
labels,
disabled,
}: {
id: string;
value: T;
onChange: (v: T) => void;
options: readonly T[];
labels: Record<T, string>;
disabled?: boolean;
}) {
return (
<Select value={value} onValueChange={(v) => onChange(v as T)} disabled={disabled}>
<SelectTrigger id={id} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{options.map((o) => (
<SelectItem key={o} value={o}>
{labels[o]}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// ── 新建订阅 ──
const toSubKind = (k: MediaEntity["kind"]): SubscriptionMediaKind =>
k === "movie" ? "movie" : k === "variety" ? "variety" : "series";
function CreateSubscriptionDialog({ onClose }: { onClose: () => void }) {
const id = useId();
const create = useCreateSubscription();
const [byTitle, setByTitle] = useState(false);
const [term, setTerm] = useState("");
const [entity, setEntity] = useState<MediaEntity | null>(null);
const [titleQuery, setTitleQuery] = useState("");
const [mediaKind, setMediaKind] = useState<SubscriptionMediaKind>("series");
const [mode, setMode] = useState<SubscriptionMode>("continuous");
const [scope, setScope] = useState<SeasonScope>("following_seasons");
const [priority, setPriority] = useState<AcquisitionPriority>("tracking");
const [errors, setErrors] = useState<Record<string, string>>({});
const search = useEntitySearch(term, { enabled: !byTitle && !entity });
const pick = (e: MediaEntity) => {
setEntity(e);
const k = toSubKind(e.kind);
setMediaKind(k);
// REQ §6.5:用户主动添加的剧集/综艺默认持续追踪并接续后续季;电影默认单次
setMode(k === "movie" ? "once" : "continuous");
setScope("following_seasons");
};
const submit = () => {
const body = {
mediaEntityId: byTitle ? undefined : entity?.id,
titleQuery: byTitle ? titleQuery.trim() || undefined : undefined,
mediaKind,
mode,
seasonScope: mediaKind === "movie" ? "current_season" : scope,
priority,
origin: "user" as const,
};
const v = SubscriptionCreate.safeParse(body);
if (!v.success) {
const errs = zodFieldErrors(v.error.issues);
if (errs[""]) errs.target = byTitle ? "请填写要追踪的标题" : "请选择一部作品";
setErrors(errs);
return;
}
setErrors({});
create.mutate(v.data, {
onSuccess: () => {
toast.success("订阅已创建,将按收录目标开始检查");
onClose();
},
onError: (e) => toast.error(errorMessage(e)),
});
};
return (
<Dialog open onOpenChange={(o) => !o && !create.isPending && onClose()}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>新建订阅</DialogTitle>
<DialogDescription>选择库里的作品,或先按标题追踪,作品建档后自动关联。</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-3">
<fieldset className="flex gap-1" aria-label="订阅对象">
{[
[false, "选择作品"],
[true, "按标题追踪"],
].map(([v, l]) => (
<button
key={String(l)}
type="button"
aria-pressed={byTitle === v}
onClick={() => {
setByTitle(v as boolean);
setErrors({});
}}
className={cn(
"h-8 cursor-pointer rounded-full border px-3 text-sm font-medium transition-colors duration-micro",
byTitle === v
? "pill-selected text-foreground"
: "border-transparent text-muted-foreground hover:bg-foreground/[0.06]",
)}
>
{l}
</button>
))}
</fieldset>
{byTitle ? (
<FormField
label="标题"
htmlFor={`${id}-tq`}
required
error={errors.target ?? errors.titleQuery}
hint="原名、译名或站点标题都可以"
>
<Input
id={`${id}-tq`}
value={titleQuery}
aria-invalid={!!errors.target}
onChange={(e) => setTitleQuery(e.target.value)}
/>
</FormField>
) : entity ? (
<div className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{entity.title}
{entity.year ? <span className="text-muted-foreground"> ({entity.year})</span> : null}
</p>
<p className="text-meta text-muted-foreground">{MEDIA_KIND[entity.kind]}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => setEntity(null)}>
更换
</Button>
</div>
) : (
<FormField label="作品" htmlFor={`${id}-q`} required error={errors.target}>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`${id}-q`}
className="pl-9"
value={term}
aria-invalid={!!errors.target}
placeholder="搜索标题 / 原名"
onChange={(e) => setTerm(e.target.value)}
/>
</div>
<div className="max-h-52 overflow-y-auto rounded-lg border border-border/70">
{search.isPending ? (
<p className="px-3 py-2 text-caption text-muted-foreground">搜索中…</p>
) : search.isError ? (
<p className="px-3 py-2 text-caption text-destructive">{errorMessage(search.error)}</p>
) : (search.data?.items ?? []).filter((e) => e.status !== "merged").length === 0 ? (
<p className="px-3 py-2 text-caption text-muted-foreground">没有匹配的作品,可以改用"按标题追踪"。</p>
) : (
<ul className="divide-y divide-border/60">
{(search.data?.items ?? [])
.filter((e) => e.status !== "merged")
.map((e) => (
<li key={e.id}>
<button
type="button"
onClick={() => pick(e)}
className="flex w-full cursor-pointer items-center justify-between gap-2 px-3 py-2 text-left hover:bg-foreground/[0.04]"
>
<span className="min-w-0 truncate text-sm text-foreground">
{e.title}
{e.year ? <span className="text-muted-foreground"> ({e.year})</span> : null}
</span>
<span className="shrink-0 text-meta text-muted-foreground">{MEDIA_KIND[e.kind]}</span>
</button>
</li>
))}
</ul>
)}
</div>
</FormField>
)}
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="类型" htmlFor={`${id}-kind`} error={errors.mediaKind}>
<EnumSelect
id={`${id}-kind`}
value={mediaKind}
onChange={(k) => {
setMediaKind(k);
if (k === "movie") setMode("once");
}}
options={SubscriptionMediaKind.options}
labels={SUB_KIND_LABEL}
disabled={!byTitle && !!entity}
/>
</FormField>
<FormField label="方式" htmlFor={`${id}-mode`}>
<EnumSelect
id={`${id}-mode`}
value={mode}
onChange={setMode}
options={SubscriptionMode.options}
labels={SUB_MODE}
/>
</FormField>
<FormField label="季范围" htmlFor={`${id}-scope`} hint={mediaKind === "movie" ? "电影不适用" : "随时可改"}>
<EnumSelect
id={`${id}-scope`}
value={mediaKind === "movie" ? "current_season" : scope}
onChange={setScope}
options={SeasonScope.options}
labels={SEASON_SCOPE}
disabled={mediaKind === "movie"}
/>
</FormField>
<FormField label="优先级" htmlFor={`${id}-prio`} hint="只影响资源分配,不越过磁盘阈值与权限">
<EnumSelect
id={`${id}-prio`}
value={priority}
onChange={setPriority}
options={AcquisitionPriority.options}
labels={PRIORITY}
/>
</FormField>
</div>
</div>
<DialogFooter>
<button
type="button"
className={buttonVariants({ variant: "outline" })}
onClick={onClose}
disabled={create.isPending}
>
取消
</button>
<Button onClick={submit} disabled={create.isPending}>
{create.isPending ? "创建中…" : "创建订阅"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}