import { AcquisitionPriority, type MediaEntity, SeasonScope, type Subscription, SubscriptionCreate, SubscriptionMediaKind, SubscriptionMode, type SubscriptionProgress, type SubscriptionStatus, } from "@umt/contracts"; import { CalendarClock, Check, FilePlus2, Pause, Play, Plus, Search, XCircle } from "lucide-react"; import { useEffect, 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, useSearchSubscription, useSetEpisodeTotal, useSubscriptionProgress, 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 = { 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 ( <> setCreatingSub(true)}> 新建订阅 ) : ( ) } /> setParams( (p) => { if (v === "policies") p.set("tab", "policies"); else p.delete("tab"); return p; }, { replace: true }, ) } > 订阅 收录策略 {creatingSub ? setCreatingSub(false)} /> : null} ); } // ── 订阅列表 ── 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 EntityTitle({ sub }: { sub: Subscription }) { // 列表响应已带 mediaEntityTitle;没有时(旧数据/详情)再按 ID 取 const q = useEntityTitle(sub.mediaEntityTitle ? null : sub.mediaEntityId); if (sub.mediaEntityId && sub.mediaEntityTitle) return {sub.mediaEntityTitle}; if (!sub.mediaEntityId) return ( {sub.titleQuery ?? "—"} 尚未建档 ); if (q.isPending) return 加载中…; if (q.isError) return {sub.mediaEntityId.slice(0, 8)}(无法读取); return ( {q.data.title} {q.data.year ? ({q.data.year}) : null} ); } function SubscriptionsTab() { const [status, setStatus] = useState(""); const [kind, setKind] = useState(""); const [search, setSearch] = useState(""); const dq = useDebounced(search.trim()); const filters: SubscriptionFilters = { status: (status || undefined) as SubscriptionFilters["status"], mediaKind: (kind || undefined) as SubscriptionFilters["mediaKind"], q: dq || undefined, }; const filtered = !!status || !!kind || !!search; const q = useSubscriptions(filters); const rows = useMemo(() => q.data?.pages.flatMap((p) => p.items), [q.data]); const [openId, setOpenId] = useState(null); const open = rows?.find((r) => r.id === openId) ?? null; const columns: DataColumn[] = [ { key: "title", header: "作品", cell: (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) => ( {SUB_MODE[r.mode]} {r.mediaKind !== "movie" ? ( · {SEASON_SCOPE[r.seasonScope]} ) : null} ), hideBelow: "md", }, { key: "priority", header: "优先级", cell: (r) => PRIORITY[r.priority], hideBelow: "lg", className: "whitespace-nowrap", }, { key: "status", header: "状态", cell: (r) => }, { key: "checked", header: "最近检查", cell: (r) => ( {relTime(r.lastCheckedAt)} ), hideBelow: "md", align: "right", }, ]; return ( <> { setStatus(""); setKind(""); setSearch(""); } : undefined } > 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: filtered ? "没有符合条件的订阅" : "还没有订阅", description: "订阅一部作品后,系统按收录目标持续补齐与升级;也可以只填标题,等作品建档后自动关联。", }} /> setOpenId(null)} /> ); } // ── 订阅详情:修改 + 收录目标 ── function SubscriptionSheet({ sub, onClose }: { sub: Subscription | null; onClose: () => void }) { const update = useUpdateSubscription(); const search = useSearchSubscription(); const runSearch = () => sub && search.mutate(sub.id, { onSuccess: () => toast.success("已开始在已开启站点搜索,结果会按收录目标自动评估"), onError: (e) => toast.error(errorMessage(e)), }); 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 ( !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.mediaEntityId ? ( ) : null} {sub.status === "active" || sub.status === "pending" ? ( ) : ( )} 取消订阅 } title="取消这个订阅?" description="不再追踪后续内容;已入库文件、已有收录目标与下载历史都保留。" confirmLabel="取消订阅" destructive onConfirm={() => setStatus("cancelled", "订阅已取消")} /> ) : null } > {sub ? ( <> }, { label: "作品", value: sub.mediaEntityId ? ( 打开媒体详情 ) : ( `按标题追踪:${sub.titleQuery ?? "—"}(尚未建档)` ), }, { label: "状态说明", value: sub.statusReason }, { label: "最近检查", value: formatDateTime(sub.lastCheckedAt) }, { label: "主动搜索", value: sub.lastSearchedAt ? `上次 ${formatDateTime(sub.lastSearchedAt)}${sub.nextSearchAt ? ` · 下次 ${formatDateTime(sub.nextSearchAt)}` : " · 周期搜索已关"}` : "尚未搜索", }, { label: "创建", value: `${formatDateTime(sub.createdAt)} · ${sub.createdBy ?? "—"}` }, { label: "策略版本", value: sub.policyRevisionId ?? "按当前已发布策略", mono: !!sub.policyRevisionId }, ]} /> {sub.mediaEntityId && sub.mediaKind !== "movie" ? ( ) : null} {sub.mediaEntityId ? ( ) : (

作品建档并关联后才能设置收录目标。

)}
) : null}
); } const UNIT_STATE: Record< SubscriptionProgress["seasons"][number]["units"][number]["state"], { label: string; cls: string } > = { covered: { label: "已入库", cls: "border-success/30 bg-success/10 text-foreground" }, inflight: { label: "下载中", cls: "border-foreground/30 bg-foreground/[0.06] text-foreground" }, missing: { label: "缺失", cls: "border-border text-muted-foreground" }, suspected_missing: { label: "疑似缺集", cls: "border-warning/50 bg-warning/10 text-foreground" }, out_of_range: { label: "不在范围", cls: "border-dashed border-border text-muted-foreground" }, }; /** 每季总集数(来源/锁定)+ 每集状态;可手动设置总集数(不锁定只升) */ function ProgressPanel({ subId, readOnly }: { subId: string; readOnly: boolean }) { const id = useId(); const progress = useSubscriptionProgress(subId); const setTotal = useSetEpisodeTotal(); const [season, setSeason] = useState("1"); const [total, setTotalValue] = useState(""); const [locked, setLocked] = useState(false); if (progress.isPending) return

读取中…

; if (progress.isError) return

{errorMessage(progress.error)}

; const p = progress.data; const save = () => setTotal.mutate( { id: subId, seasonNumber: Number(season), total: Number(total), locked }, { onSuccess: () => { toast.success(locked ? "总集数已锁定" : "总集数已更新(只升)"); setTotalValue(""); }, onError: (e) => toast.error(errorMessage(e)), }, ); return (
{!p.completable ? (

总集数未知或元数据未就绪:继续追踪,不会自动结束。

) : null} {p.seasons.length === 0 ?

还没有季信息。

: null} {p.seasons.map((s) => (

第 {s.seasonNumber} 季 · {s.totalKnown ? `共 ${s.episodeTotal} 集` : "总集数未知"} {s.totalSource ? `(${s.totalSource}${s.totalLocked ? ",已锁定" : ""})` : ""} {s.complete ? " · 已齐" : ""}

{s.units.map((u) => ( E{String(u.episode).padStart(2, "0")} ))}
{s.suspectedMissing.length ? (

疑似缺集:{s.suspectedMissing.map((e) => `E${e}`).join("、")}

) : null}
))} {p.slots.some((x) => x.inflightPlans.length > 1 || x.deferred > 0) ? (
    {p.slots.map((x) => (
  • {x.name}:在途 {x.inflightPlans.length} 个计划,延后 {x.deferred} 个发布
  • ))}
) : null} {readOnly ? null : (
setSeason(e.target.value)} /> setTotalValue(e.target.value)} />
)}
); } function SubscriptionEditForm({ sub }: { sub: Subscription }) { const id = useId(); const update = useUpdateSubscription(); const [mode, setMode] = useState(sub.mode); const [scope, setScope] = useState(sub.seasonScope); const [priority, setPriority] = useState(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 (
{mode === "continuous" && scope === "following_seasons" && sub.mediaKind !== "movie" ? (

特别篇、衍生节目和同名重启不直接视作下一季,按证据识别。

) : null}
); } function EnumSelect({ id, value, onChange, options, labels, disabled, }: { id: string; value: T; onChange: (v: T) => void; options: readonly T[]; labels: Record; disabled?: boolean; }) { return ( ); } // ── 新建订阅 ── 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(null); const [titleQuery, setTitleQuery] = useState(""); const [mediaKind, setMediaKind] = useState("series"); const [mode, setMode] = useState("continuous"); const [scope, setScope] = useState("following_seasons"); const [priority, setPriority] = useState("tracking"); const [errors, setErrors] = useState>({}); 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 ( !o && !create.isPending && onClose()}> 新建订阅 选择库里的作品,或先按标题追踪,作品建档后自动关联。
{[ [false, "选择作品"], [true, "按标题追踪"], ].map(([v, l]) => ( ))}
{byTitle ? ( setTitleQuery(e.target.value)} /> ) : entity ? (

{entity.title} {entity.year ? ({entity.year}) : null}

{MEDIA_KIND[entity.kind]}

) : (
setTerm(e.target.value)} />
{search.isPending ? (

搜索中…

) : search.isError ? (

{errorMessage(search.error)}

) : (search.data?.items ?? []).filter((e) => e.status !== "merged").length === 0 ? (

没有匹配的作品,可以改用"按标题追踪"。

) : (
    {(search.data?.items ?? []) .filter((e) => e.status !== "merged") .map((e) => (
  • ))}
)}
)}
{ setMediaKind(k); if (k === "movie") setMode("once"); }} options={SubscriptionMediaKind.options} labels={SUB_KIND_LABEL} disabled={!byTitle && !!entity} />
); }