feat(web): subscriptions overview groups, coverage matrix view and v2 form (FE-5)

- Overview: kind tabs, five groups with server counts, card/list views,
  slot badges, progress (unknown total = "?"), filters in URL, batch actions
- Detail /console/subscriptions/🆔 season tabs, episode x version-target
  matrix with cell sheet (manual fill, plan link), drag slot order,
  auto-fill switch and fill budget, refresh coverage
- Create/edit dialog (basic/advanced): work picker via global search,
  season/range/total, sites, rule groups, include/exclude, templates,
  upgrade strategy, downloader/save path; 409 links to existing subscription

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:27:29 +08:00
1 parent 4deaaab6fc
commit b7a7fd3958
13 files changed
+3241 -726

No files matched your search

+161 -2
View File
@@ -1,15 +1,24 @@
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AcquisitionSlot,
cursorPage,
type EpisodeTotalSet,
MediaEntity,
Subscription,
type SubscriptionBatch,
SubscriptionBatchResult,
SubscriptionCoverage,
type SubscriptionCreate,
type SubscriptionGroup,
type SubscriptionMediaKind,
type SubscriptionOrigin,
SubscriptionOverviewPage,
SubscriptionProgress,
type SubscriptionSort,
type SubscriptionStatus,
type SubscriptionUpdate,
} from "@umt/contracts";
import { z } from "zod";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
@@ -18,6 +27,9 @@ import { getParsed, parseOrThrow } from "./library.ts";
export const subscriptionKeys = {
all: ["subscriptions"] as const,
list: (f: SubscriptionFilters) => ["subscriptions", "list", f] as const,
overview: (f: OverviewFilters) => ["subscriptions", "overview", f] as const,
detail: (id: string) => ["subscriptions", "detail", id] as const,
coverage: (id: string) => ["subscriptions", "coverage", id] as const,
};
export type SubscriptionFilters = {
@@ -59,7 +71,11 @@ export function useUpdateSubscription() {
return useMutation({
mutationFn: ({ id, ...body }: SubscriptionUpdate & { id: string }) =>
api.patch<unknown>(`/subscriptions/${id}`, body).then((d) => parseOrThrow(Subscription, d, "/subscriptions")),
onSuccess: () => void qc.invalidateQueries({ queryKey: subscriptionKeys.all }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: subscriptionKeys.all });
// best_version 写到版本目标的升级策略
void qc.invalidateQueries({ queryKey: ["slots"] });
},
});
}
@@ -84,8 +100,10 @@ export function useSubscriptionProgress(id: string | null | undefined, enabled =
/** 手动主动搜索:按标题/别名在已开启站点搜索,结果走同一识别评估链 */
export function useSearchSubscription() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.post<{ runId: string; deduplicated: boolean }>(`/subscriptions/${id}/search`),
onSuccess: () => void qc.invalidateQueries({ queryKey: ["subscriptions", "overview"] }),
});
}
@@ -97,6 +115,147 @@ export function useSetEpisodeTotal() {
api
.post<unknown>(`/subscriptions/${id}/episode-total`, body)
.then((d) => parseOrThrow(SubscriptionProgress, d, "/subscriptions/episode-total")),
onSuccess: (_d, v) => void qc.invalidateQueries({ queryKey: ["subscriptions", "progress", v.id] }),
onSuccess: (_d, v) => {
void qc.invalidateQueries({ queryKey: ["subscriptions", "progress", v.id] });
void qc.invalidateQueries({ queryKey: subscriptionKeys.coverage(v.id) });
void qc.invalidateQueries({ queryKey: ["subscriptions", "overview"] });
},
});
}
// ── W9-E 总览 / 覆盖矩阵 / 动作(docs/runbook/subscriptions.md) ──
export type OverviewFilters = {
mediaKind?: SubscriptionMediaKind;
group?: SubscriptionGroup;
sort?: SubscriptionSort;
order?: "asc" | "desc";
siteId?: string;
ruleGroupId?: string;
origin?: SubscriptionOrigin;
upgrading?: "true" | "false";
q?: string;
};
/** 总览:条目带分组/进度/版本徽标;counts 是当前筛选(不含 group)下各分组数量 */
export function useSubscriptionOverview(filters: OverviewFilters) {
return useInfiniteQuery({
queryKey: subscriptionKeys.overview(filters),
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam, signal }) =>
getParsed(SubscriptionOverviewPage, "/subscriptions", { ...filters, cursor: pageParam, limit: 100 }, signal),
getNextPageParam: (last) => last.nextCursor ?? undefined,
placeholderData: keepPreviousData,
});
}
export function useSubscription(id: string | undefined) {
return useQuery({
queryKey: subscriptionKeys.detail(id ?? ""),
enabled: !!id,
queryFn: ({ signal }) => getParsed(Subscription, `/subscriptions/${id}`, undefined, signal),
});
}
/** 单条总览条目(详情页页头:进度/徽标/自动补全来源);按作品筛选后取同 id */
export function useSubscriptionOverviewItem(sub: Subscription | undefined) {
return useQuery({
queryKey: ["subscriptions", "overview-item", sub?.id ?? "", sub?.updatedAt ?? ""],
enabled: !!sub,
queryFn: async ({ signal }) => {
const page = await getParsed(
SubscriptionOverviewPage,
"/subscriptions",
{
...(sub!.mediaEntityId ? { mediaEntityId: sub!.mediaEntityId } : { q: sub!.titleQuery ?? undefined }),
...(sub!.status === "cancelled" ? { status: "cancelled" } : {}),
limit: 100,
},
signal,
);
return page.items.find((x) => x.id === sub!.id) ?? null;
},
});
}
/** 覆盖矩阵;refresh() 跳过服务端 30s 缓存 */
export function useSubscriptionCoverage(id: string | undefined) {
return useQuery({
queryKey: subscriptionKeys.coverage(id ?? ""),
enabled: !!id,
queryFn: ({ signal }) => getParsed(SubscriptionCoverage, `/subscriptions/${id}/coverage`, undefined, signal),
});
}
export function useRefreshCoverage() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => getParsed(SubscriptionCoverage, `/subscriptions/${id}/coverage`, { refresh: "true" }),
onSuccess: (d, id) => qc.setQueryData(subscriptionKeys.coverage(id), d),
});
}
function useInvalidateSubs() {
const qc = useQueryClient();
return () => {
void qc.invalidateQueries({ queryKey: subscriptionKeys.all });
void qc.invalidateQueries({ queryKey: ["slots"] });
};
}
export type SubscriptionAction = "pause" | "resume" | "resubscribe";
/** 暂停 / 恢复 / 重新订阅 */
export function useSubscriptionAction() {
const done = useInvalidateSubs();
return useMutation({
mutationFn: ({ id, action }: { id: string; action: SubscriptionAction }) =>
api
.post<unknown>(`/subscriptions/${id}/${action}`)
.then((d) => parseOrThrow(Subscription, d, `/subscriptions/${action}`)),
onSuccess: done,
});
}
/** 批量:search / pause / resume / delete(删除 = 取消,写审计);逐条返回结果 */
export function useSubscriptionBatch() {
const done = useInvalidateSubs();
return useMutation({
mutationFn: (body: SubscriptionBatch) =>
api
.post<unknown>("/subscriptions/batch", body)
.then((d) => parseOrThrow(SubscriptionBatchResult, d, "/subscriptions/batch")),
onSuccess: done,
});
}
/** 手动补全某版本:不受自动补全开关与补全预算限制 */
export function useFillSlot() {
const done = useInvalidateSubs();
return useMutation({
mutationFn: ({ id, slotId, unitKeys }: { id: string; slotId: string; unitKeys?: string[] }) =>
api.post<{ runId: string; deduplicated: boolean }>(
`/subscriptions/${id}/slots/${slotId}/fill`,
unitKeys?.length ? { unitKeys } : {},
),
onSuccess: done,
});
}
/** 版本目标排序:第一个成为主版本 */
export function useSetSlotOrder() {
const done = useInvalidateSubs();
return useMutation({
mutationFn: ({ id, slotIds }: { id: string; slotIds: string[] }) =>
api
.put<unknown>(`/subscriptions/${id}/slot-order`, { slotIds })
.then((d) => parseOrThrow(z.array(AcquisitionSlot), d, "/subscriptions/slot-order")),
onSuccess: done,
});
}
/** 409 重复订阅:消息里带已有订阅 id */
export function duplicateSubscriptionId(message: string): string | null {
const m = /已有订阅[::]\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(message);
return m?.[1] ?? null;
}
@@ -0,0 +1,207 @@
import type { Subscription } from "@umt/contracts";
import { Grid3x3, MoreHorizontal, Pause, Pencil, Play, RotateCcw, Search, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import {
type SubscriptionAction,
useSearchSubscription,
useSubscriptionAction,
useSubscriptionBatch,
} from "~/api/subscriptions.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
// 订阅操作:立即搜索 / 暂停·恢复 / 编辑 / 重新订阅 / 删除 / 查看对比(总览卡片菜单与详情页共用)。
type Sub = Pick<Subscription, "id" | "status" | "mediaEntityId">;
const tracking = (s: Sub) => s.status === "active" || s.status === "pending";
export function useSubscriptionOps() {
const search = useSearchSubscription();
const action = useSubscriptionAction();
const batch = useSubscriptionBatch();
const pending = search.isPending || action.isPending || batch.isPending;
return {
pending,
search: (id: string) =>
search.mutate(id, {
onSuccess: (r) => toast.success(r.deduplicated ? "搜索已在进行" : "已开始搜索"),
onError: (e) => toast.error(errorMessage(e, "搜索失败")),
}),
act: (id: string, a: SubscriptionAction) =>
action.mutate(
{ id, action: a },
{
onSuccess: () => toast.success({ pause: "已暂停", resume: "已恢复", resubscribe: "已重新订阅" }[a]),
onError: (e) => toast.error(errorMessage(e)),
},
),
remove: (ids: string[]) =>
batch.mutateAsync({ ids, action: "delete" }).then((r) => {
const bad = r.results.filter((x) => !x.ok);
if (bad.length) toast.error(`${bad.length} 个没有删除:${bad[0]?.error ?? ""}`);
else toast.success(ids.length > 1 ? `已删除 ${ids.length} 个订阅` : "订阅已删除");
}),
batch: (ids: string[], a: "search" | "pause" | "resume") =>
batch.mutate(
{ ids, action: a },
{
onSuccess: (r) => {
const ok = r.results.filter((x) => x.ok).length;
const label = { search: "开始搜索", pause: "暂停", resume: "恢复" }[a];
if (ok === ids.length) toast.success(`${ok} 个订阅已${label}`);
else toast.warning(`${ok}/${ids.length} 个已${label};${r.results.find((x) => !x.ok)?.error ?? ""}`);
},
onError: (e) => toast.error(errorMessage(e)),
},
),
};
}
function MenuItem({
icon,
children,
onClick,
danger,
disabled,
}: {
icon: ReactNode;
children: ReactNode;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
}) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={onClick}
className={cn(
"flex h-9 w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 text-left text-sm text-foreground transition-colors duration-micro hover:bg-foreground/[0.06] disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:text-muted-foreground",
danger && "text-destructive [&_svg]:text-destructive",
)}
>
{icon}
{children}
</button>
);
}
/** 卡片 / 行的"更多"菜单 */
export function SubscriptionMenu({
sub,
onEdit,
showDetail = true,
trigger,
}: {
sub: Sub;
onEdit: () => void;
showDetail?: boolean;
trigger?: ReactNode;
}) {
const ops = useSubscriptionOps();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [confirm, setConfirm] = useState(false);
const run = (fn: () => void) => () => {
setOpen(false);
fn();
};
const ended = sub.status === "completed" || sub.status === "cancelled";
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{trigger ?? (
<Button
variant="ghost"
size="icon-sm"
aria-label="订阅操作"
onClick={(e) => e.stopPropagation()}
className="shrink-0"
>
<MoreHorizontal />
</Button>
)}
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-1.5" role="menu" onClick={(e) => e.stopPropagation()}>
{showDetail ? (
<MenuItem icon={<Grid3x3 />} onClick={run(() => navigate(consolePath("subscriptions", sub.id)))}>
查看对比
</MenuItem>
) : null}
{tracking(sub) && sub.mediaEntityId ? (
<MenuItem icon={<Search />} disabled={ops.pending} onClick={run(() => ops.search(sub.id))}>
立即搜索
</MenuItem>
) : null}
{sub.status !== "cancelled" ? (
<MenuItem icon={<Pencil />} onClick={run(onEdit)}>
编辑
</MenuItem>
) : null}
{tracking(sub) ? (
<MenuItem icon={<Pause />} disabled={ops.pending} onClick={run(() => ops.act(sub.id, "pause"))}>
暂停
</MenuItem>
) : sub.status === "paused" ? (
<MenuItem icon={<Play />} disabled={ops.pending} onClick={run(() => ops.act(sub.id, "resume"))}>
恢复
</MenuItem>
) : null}
{ended ? (
<MenuItem icon={<RotateCcw />} disabled={ops.pending} onClick={run(() => ops.act(sub.id, "resubscribe"))}>
重新订阅
</MenuItem>
) : null}
{sub.status !== "cancelled" ? (
<MenuItem icon={<Trash2 />} danger onClick={run(() => setConfirm(true))}>
删除
</MenuItem>
) : null}
</PopoverContent>
</Popover>
<DeleteSubscriptionsDialog open={confirm} onOpenChange={setConfirm} ids={[sub.id]} />
</>
);
}
export function DeleteSubscriptionsDialog({
open,
onOpenChange,
ids,
onDone,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
ids: string[];
onDone?: () => void;
}) {
const ops = useSubscriptionOps();
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title={ids.length > 1 ? `删除 ${ids.length} 个订阅?` : "删除这个订阅?"}
description="不再搜索和追更。已入库文件、版本目标和下载记录保留,可在订阅历史里重新订阅。"
confirmLabel="删除"
destructive
onConfirm={() =>
ops
.remove(ids)
.then(() => onDone?.())
.catch((e) => {
toast.error(errorMessage(e));
throw e;
})
}
/>
);
}
@@ -0,0 +1,325 @@
import type { CoverageCell, SubscriptionCoverage } from "@umt/contracts";
import { Clock, Download, ExternalLink, Wand2 } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useFillSlot } from "~/api/subscriptions.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import {
CELL_LABEL,
type CellKind,
type CoverageColumn,
cellKind,
columnLabel,
PLAN_STAGE,
SKIP_REASON,
seasonSlices,
seasonTitle,
} from "./model.ts";
// 覆盖矩阵(设计 §2.1):列 = 集(电影 = 内容版本),行 = 版本目标,首行"库内任一版本"。
// 底色无彩,只有状态用状态色:已入库 success、缺失 destructive、可升级/延后 warning;在途用斜纹。
type Row = SubscriptionCoverage["rows"][number];
/** 格样式:每种都配文字(悬浮 + 侧栏),不只靠颜色 */
const CELL_CLS: Record<CellKind, string> = {
have: "border-success/50 bg-success/70",
upgradable:
"border-success/50 bg-success/70 after:absolute after:top-0 after:right-0 after:h-0 after:w-0 after:border-t-[7px] after:border-l-[7px] after:border-t-warning after:border-l-transparent",
inflight:
"border-foreground/40 bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--color-foreground)_45%,transparent)_0_2px,transparent_2px_5px)]",
deferred: "border-warning/70 bg-transparent",
missing: "border-destructive/70 bg-transparent",
suspected: "border-dashed border-destructive/60 bg-transparent",
not_aired: "border-transparent bg-foreground/[0.10]",
skipped: "border-transparent bg-foreground/[0.04]",
};
const LEGEND: CellKind[] = [
"have",
"upgradable",
"inflight",
"missing",
"suspected",
"deferred",
"not_aired",
"skipped",
];
function CellSwatch({ k, className }: { k: CellKind; className?: string }) {
return (
<span
aria-hidden
className={cn(
"relative inline-grid h-3.5 w-3.5 shrink-0 place-items-center overflow-hidden rounded-tight border",
CELL_CLS[k],
className,
)}
>
{k === "deferred" ? <Clock className="h-2.5 w-2.5 text-warning" /> : null}
</span>
);
}
export function CoverageLegend() {
return (
<ul className="flex flex-wrap gap-x-3 gap-y-1 text-meta text-muted-foreground">
{LEGEND.map((k) => (
<li key={k} className="flex items-center gap-1.5">
<CellSwatch k={k} />
{CELL_LABEL[k]}
</li>
))}
</ul>
);
}
function cellTitle(row: Row, col: CoverageColumn, c: CoverageCell) {
const k = cellKind(c);
const parts = [`${row.name} · ${columnLabel(col)}`, CELL_LABEL[k]];
if (c.quality?.label) parts.push(c.quality.label);
if (c.pri !== null) parts.push(`pri ${c.pri}`);
if (c.planStage) parts.push(PLAN_STAGE[c.planStage] ?? c.planStage);
if (c.reason && SKIP_REASON[c.reason]) parts.push(SKIP_REASON[c.reason]);
return parts.join(" · ");
}
export type SelectedCell = { row: Row; col: CoverageColumn; cell: CoverageCell; season: string };
export function CoverageMatrix({
cov,
seasonIndex,
onSelect,
}: {
cov: SubscriptionCoverage;
seasonIndex: number;
onSelect: (s: SelectedCell) => void;
}) {
const season = cov.seasons[seasonIndex];
const slice = seasonSlices(cov)[seasonIndex];
if (!season || !slice) return null;
const cols = season.columns;
if (cols.length === 0)
return (
<p className="py-6 text-center text-caption text-muted-foreground">这一季还没有集信息。设置总集数后按集显示。</p>
);
const sTitle = seasonTitle(season, cov.mediaKind);
const wide = cov.mediaKind === "movie";
return (
<div className="-mx-4 overflow-x-auto px-4 sm:-mx-5 sm:px-5">
<table className="border-separate border-spacing-[3px] text-meta" aria-label={`${sTitle}覆盖矩阵`}>
<thead>
<tr>
<th
scope="col"
className="sticky left-0 z-10 bg-surface-raised pr-2 text-left font-normal text-muted-foreground"
>
版本目标
</th>
{cols.map((c) => (
<th
key={c.unitKey}
scope="col"
title={
[c.airDate ? `播出 ${c.airDate}` : null, c.temporary ? "临时单元" : null]
.filter(Boolean)
.join(" · ") || undefined
}
className={cn(
"px-0 font-mono font-normal text-muted-foreground tabular-nums",
wide ? "min-w-24" : "w-7 min-w-7",
c.temporary && "italic",
)}
>
{wide ? columnLabel(c) : (c.episode ?? columnLabel(c))}
</th>
))}
</tr>
</thead>
<tbody>
{cov.rows.map((row) => {
const cells = row.cells.slice(slice.start, slice.end);
const any = row.slotId === null;
return (
<tr key={row.slotId ?? "any"}>
<th
scope="row"
className={cn(
"sticky left-0 z-10 max-w-36 truncate bg-surface-raised pr-2 text-left font-medium",
any ? "text-muted-foreground" : "text-foreground",
)}
title={row.name}
>
{row.name}
{row.primary ? <span className="ml-1 font-normal text-muted-foreground">主</span> : null}
</th>
{cells.map((c, i) => {
const col = cols[i]!;
const k = cellKind(c);
return (
<td key={c.unitKey} className="p-0">
<button
type="button"
onClick={() => onSelect({ row, col, cell: c, season: sTitle })}
title={cellTitle(row, col, c)}
aria-label={cellTitle(row, col, c)}
className={cn(
"relative grid cursor-pointer place-items-center overflow-hidden rounded-tight border outline-none transition-transform duration-micro hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring",
wide ? "h-7 w-full px-2" : "h-7 w-7",
CELL_CLS[k],
any && "opacity-80",
)}
>
{k === "deferred" ? <Clock className="h-3 w-3 text-warning" /> : null}
{wide && c.quality?.label ? (
<span className="truncate text-micro font-medium text-success-foreground">
{c.quality.label}
</span>
) : null}
</button>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/** 点格打开:资产 / 质量 / pri / 计划 / 原因 / 可升级 / 延后 → 手动补全、跳下载计划 */
export function CellSheet({
sel,
subscriptionId,
mediaEntityId,
canFill,
onClose,
}: {
sel: SelectedCell | null;
subscriptionId: string;
mediaEntityId: string | null;
canFill: boolean;
onClose: () => void;
}) {
const fill = useFillSlot();
const [done, setDone] = useState<string | null>(null);
const c = sel?.cell;
const k = c ? cellKind(c) : null;
const slotId = sel?.row.slotId ?? null;
const fillable = !!slotId && !!c && (c.state === "missing" || c.upgradable);
const doFill = () =>
slotId &&
c &&
fill.mutate(
{ id: subscriptionId, slotId, unitKeys: [c.unitKey] },
{
onSuccess: (r) => {
setDone(c.unitKey);
toast.success(r.deduplicated ? "补全任务已在排队" : "已开始补全这一集");
},
onError: (e) => toast.error(errorMessage(e, "补全失败")),
},
);
return (
<DetailSheet
open={!!sel}
onOpenChange={(o) => {
if (!o) {
setDone(null);
onClose();
}
}}
title={sel ? `${sel.season} · ${columnLabel(sel.col)}` : ""}
description={sel ? `${sel.row.name}${sel.row.slotId === null ? "(作品全部资产)" : ""}` : null}
footer={
sel && c ? (
<>
{c.planId ? (
<Button asChild variant="outline">
<Link to={`${consolePath("downloads", "plans")}?plan=${c.planId}`}>
<Download />
下载计划
</Link>
</Button>
) : null}
{fillable ? (
<Button
onClick={doFill}
disabled={!canFill || fill.isPending || done === c.unitKey}
title={canFill ? undefined : "订阅不在追踪中,先恢复"}
>
<Wand2 />
{done === c.unitKey ? "已提交" : c.upgradable ? "补全更好版本" : "手动补全"}
</Button>
) : null}
</>
) : null
}
>
{sel && c && k ? (
<>
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<CellSwatch k={k} className="h-4 w-4" />
{CELL_LABEL[k]}
</div>
<DetailSection title="详情">
<KvList
items={[
{ label: "单元", value: sel.col.unitKey, mono: true },
...(sel.col.airDate ? [{ label: "播出日期", value: sel.col.airDate }] : []),
...(sel.col.temporary ? [{ label: "临时单元", value: "还没有正式的内容单元" }] : []),
{
label: "质量",
value:
c.quality?.label ?? ([c.quality?.resolution, c.quality?.source].filter(Boolean).join(" ") || null),
},
{ label: "pri", value: c.pri !== null ? String(c.pri) : null },
{ label: "可升级", value: c.state === "have" ? (c.upgradable ? "是,低于目标档位" : "否") : null },
{
label: "资产",
value: c.assetId ? (
mediaEntityId ? (
<Link
className="inline-flex items-center gap-1 underline-offset-4 hover:underline"
to={consolePath("library", mediaEntityId)}
>
<span className="font-mono">{c.assetId.slice(0, 8)}</span>
<ExternalLink className="h-3 w-3" />
</Link>
) : (
<span className="font-mono">{c.assetId}</span>
)
) : null,
},
{
label: "下载计划",
value: c.planId
? `${PLAN_STAGE[c.planStage ?? ""] ?? c.planStage ?? "在途"} · ${c.planId.slice(0, 8)}`
: null,
},
{ label: "原因", value: c.reason ? (SKIP_REASON[c.reason] ?? c.reason) : null },
{ label: "延后", value: c.deferred ? "有资源在等待重评(预算或自动补全关闭)" : null },
]}
/>
</DetailSection>
{fillable ? (
<p className="text-meta text-muted-foreground">
手动补全不受自动补全开关和补全预算限制,仍受全局每日预算与缓存卷阈值约束。
</p>
) : null}
{sel.row.slotId === null && c.state === "missing" ? (
<p className="text-meta text-muted-foreground">这一行汇总全部版本。要补全,点具体版本目标那一行的格。</p>
) : null}
</>
) : null}
</DetailSheet>
);
}
@@ -0,0 +1,156 @@
import type { CoverageCell, Subscription, SubscriptionSlotSummary } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import {
cellKind,
draftFromSubscription,
draftToParams,
emptyDraft,
foldNames,
moveItem,
progressText,
seasonLabel,
slotBadge,
upgradeOf,
} from "./model.ts";
const slot = (x: Partial<SubscriptionSlotSummary>): SubscriptionSlotSummary => ({
slotId: "s",
name: "4K",
primary: true,
status: "empty",
have: 0,
inflight: 0,
missing: 0,
total: null,
upgrading: false,
minPri: null,
targetPri: null,
autoFill: true,
ruleGroupIds: [],
...x,
});
const cell = (x: Partial<CoverageCell>): CoverageCell => ({
unitKey: "s1e1",
state: "missing",
assetId: null,
quality: null,
pri: null,
planId: null,
planStage: null,
reason: null,
upgradable: false,
deferred: false,
...x,
});
describe("订阅总览文案", () => {
it("进度总未知显示 ?", () => {
expect(progressText({ have: 3, total: null, inflight: 0 })).toBe("3/?");
expect(progressText({ have: 3, total: 10, inflight: 1 })).toBe("3/10");
});
it("季标签:特别篇 / 接续 / 电影", () => {
expect(seasonLabel({ mediaKind: "series", seasonNumber: 0, seasonScope: "current_season" })).toBe("特别篇");
expect(seasonLabel({ mediaKind: "series", seasonNumber: 2, seasonScope: "following_seasons" })).toBe("S2 起");
expect(seasonLabel({ mediaKind: "series", seasonNumber: 1, seasonScope: "current_season" })).toBe("S1");
expect(seasonLabel({ mediaKind: "movie", seasonNumber: null, seasonScope: "current_season" })).toBeNull();
});
it("版本徽标按 洗版 → 在途 → 缺 → 已齐 判定", () => {
expect(slotBadge(slot({ upgrading: true, minPri: 97, targetPri: 100, missing: 2 })).label).toBe("洗版中 97→100");
expect(slotBadge(slot({ inflight: 2, missing: 3 }))).toEqual({ label: "在途 2", tone: "neutral" });
expect(slotBadge(slot({ missing: 3 })).tone).toBe("destructive");
expect(slotBadge(slot({ have: 10, total: 10 }))).toEqual({ label: "已齐", tone: "success" });
expect(slotBadge(slot({})).label).toBe("未开始");
});
it("名称折叠", () => {
expect(foldNames([])).toBe("全部站点");
expect(foldNames(["a", "b", "c", "d"])).toBe("a、b +2");
});
});
describe("覆盖矩阵格", () => {
it("state + 附带字段 → 显示类别", () => {
expect(cellKind(cell({ state: "have" }))).toBe("have");
expect(cellKind(cell({ state: "have", upgradable: true }))).toBe("upgradable");
expect(cellKind(cell({ state: "missing", reason: "suspected" }))).toBe("suspected");
expect(cellKind(cell({ state: "missing", deferred: true }))).toBe("deferred");
expect(cellKind(cell({ state: "skipped", reason: "not_aired" }))).toBe("not_aired");
expect(cellKind(cell({ state: "skipped", reason: "out_of_range" }))).toBe("skipped");
expect(cellKind(cell({ state: "inflight" }))).toBe("inflight");
});
it("拖动排序", () => {
expect(moveItem(["a", "b", "c"], 2, 0)).toEqual(["c", "a", "b"]);
expect(moveItem(["a", "b"], 0, 5)).toEqual(["a", "b"]);
});
it("升级策略取自版本目标", () => {
expect(upgradeOf([{ upgradeThreshold: {} }], "series")).toBe("off");
expect(upgradeOf([{ upgradeThreshold: { enabled: true, mode: "full_pack" } }], "series")).toBe("full_pack");
expect(upgradeOf([{ upgradeThreshold: { enabled: true } }], "movie")).toBe("movie");
});
});
describe("表单 ↔ params", () => {
it("保留未知键,空值不写,只在改动时带 best_version / total_episode", () => {
const d = {
...emptyDraft("series"),
siteIds: ["x"],
episodeStart: "3",
include: "中字\n\n 4K ",
autoFill: "off" as const,
unitsPerDay: "2",
gbPerDay: "1.5",
total: "12",
upgrade: "full_pack" as const,
};
const { params, errors } = draftToParams(
d,
{ custom: 1, keywords: ["旧"] },
{ upgradeChanged: true, totalChanged: true },
);
expect(errors).toEqual({});
expect(params).toEqual({
custom: 1,
siteIds: ["x"],
include: ["中字", "4K"],
episodeRange: { start: 3 },
total_episode: 12,
manual_total_episode: false,
autoFill: "off",
fillBudget: { unitsPerDay: 2, bytesPerDay: Math.round(1.5 * 1024 ** 3) },
best_version: true,
best_version_full: true,
});
const same = draftToParams(d, {}, { upgradeChanged: false, totalChanged: false }).params;
expect(same.best_version).toBeUndefined();
expect(same.total_episode).toBeUndefined();
});
it("校验:结束集 < 开始集", () => {
const { errors } = draftToParams(
{ ...emptyDraft("series"), episodeStart: "5", episodeEnd: "2" },
{},
{ upgradeChanged: false, totalChanged: false },
);
expect(errors.episodeEnd).toBeTruthy();
});
it("从订阅回填", () => {
const sub = {
mediaKind: "series",
seasonUnitId: null,
seasonScope: "current_season",
mode: "continuous",
priority: "tracking",
policyRevisionId: null,
params: {
episodeRange: { start: 2 },
autoFill: "on",
fillBudget: { bytesPerDay: 2 * 1024 ** 3 },
keywords: ["a", "b"],
},
} as unknown as Subscription;
const d = draftFromSubscription(sub, "per_unit");
expect(d.episodeStart).toBe("2");
expect(d.autoFill).toBe("on");
expect(d.gbPerDay).toBe("2");
expect(d.keywords).toBe("a\nb");
expect(d.upgrade).toBe("per_unit");
});
});
@@ -0,0 +1,382 @@
import type {
AcquisitionSlot,
AutoFillMode,
CoverageCell,
JsonObject,
Subscription,
SubscriptionCoverage,
SubscriptionGroup,
SubscriptionMediaKind,
SubscriptionOverviewItem,
SubscriptionSlotSummary,
} from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
// 订阅页(FE-5)的纯函数:分组/徽标/进度文案、矩阵格语义、表单 ↔ params。docs/design/subscriptions.md §1–§3。
export const SUB_KIND_LABEL: Record<SubscriptionMediaKind, string> = { series: "剧集", movie: "电影", variety: "综艺" };
/** 分组展示顺序(设计 §1.1:待定、追更中、待搜索、已完成、暂停) */
export const GROUP_ORDER: SubscriptionGroup[] = ["pending", "following", "waiting", "completed", "paused"];
export const GROUP_LABEL: Record<SubscriptionGroup, string> = {
following: "追更中",
waiting: "待搜索",
completed: "已完成",
paused: "暂停",
pending: "待定",
};
export type ToneLabel = { label: string; tone: StatusTone };
/** 进度文字:已入库/总;总未知为 "?" */
export function progressText(p: SubscriptionOverviewItem["progress"]): string {
return `${p.have}/${p.total ?? "?"}`;
}
/** 进度两段(已入库、在途)占比;总未知时按 已入库+在途 为分母只画相对比例 */
export function progressParts(p: SubscriptionOverviewItem["progress"]): { have: number; inflight: number } {
const denom = p.total ?? 0;
if (denom <= 0) return { have: 0, inflight: 0 };
return { have: Math.min(1, p.have / denom), inflight: Math.min(1 - Math.min(1, p.have / denom), p.inflight / denom) };
}
/** 季标签:S0 → 特别篇;接续后续季 → "S2 起";电影 / 全部季 → null */
export function seasonLabel(
x: Pick<SubscriptionOverviewItem, "mediaKind" | "seasonNumber" | "seasonScope">,
): string | null {
if (x.mediaKind === "movie") return null;
if (x.seasonNumber === null) return x.seasonScope === "following_seasons" ? "全部季" : null;
const base = x.seasonNumber === 0 ? "特别篇" : `S${x.seasonNumber}`;
return x.seasonScope === "following_seasons" && x.seasonNumber > 0 ? `${base} 起` : base;
}
/** 版本目标徽标:洗版中 → 在途 → 缺 N → 已齐;都没有 → 已入库 N */
export function slotBadge(s: SubscriptionSlotSummary): ToneLabel {
if (s.upgrading)
return {
label: s.minPri !== null && s.targetPri !== null ? `洗版中 ${s.minPri}→${s.targetPri}` : "洗版中",
tone: "warning",
};
if (s.inflight > 0) return { label: `在途 ${s.inflight}`, tone: "neutral" };
if (s.missing > 0) return { label: `缺 ${s.missing}`, tone: "destructive" };
if (s.total !== null && s.total > 0 && s.have >= s.total) return { label: "已齐", tone: "success" };
return { label: s.have > 0 ? `已入库 ${s.have}` : "未开始", tone: "neutral" };
}
/** 名称列表折叠:≤max 个名 + "+N";空 = emptyLabel */
export function foldNames(names: string[], max = 2, emptyLabel = "全部站点"): string {
if (names.length === 0) return emptyLabel;
const head = names.slice(0, max).join("、");
return names.length > max ? `${head} +${names.length - max}` : head;
}
export const AUTOFILL_LABEL: Record<AutoFillMode, string> = { inherit: "跟随规则组", on: "开", off: "关" };
export const AUTOFILL_SOURCE: Record<"subscription" | "rule_group" | "default", string> = {
subscription: "订阅设置",
rule_group: "规则组",
default: "系统缺省",
};
export function autoFillText(a: SubscriptionOverviewItem["autoFill"]): string {
return `自动补全${a.enabled ? "开" : "关"}${a.mode === "inherit" ? `(${AUTOFILL_SOURCE[a.source]})` : ""}`;
}
// ── 覆盖矩阵 ──
export type CoverageColumn = SubscriptionCoverage["seasons"][number]["columns"][number];
/** 各季在展平列里的起止下标 */
export function seasonSlices(cov: SubscriptionCoverage): { start: number; end: number }[] {
let i = 0;
return cov.seasons.map((s) => {
const start = i;
i += s.columns.length;
return { start, end: i };
});
}
export type CellKind =
| "have"
| "upgradable"
| "inflight"
| "deferred"
| "missing"
| "suspected"
| "not_aired"
| "skipped";
export function cellKind(c: CoverageCell): CellKind {
if (c.state === "have") return c.upgradable ? "upgradable" : "have";
if (c.state === "inflight") return "inflight";
if (c.state === "missing") {
if (c.deferred) return "deferred";
return c.reason === "suspected" ? "suspected" : "missing";
}
return c.reason === "not_aired" ? "not_aired" : "skipped";
}
export const CELL_LABEL: Record<CellKind, string> = {
have: "已入库",
upgradable: "可升级",
inflight: "在途",
deferred: "延后",
missing: "缺失",
suspected: "疑似缺失",
not_aired: "未播出",
skipped: "不需要",
};
export const PLAN_STAGE: Record<string, string> = { reserved: "已预留", downloading: "下载中", verifying: "核验中" };
export const SKIP_REASON: Record<string, string> = {
out_of_range: "集范围外",
not_aired: "未播出",
not_required: "该版本目标不要求这个内容版本",
suspected: "总集数未知,按已见最高集推断",
deferred: "有延后的资源",
};
/** 列头:E03 / 日期 / 电影内容版本 */
export function columnLabel(col: CoverageColumn): string {
if (col.edition) return col.edition === "default" ? "正片" : col.edition;
if (col.episode !== null) return `E${String(col.episode).padStart(2, "0")}`;
if (col.airDate) return col.airDate.slice(5);
return col.unitKey;
}
export function seasonTitle(s: SubscriptionCoverage["seasons"][number], kind: SubscriptionMediaKind): string {
if (kind === "movie") return "电影";
if (s.seasonNumber === null) return "按播出日期";
return s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`;
}
// ── 版本目标 ──
export type UpgradeStrategy = "off" | "per_unit" | "full_pack" | "movie";
export const UPGRADE_LABEL: Record<UpgradeStrategy, string> = {
off: "不洗版",
per_unit: "按集洗版",
full_pack: "全集洗版",
movie: "洗版",
};
/** 订阅版本目标当前的升级策略:任一开启取其 mode */
export function upgradeOf(
slots: Pick<AcquisitionSlot, "upgradeThreshold">[],
kind: SubscriptionMediaKind,
): UpgradeStrategy {
const on = slots.find((s) => (s.upgradeThreshold as { enabled?: unknown }).enabled === true);
if (!on) return "off";
if (kind === "movie") return "movie";
return (on.upgradeThreshold as { mode?: unknown }).mode === "full_pack" ? "full_pack" : "per_unit";
}
/** 把 from 移到 to(拖动排序) */
export function moveItem<T>(arr: readonly T[], from: number, to: number): T[] {
const out = arr.slice();
if (from < 0 || from >= out.length || to < 0 || to >= out.length || from === to) return out;
const [x] = out.splice(from, 1);
out.splice(to, 0, x as T);
return out;
}
// ── 表单 ──
export type SubscriptionDraft = {
mediaKind: SubscriptionMediaKind;
seasonUnitId: string;
seasonScope: Subscription["seasonScope"];
mode: Subscription["mode"];
priority: Subscription["priority"];
episodeStart: string;
episodeEnd: string;
total: string;
totalLocked: boolean;
siteIds: string[];
downloaderInstanceId: string;
libraryRootId: string;
downloadSavePath: string;
policyRevisionId: string;
upgrade: UpgradeStrategy;
ruleGroupIds: string[];
include: string;
exclude: string;
keywords: string;
searchIntervalHours: string;
stopAfter: string;
autoFill: AutoFillMode;
unitsPerDay: string;
gbPerDay: string;
};
const lines = (s: string) =>
s
.split(/\n/)
.map((x) => x.trim())
.filter(Boolean);
const str = (v: unknown) => (typeof v === "string" ? v : "");
const numStr = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? String(v) : "");
const strArr = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
export function emptyDraft(kind: SubscriptionMediaKind = "series"): SubscriptionDraft {
return {
mediaKind: kind,
seasonUnitId: "",
seasonScope: kind === "movie" ? "current_season" : "following_seasons",
mode: kind === "movie" ? "once" : "continuous",
priority: "tracking",
episodeStart: "",
episodeEnd: "",
total: "",
totalLocked: false,
siteIds: [],
downloaderInstanceId: "",
libraryRootId: "",
downloadSavePath: "",
policyRevisionId: "",
upgrade: "off",
ruleGroupIds: [],
include: "",
exclude: "",
keywords: "",
searchIntervalHours: "",
stopAfter: "",
autoFill: "inherit",
unitsPerDay: "",
gbPerDay: "",
};
}
const GB = 1024 ** 3;
/** datetime-local 值 ↔ ISO */
export function toLocalInput(iso: string): string {
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return "";
const p = (n: number) => String(n).padStart(2, "0");
return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}`;
}
export function draftFromSubscription(sub: Subscription, upgrade: UpgradeStrategy): SubscriptionDraft {
const p = sub.params as Record<string, unknown>;
const range = (p.episodeRange ?? {}) as { start?: unknown; end?: unknown };
const budget = (p.fillBudget ?? {}) as { unitsPerDay?: unknown; bytesPerDay?: unknown };
const bytes = typeof budget.bytesPerDay === "number" ? budget.bytesPerDay : null;
return {
...emptyDraft(sub.mediaKind),
mediaKind: sub.mediaKind,
seasonUnitId: sub.seasonUnitId ?? "",
seasonScope: sub.seasonScope,
mode: sub.mode,
priority: sub.priority,
episodeStart: numStr(range.start),
episodeEnd: numStr(range.end),
siteIds: strArr(p.siteIds),
downloaderInstanceId: str(p.downloaderInstanceId),
libraryRootId: str(p.libraryRootId),
downloadSavePath: str(p.downloadSavePath),
policyRevisionId: sub.policyRevisionId ?? "",
upgrade,
ruleGroupIds: strArr(p.ruleGroupIds),
include: strArr(p.include).join("\n"),
exclude: strArr(p.exclude).join("\n"),
keywords: strArr(p.keywords).join("\n"),
searchIntervalHours: numStr(p.searchIntervalHours),
stopAfter: typeof p.stopAfter === "string" ? toLocalInput(p.stopAfter) : "",
autoFill: p.autoFill === "on" || p.autoFill === "off" ? p.autoFill : "inherit",
unitsPerDay: numStr(budget.unitsPerDay),
gbPerDay: bytes !== null ? String(Math.round((bytes / GB) * 10) / 10) : "",
};
}
export type DraftErrors = Partial<Record<keyof SubscriptionDraft, string>>;
const isInt = (s: string) => /^\d+$/.test(s.trim());
/**
* 草稿 → params(PATCH 整体替换,所以以现有 params 为底保留未知键)。
* upgradeChanged:只有升级策略改了才带 best_version(否则每次保存都会 bump 版本目标 revision)。
*/
export function draftToParams(
d: SubscriptionDraft,
base: JsonObject = {},
opts: { upgradeChanged: boolean; totalChanged: boolean },
): { params: JsonObject; errors: DraftErrors } {
const errors: DraftErrors = {};
const out: Record<string, unknown> = { ...base };
const known = [
"siteIds",
"episodeRange",
"searchIntervalHours",
"stopAfter",
"downloaderInstanceId",
"ruleGroupIds",
"keywords",
"include",
"exclude",
"libraryRootId",
"downloadSavePath",
"autoFill",
"fillBudget",
];
for (const k of known) delete out[k];
const set = (k: string, v: unknown) => {
if (v === undefined || v === "" || (Array.isArray(v) && v.length === 0)) return;
out[k] = v;
};
set("siteIds", d.siteIds);
set("downloaderInstanceId", d.downloaderInstanceId);
set("libraryRootId", d.libraryRootId);
set("downloadSavePath", d.downloadSavePath.trim());
set("ruleGroupIds", d.ruleGroupIds);
set("include", lines(d.include));
set("exclude", lines(d.exclude));
const kw = lines(d.keywords);
if (kw.length > 10) errors.keywords = "最多 10 个";
set("keywords", kw);
if (d.mediaKind !== "movie") {
const s = d.episodeStart.trim();
const e = d.episodeEnd.trim();
if (s && (!isInt(s) || Number(s) < 1)) errors.episodeStart = "填正整数";
if (e && (!isInt(e) || Number(e) < 1)) errors.episodeEnd = "填正整数";
if (s && e && !errors.episodeStart && !errors.episodeEnd && Number(e) < Number(s))
errors.episodeEnd = "结束集不能小于开始集";
if ((s || e) && !errors.episodeStart && !errors.episodeEnd)
out.episodeRange = { ...(s ? { start: Number(s) } : {}), ...(e ? { end: Number(e) } : {}) };
const t = d.total.trim();
if (t) {
if (!isInt(t) || Number(t) < 1) errors.total = "填正整数";
else if (opts.totalChanged) {
out.total_episode = Number(t);
out.manual_total_episode = d.totalLocked;
}
}
}
const h = d.searchIntervalHours.trim();
if (h) {
if (!/^\d+(\.\d+)?$/.test(h) || Number(h) > 720) errors.searchIntervalHours = "0–720 小时";
else out.searchIntervalHours = Number(h);
}
if (d.stopAfter) {
const t = new Date(d.stopAfter);
if (Number.isNaN(t.getTime())) errors.stopAfter = "时间无效";
else out.stopAfter = t.toISOString();
}
if (d.autoFill !== "inherit") out.autoFill = d.autoFill;
const budget: Record<string, number> = {};
if (d.unitsPerDay.trim()) {
if (!isInt(d.unitsPerDay)) errors.unitsPerDay = "填非负整数";
else budget.unitsPerDay = Number(d.unitsPerDay);
}
if (d.gbPerDay.trim()) {
const g = Number(d.gbPerDay);
if (!Number.isFinite(g) || g < 0) errors.gbPerDay = "填非负数";
else budget.bytesPerDay = Math.round(g * GB);
}
if (Object.keys(budget).length) out.fillBudget = budget;
if (opts.upgradeChanged) {
out.best_version = d.upgrade !== "off";
if (d.mediaKind !== "movie") out.best_version_full = d.upgrade === "full_pack";
}
return { params: out as JsonObject, errors };
}
@@ -0,0 +1,243 @@
import type { SubscriptionOverviewItem } from "@umt/contracts";
import { Link } from "react-router";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx";
import { cn } from "~/lib/utils.ts";
import { SubscriptionMenu } from "./actions.tsx";
import {
autoFillText,
foldNames,
progressParts,
progressText,
SUB_KIND_LABEL,
seasonLabel,
slotBadge,
} from "./model.ts";
// 总览卡片 / 列表行(设计 §1.2)。点卡片 = 查看对比。
type Item = SubscriptionOverviewItem;
export const itemTitle = (x: Item) => x.mediaEntityTitle ?? x.titleQuery ?? "未命名";
/** 状态角标:只标 暂停 / 待定 / 洗版中 / 等待新季 */
function Flags({ x }: { x: Item }) {
return (
<>
{x.group === "paused" ? <StatusBadge tone="neutral">暂停</StatusBadge> : null}
{x.group === "pending" ? <StatusBadge tone="warning">{x.mediaEntityId ? "待定" : "尚未建档"}</StatusBadge> : null}
{x.upgrading ? <StatusBadge tone="warning">洗版中</StatusBadge> : null}
{x.awaitingNewSeason ? <StatusBadge tone="success">等待新季</StatusBadge> : null}
</>
);
}
/** 进度:数字 + 已入库/在途两段细条;总未知写"持续追踪" */
export function ProgressBlock({ x, className }: { x: Pick<Item, "progress" | "mode">; className?: string }) {
const parts = progressParts(x.progress);
const unknown = x.progress.total === null;
return (
<div className={cn("min-w-0", className)}>
<div className="flex items-baseline justify-between gap-2 text-meta">
<span className="font-medium text-foreground tabular-nums">
{progressText(x.progress)}
{x.progress.inflight > 0 ? (
<span className="font-normal text-muted-foreground"> · 在途 {x.progress.inflight}</span>
) : null}
</span>
{unknown ? <span className="text-muted-foreground">持续追踪</span> : null}
</div>
<div
className={cn(
"mt-1 flex h-1 w-full gap-px overflow-hidden rounded-full bg-foreground/[0.08]",
unknown &&
"bg-[repeating-linear-gradient(90deg,var(--color-foreground)_0_4px,transparent_4px_8px)] opacity-20",
)}
role="img"
aria-label={`已入库 ${x.progress.have},在途 ${x.progress.inflight},总 ${x.progress.total ?? "未知"}`}
>
{unknown ? null : (
<>
<div className="h-full bg-foreground/70" style={{ width: `${parts.have * 100}%` }} />
<div
className="h-full bg-[repeating-linear-gradient(135deg,var(--color-foreground)_0_2px,transparent_2px_4px)] opacity-50"
style={{ width: `${parts.inflight * 100}%` }}
/>
</>
)}
</div>
</div>
);
}
export function SlotBadges({ slots, max = 4 }: { slots: Item["slots"]; max?: number }) {
if (slots.length === 0) return <span className="text-meta text-muted-foreground">没有版本目标</span>;
return (
<div className="flex min-w-0 flex-wrap gap-1">
{slots.slice(0, max).map((s) => {
const b = slotBadge(s);
return (
<StatusBadge key={s.slotId} tone={b.tone} className="max-w-full">
<span className="max-w-24 truncate text-foreground" title={s.name}>
{s.name}
</span>
<span className="tabular-nums">{b.label}</span>
</StatusBadge>
);
})}
{slots.length > max ? <span className="text-meta text-muted-foreground">+{slots.length - max}</span> : null}
</div>
);
}
function nextSearchText(x: Item) {
if (!x.searchEnabled) return "搜索已关";
if (x.status === "paused" || x.status === "completed") return "—";
return x.nextSearchAt ? relTime(x.nextSearchAt) : "待排期";
}
function Meta({ x }: { x: Item }) {
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-meta">
<dt className="text-muted-foreground">上次更新</dt>
<dd className="truncate text-foreground tabular-nums">{relTime(x.lastUpdatedAt)}</dd>
<dt className="text-muted-foreground">下次搜索</dt>
<dd className="truncate text-foreground tabular-nums">{nextSearchText(x)}</dd>
<dt className="text-muted-foreground">站点</dt>
<dd className="truncate text-foreground" title={x.sites.map((s) => s.name).join("、")}>
{foldNames(x.sites.map((s) => s.name))}
</dd>
<dt className="text-muted-foreground">规则组</dt>
<dd className="truncate text-foreground" title={x.ruleGroups.map((s) => s.name).join("、")}>
{foldNames(
x.ruleGroups.map((s) => s.name),
2,
"未设置",
)}
</dd>
</dl>
);
}
function TitleLine({ x }: { x: Item }) {
const season = seasonLabel(x);
return (
<div className="min-w-0">
<p className="truncate text-body font-semibold text-foreground" title={itemTitle(x)}>
{itemTitle(x)}
</p>
<p className="truncate text-meta text-muted-foreground">
{[x.year, SUB_KIND_LABEL[x.mediaKind], season].filter(Boolean).join(" · ")}
</p>
</div>
);
}
export function SubscriptionCard({
x,
onEdit,
selected,
onSelect,
}: {
x: Item;
onEdit: () => void;
selected?: boolean;
onSelect?: (v: boolean) => void;
}) {
return (
<article
className={cn(
"pg-slab group relative flex min-w-0 flex-col gap-3 rounded-xl p-3.5 transition-shadow duration-micro hover:shadow-md",
selected && "ring-2 ring-foreground/40",
)}
>
<div className="flex min-w-0 items-start gap-3">
<CoverPlaceholder title={itemTitle(x)} kind={x.mediaKind} className="h-20 w-14" />
<div className="min-w-0 flex-1">
<Link
to={consolePath("subscriptions", x.id)}
className="block min-w-0 rounded-md outline-none after:absolute after:inset-0 after:content-[''] focus-visible:ring-2 focus-visible:ring-ring"
>
<TitleLine x={x} />
</Link>
<div className="relative mt-1.5 flex flex-wrap gap-1">
<Flags x={x} />
</div>
</div>
<div className="relative z-10 flex items-center gap-1">
{onSelect ? (
<input
type="checkbox"
aria-label={`选择 ${itemTitle(x)}`}
checked={!!selected}
onChange={(e) => onSelect(e.target.checked)}
className="h-4 w-4 cursor-pointer accent-foreground"
/>
) : null}
<SubscriptionMenu sub={x} onEdit={onEdit} />
</div>
</div>
<ProgressBlock x={x} />
<SlotBadges slots={x.slots} />
<Meta x={x} />
<p className="text-meta text-muted-foreground">{autoFillText(x.autoFill)}</p>
</article>
);
}
/** 列表视图的一行(桌面表格化,手机折成两行) */
export function SubscriptionRow({
x,
onEdit,
selected,
onSelect,
}: {
x: Item;
onEdit: () => void;
selected?: boolean;
onSelect?: (v: boolean) => void;
}) {
return (
<li
className={cn(
"relative grid min-w-0 grid-cols-[auto_1fr_auto] items-center gap-3 px-3 py-2.5 hover:bg-foreground/[0.03] md:grid-cols-[auto_minmax(0,2fr)_minmax(0,1.2fr)_minmax(0,2fr)_7rem_7rem_auto]",
selected && "bg-foreground/[0.05]",
)}
>
<div className="relative z-10 flex items-center gap-2">
{onSelect ? (
<input
type="checkbox"
aria-label={`选择 ${itemTitle(x)}`}
checked={!!selected}
onChange={(e) => onSelect(e.target.checked)}
className="h-4 w-4 cursor-pointer accent-foreground"
/>
) : null}
<CoverPlaceholder title={itemTitle(x)} kind={x.mediaKind} small className="h-10 w-7" />
</div>
<Link
to={consolePath("subscriptions", x.id)}
className="min-w-0 outline-none after:absolute after:inset-0 after:content-[''] focus-visible:underline"
>
<TitleLine x={x} />
</Link>
<ProgressBlock x={x} className="hidden md:block" />
<div className="relative hidden min-w-0 md:block">
<SlotBadges slots={x.slots} max={3} />
</div>
<span className="hidden text-meta text-muted-foreground tabular-nums md:block">{relTime(x.lastUpdatedAt)}</span>
<span className="hidden text-meta text-muted-foreground tabular-nums md:block">{nextSearchText(x)}</span>
<div className="relative z-10 flex items-center gap-1">
<span className="hidden flex-wrap gap-1 lg:flex">
<Flags x={x} />
</span>
<SubscriptionMenu sub={x} onEdit={onEdit} />
</div>
<div className="col-span-3 -mt-1 md:hidden">
<ProgressBlock x={x} />
</div>
</li>
);
}
@@ -0,0 +1,91 @@
import { ArrowDown, ArrowUp, GripVertical } from "lucide-react";
import { type ReactNode, useState } from "react";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
import { moveItem } from "./model.ts";
// 版本目标排序:拖动(桌面)或上移/下移(键盘、手机)。第一个是主版本。
export function SlotOrderList<T extends { id: string; name: string }>({
items,
onChange,
disabled,
renderExtra,
className,
}: {
items: T[];
onChange: (next: T[]) => void;
disabled?: boolean;
/** 行尾内容(计数、按钮) */
renderExtra?: (item: T, index: number) => ReactNode;
className?: string;
}) {
const [drag, setDrag] = useState<number | null>(null);
const [over, setOver] = useState<number | null>(null);
return (
<ol className={cn("divide-y divide-border/60 rounded-lg border border-border", className)}>
{items.map((it, i) => (
<li
key={it.id}
draggable={!disabled}
onDragStart={(e) => {
setDrag(i);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (drag === null) return;
e.preventDefault();
setOver(i);
}}
onDragLeave={() => setOver((o) => (o === i ? null : o))}
onDrop={(e) => {
e.preventDefault();
if (drag !== null && drag !== i) onChange(moveItem(items, drag, i));
setDrag(null);
setOver(null);
}}
onDragEnd={() => {
setDrag(null);
setOver(null);
}}
className={cn(
"flex min-w-0 flex-wrap items-center gap-2 px-2.5 py-2 sm:flex-nowrap",
drag === i && "opacity-50",
over === i && drag !== i && "bg-foreground/[0.05]",
)}
>
<GripVertical
aria-hidden
className={cn("h-4 w-4 shrink-0 text-muted-foreground", disabled ? "opacity-30" : "cursor-grab")}
/>
<span className="w-5 shrink-0 text-meta text-muted-foreground tabular-nums">{i + 1}</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground" title={it.name}>
{it.name}
{i === 0 ? <span className="ml-1.5 text-meta font-normal text-muted-foreground">主版本</span> : null}
</span>
<span className="flex shrink-0 items-center sm:order-last">
<Button
variant="ghost"
size="icon-xs"
aria-label={`上移 ${it.name}`}
disabled={disabled || i === 0}
onClick={() => onChange(moveItem(items, i, i - 1))}
>
<ArrowUp />
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label={`下移 ${it.name}`}
disabled={disabled || i === items.length - 1}
onClick={() => onChange(moveItem(items, i, i + 1))}
>
<ArrowDown />
</Button>
</span>
{renderExtra ? renderExtra(it, i) : null}
</li>
))}
</ol>
);
}
@@ -0,0 +1,155 @@
import type { Subscription, SubscriptionCoverage, SubscriptionOverviewItem } from "@umt/contracts";
import { Check, Wand2 } from "lucide-react";
import { useEffect, useId, useMemo, useState } from "react";
import { toast } from "sonner";
import { useFillSlot, useSetSlotOrder, useUpdateSubscription } from "~/api/subscriptions.ts";
import { Panel } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import {
AUTOFILL_SOURCE,
type DraftErrors,
draftFromSubscription,
draftToParams,
type SubscriptionDraft,
} from "./model.ts";
import { SlotOrderList } from "./slot-order.tsx";
import { AutoFillFields } from "./subscription-form.tsx";
// 对比视图的"版本目标"区(设计 §2.2):拖动排序(PUT slot-order)、行尾计数、补全此版本、自动补全开关与预算。
type SlotRow = SubscriptionCoverage["rows"][number] & { id: string };
export function SlotPanel({
sub,
cov,
item,
canFill,
}: {
sub: Subscription;
cov: SubscriptionCoverage | undefined;
item: SubscriptionOverviewItem | null | undefined;
canFill: boolean;
}) {
const id = useId();
const setOrder = useSetSlotOrder();
const fill = useFillSlot();
const update = useUpdateSubscription();
const serverRows = useMemo<SlotRow[]>(
() => (cov?.rows ?? []).filter((r) => r.slotId !== null).map((r) => ({ ...r, id: r.slotId! })),
[cov],
);
const [rows, setRows] = useState<SlotRow[]>(serverRows);
useEffect(() => setRows(serverRows), [serverRows]);
const dirty = rows.map((r) => r.id).join() !== serverRows.map((r) => r.id).join();
const base = useMemo(() => draftFromSubscription(sub, "off"), [sub]);
const [af, setAf] = useState<Pick<SubscriptionDraft, "autoFill" | "unitsPerDay" | "gbPerDay">>(base);
useEffect(() => setAf(base), [base]);
const [errs, setErrs] = useState<DraftErrors>({});
const afDirty = af.autoFill !== base.autoFill || af.unitsPerDay !== base.unitsPerDay || af.gbPerDay !== base.gbPerDay;
const saveOrder = () =>
setOrder.mutate(
{ id: sub.id, slotIds: rows.map((r) => r.id) },
{
onSuccess: () => toast.success(`主版本:${rows[0]?.name ?? ""}`),
onError: (e) => {
toast.error(errorMessage(e));
setRows(serverRows);
},
},
);
const fillSlot = (r: SlotRow) =>
fill.mutate(
{ id: sub.id, slotId: r.id },
{
onSuccess: (x) => toast.success(x.deduplicated ? "补全任务已在排队" : `已开始补全「${r.name}」`),
onError: (e) => toast.error(errorMessage(e, "补全失败")),
},
);
const saveAutoFill = () => {
const { params, errors } = draftToParams({ ...base, ...af }, sub.params, {
upgradeChanged: false,
totalChanged: false,
});
setErrs(errors);
if (Object.keys(errors).length) return;
update.mutate(
{ id: sub.id, params },
{ onSuccess: () => toast.success("自动补全设置已保存"), onError: (e) => toast.error(errorMessage(e)) },
);
};
return (
<Panel
title="版本目标"
description="拖动调整顺序,第一个是主版本。主版本缺的照常追更;其他版本按下面的自动补全设置补齐。"
actions={
dirty ? (
<>
<Button variant="ghost" size="sm" onClick={() => setRows(serverRows)} disabled={setOrder.isPending}>
还原
</Button>
<Button size="sm" onClick={saveOrder} disabled={setOrder.isPending}>
<Check />
保存顺序
</Button>
</>
) : null
}
>
{rows.length === 0 ? (
<p className="text-caption text-muted-foreground">
{sub.mediaEntityId ? "还没有版本目标。在下方规则区新建。" : "作品建档后才有版本目标。"}
</p>
) : (
<SlotOrderList
items={rows}
onChange={setRows}
disabled={setOrder.isPending}
renderExtra={(r, i) => (
<span className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums max-sm:w-full max-sm:pl-9">
<span>已入库 {r.summary.have}</span>
<span className={r.summary.missing ? "text-destructive" : undefined}>缺 {r.summary.missing}</span>
<span>在途 {r.summary.inflight}</span>
{i === 0 ? null : <span>{r.autoFill ? "自动补全" : "只登记缺失"}</span>}
<Button
variant="outline"
size="xs"
onClick={() => fillSlot(r)}
disabled={!canFill || fill.isPending || r.summary.missing === 0}
title={canFill ? undefined : "订阅不在追踪中,先恢复"}
>
<Wand2 />
补全此版本
</Button>
</span>
)}
/>
)}
<div className="mt-5 space-y-2 border-t border-border/60 pt-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">自动补全其他版本</h3>
{item ? (
<span className="text-meta text-muted-foreground">
当前{item.autoFill.enabled ? "开启" : "关闭"} · 来自{AUTOFILL_SOURCE[item.autoFill.source]}
</span>
) : null}
</div>
<AutoFillFields d={af} patch={(p) => setAf((x) => ({ ...x, ...p }))} errors={errs} idPrefix={id} />
{afDirty ? (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setAf(base)} disabled={update.isPending}>
还原
</Button>
<Button size="sm" onClick={saveAutoFill} disabled={update.isPending}>
<Check />
保存
</Button>
</div>
) : null}
</div>
</Panel>
);
}
@@ -0,0 +1,848 @@
import {
type AcquisitionPriority,
AcquisitionPriority as AcquisitionPriorityEnum,
type AutoFillMode,
type LocalMediaHit,
type SeasonScope,
type Subscription,
type SubscriptionMediaKind,
type SubscriptionMode,
type TmdbCandidate,
} from "@umt/contracts";
import { ExternalLink, Search, X } from "lucide-react";
import { type ReactNode, useEffect, useId, useMemo, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { downloaders, useSiteOptions } from "~/api/instances.ts";
import { useEntityDetail, useLibraryRoots } from "~/api/library.ts";
import { usePolicyRevisions } from "~/api/policies.ts";
import { useRuleGroups } from "~/api/rules.ts";
import { useSearch } from "~/api/search.ts";
import { useSlots } from "~/api/slots.ts";
import {
duplicateSubscriptionId,
useCreateSubscription,
useSetSlotOrder,
useUpdateSubscription,
} from "~/api/subscriptions.ts";
import { FormField } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PRIORITY, SEASON_SCOPE, SUB_MODE } from "~/components/library/labels.ts";
import { ChipToggleGroup } from "~/components/library/parts.tsx";
import { Button } 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 { Switch } from "~/components/ui/switch.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { ApiRequestError, errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import {
AUTOFILL_LABEL,
type DraftErrors,
draftFromSubscription,
draftToParams,
emptyDraft,
SUB_KIND_LABEL,
type SubscriptionDraft,
UPGRADE_LABEL,
type UpgradeStrategy,
upgradeOf,
} from "./model.ts";
import { SlotOrderList } from "./slot-order.tsx";
// 新建 / 编辑订阅(设计 §3):基础 / 进阶两栏,对齐 MP 编辑对话框。作品用全局搜索的 media 段(本地 + TMDB)。
type Target =
| { kind: "entity"; id: string; title: string; year: number | null; mediaKind: SubscriptionMediaKind }
| { kind: "title"; titleQuery: string; mediaKind: SubscriptionMediaKind; note?: string };
const toSubKind = (k: string): SubscriptionMediaKind =>
k === "movie" ? "movie" : k === "variety" ? "variety" : "series";
const NONE = "__none__";
function Pick<T extends string>({
id,
value,
onChange,
options,
placeholder,
disabled,
}: {
id: string;
value: T | "";
onChange: (v: T | "") => void;
options: { value: T; label: string }[];
/** 有值时提供"空"选项 */
placeholder?: string;
disabled?: boolean;
}) {
return (
<Select
value={value || (placeholder ? NONE : undefined)}
onValueChange={(v) => onChange(v === NONE ? "" : (v as T))}
disabled={disabled}
>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent position="popper">
{placeholder ? <SelectItem value={NONE}>{placeholder}</SelectItem> : null}
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="min-w-0 space-y-3 border-t border-border/60 pt-4 first:border-t-0 first:pt-0">
<h3 className="text-meta font-semibold tracking-wide text-muted-foreground">{title}</h3>
{children}
</section>
);
}
/** 作品选择:全局搜索 media 段 */
function WorkPicker({ onPick, error }: { onPick: (t: Target) => void; error?: string }) {
const id = useId();
const [q, setQ] = useState("");
const [dq, setDq] = useState("");
useEffect(() => {
const t = setTimeout(() => setDq(q), 300);
return () => clearTimeout(t);
}, [q]);
const s = useSearch(dq, "media", 8);
const local = (s.data?.media?.local.items ?? []).filter((h) => h.entity.status !== "merged");
const localIds = new Set(local.map((h) => h.entity.id));
const tmdb = (s.data?.media?.tmdb.items ?? []).filter((c) => !c.localEntityId || !localIds.has(c.localEntityId));
const pickLocal = (h: LocalMediaHit) =>
onPick({
kind: "entity",
id: h.entity.id,
title: h.entity.title,
year: h.entity.year,
mediaKind: toSubKind(h.entity.kind),
});
const pickTmdb = (c: TmdbCandidate) =>
c.localEntityId
? onPick({
kind: "entity",
id: c.localEntityId,
title: c.title,
year: c.year,
mediaKind: c.entityType === "movie" ? "movie" : "series",
})
: onPick({
kind: "title",
titleQuery: c.year ? `${c.title} ${c.year}` : c.title,
mediaKind: c.entityType === "movie" ? "movie" : "series",
note: `TMDB ${c.externalId},建档后自动关联`,
});
const row = (key: string, main: ReactNode, sub: string, onClick: () => void) => (
<li key={key}>
<button
type="button"
onClick={onClick}
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">{main}</span>
<span className="shrink-0 text-meta text-muted-foreground">{sub}</span>
</button>
</li>
);
const term = dq.trim();
return (
<FormField label="作品" htmlFor={`${id}-q`} required error={error}>
<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={q}
aria-invalid={!!error}
placeholder="搜索标题 / 原名,库里没有的从 TMDB 选"
onChange={(e) => setQ(e.target.value)}
/>
</div>
{term ? (
<div className="max-h-60 overflow-y-auto rounded-lg border border-border/70">
{s.isPending ? (
<p className="px-3 py-2 text-caption text-muted-foreground">搜索中…</p>
) : s.isError ? (
<p className="px-3 py-2 text-caption text-destructive">{errorMessage(s.error)}</p>
) : (
<>
{local.length ? (
<>
<p className="px-3 pt-2 text-meta text-muted-foreground">媒体库</p>
<ul className="divide-y divide-border/60">
{local.map((h) =>
row(
h.entity.id,
<>
{h.entity.title}
{h.entity.year ? <span className="text-muted-foreground"> ({h.entity.year})</span> : null}
{h.subscribed ? <span className="ml-1.5 text-meta text-muted-foreground">已订阅</span> : null}
</>,
SUB_KIND_LABEL[toSubKind(h.entity.kind)],
() => pickLocal(h),
),
)}
</ul>
</>
) : null}
{tmdb.length ? (
<>
<p className="px-3 pt-2 text-meta text-muted-foreground">TMDB</p>
<ul className="divide-y divide-border/60">
{tmdb.map((c) =>
row(
`tmdb-${c.externalId}`,
<>
{c.title}
{c.year ? <span className="text-muted-foreground"> ({c.year})</span> : null}
</>,
c.entityType === "movie" ? "电影" : "剧集",
() => pickTmdb(c),
),
)}
</ul>
</>
) : null}
{!local.length && !tmdb.length ? (
<p className="px-3 py-2 text-caption text-muted-foreground">
没有匹配的作品。
<button
type="button"
className="cursor-pointer underline underline-offset-4"
onClick={() => onPick({ kind: "title", titleQuery: term, mediaKind: "series" })}
>
按标题「{term}」追踪
</button>
</p>
) : null}
{s.data?.media && !s.data.media.tmdb.configured ? (
<p className="px-3 pb-2 text-meta text-muted-foreground">TMDB 未配置,只搜了媒体库。</p>
) : null}
</>
)}
</div>
) : null}
</FormField>
);
}
export function SubscriptionFormDialog({
sub,
title,
onClose,
onCreated,
}: {
/** 缺省 = 新建 */
sub?: Subscription | null;
/** 编辑时的作品名(单条订阅接口不带标题) */
title?: string;
onClose: () => void;
onCreated?: (s: Subscription) => void;
}) {
const id = useId();
const editing = !!sub;
const create = useCreateSubscription();
const update = useUpdateSubscription();
const setOrder = useSetSlotOrder();
const [target, setTarget] = useState<Target | null>(null);
const [tab, setTab] = useState("basic");
const slotsQ = useSlots({ subscriptionId: sub?.id }, editing);
const subSlots = useMemo(
() =>
(slotsQ.data?.items ?? [])
.filter((s) => s.subscriptionId === sub?.id)
.sort((a, b) => a.position - b.position || a.createdAt.localeCompare(b.createdAt)),
[slotsQ.data, sub?.id],
);
const initialUpgrade: UpgradeStrategy = sub ? upgradeOf(subSlots, sub.mediaKind) : "off";
const [d, setD] = useState<SubscriptionDraft>(() => (sub ? draftFromSubscription(sub, "off") : emptyDraft("series")));
const [order, setOrderState] = useState<{ id: string; name: string }[] | null>(null);
const [upgradeTouched, setUpgradeTouched] = useState(false);
const [totalTouched, setTotalTouched] = useState(false);
const [errors, setErrors] = useState<DraftErrors & { target?: string }>({});
const [dup, setDup] = useState<string | null>(null);
// 编辑:版本目标到了再回填升级策略
useEffect(() => {
if (sub && slotsQ.isSuccess && !upgradeTouched) setD((x) => ({ ...x, upgrade: initialUpgrade }));
}, [sub, slotsQ.isSuccess, initialUpgrade, upgradeTouched]);
const patch = (p: Partial<SubscriptionDraft>) => setD((x) => ({ ...x, ...p }));
const entityId = sub?.mediaEntityId ?? (target?.kind === "entity" ? target.id : null);
const kind = d.mediaKind;
const isMovie = kind === "movie";
const detail = useEntityDetail(entityId && !isMovie ? entityId : undefined);
const seasons = (detail.data?.contentUnits ?? [])
.filter((u) => u.kind === "season" && u.seasonNumber !== null)
.sort((a, b) => (a.seasonNumber ?? 0) - (b.seasonNumber ?? 0));
const sites = useSiteOptions();
const dls = downloaders.useList();
const roots = useLibraryRoots();
const groups = useRuleGroups({ enabled: "true" });
const policies = usePolicyRevisions({ status: "published" });
const templates = (policies.data?.items ?? []).filter(
(p) => p.kind === "acquisition" && Array.isArray((p.rules as { defaultSlots?: unknown }).defaultSlots),
);
const defaultTpl = templates.find((p) => ((p.rules as { defaultSlots?: unknown[] }).defaultSlots ?? []).length);
const tplNames = (pid: string) =>
(
((templates.find((p) => p.id === pid)?.rules as { defaultSlots?: { name?: string }[] })?.defaultSlots ?? []) as {
name?: string;
}[]
)
.map((t) => t.name ?? "")
.filter(Boolean);
const pickTarget = (t: Target) => {
setTarget(t);
setErrors({});
setDup(null);
const k = t.mediaKind;
patch({
mediaKind: k,
mode: k === "movie" ? "once" : "continuous",
seasonScope: k === "movie" ? "current_season" : "following_seasons",
seasonUnitId: "",
});
};
const pending = create.isPending || update.isPending || setOrder.isPending;
const submit = async () => {
const { params, errors: errs } = draftToParams(d, sub?.params ?? {}, {
upgradeChanged: editing ? upgradeTouched && d.upgrade !== initialUpgrade : d.upgrade !== "off",
totalChanged: totalTouched,
});
const all: typeof errors = { ...errs };
if (!editing && !target) all.target = "选择作品";
if (!editing && target?.kind === "title" && !target.titleQuery.trim()) all.target = "填写标题";
setErrors(all);
if (Object.keys(all).length) {
const basicKeys = ["target", "episodeStart", "episodeEnd", "total"];
setTab(Object.keys(all).some((k) => basicKeys.includes(k)) ? "basic" : "advanced");
return;
}
setDup(null);
try {
if (sub) {
await update.mutateAsync({
id: sub.id,
mode: d.mode,
seasonScope: isMovie ? "current_season" : d.seasonScope,
priority: d.priority,
params,
});
if (order && order.map((o) => o.id).join() !== subSlots.map((s) => s.id).join())
await setOrder.mutateAsync({ id: sub.id, slotIds: order.map((o) => o.id) });
toast.success("订阅已保存");
onClose();
} else if (target) {
const created = await create.mutateAsync({
...(target.kind === "entity"
? { mediaEntityId: target.id, seasonUnitId: d.seasonUnitId || undefined }
: { titleQuery: target.titleQuery.trim() }),
mediaKind: d.mediaKind,
mode: d.mode,
seasonScope: isMovie ? "current_season" : d.seasonScope,
priority: d.priority,
origin: "user",
policyRevisionId: d.policyRevisionId || undefined,
params,
});
toast.success("订阅已创建");
onCreated?.(created);
onClose();
}
} catch (e) {
if (e instanceof ApiRequestError && e.code === "conflict") {
const existing = duplicateSubscriptionId(e.message);
if (existing) {
setDup(existing);
setTab("basic");
return;
}
}
toast.error(errorMessage(e, "保存失败"));
}
};
const err = (k: keyof SubscriptionDraft) => errors[k];
const f = (k: string) => `${id}-${k}`;
const titleText = sub
? (title ?? sub.mediaEntityTitle ?? sub.titleQuery ?? "订阅")
: target?.kind === "entity"
? `${target.title}${target.year ? ` (${target.year})` : ""}`
: (target?.titleQuery ?? "");
return (
<Dialog open onOpenChange={(o) => !o && !pending && onClose()}>
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{editing ? "编辑订阅" : "新建订阅"}</DialogTitle>
<DialogDescription>
{editing ? titleText : "从媒体库或 TMDB 选作品;库里没有的按标题追踪,建档后自动关联。"}
</DialogDescription>
</DialogHeader>
{dup ? (
<div
role="alert"
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-foreground"
>
<span>这部作品的同一季已有订阅,不能重复创建。</span>
<Link
to={consolePath("subscriptions", dup)}
onClick={onClose}
className="inline-flex items-center gap-1 font-medium underline underline-offset-4"
>
打开已有订阅
<ExternalLink className="h-3.5 w-3.5" />
</Link>
</div>
) : null}
<Tabs value={tab} onValueChange={setTab} className="min-w-0">
<TabsList>
<TabsTrigger value="basic">基础</TabsTrigger>
<TabsTrigger value="advanced">进阶</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="mt-4 min-w-0 space-y-5">
<Section title={editing ? "范围" : "作品"}>
{editing ? null : target ? (
<div className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
{target.kind === "title" ? (
<Input
aria-label="追踪标题"
value={target.titleQuery}
onChange={(e) => setTarget({ ...target, titleQuery: e.target.value })}
/>
) : (
<p className="truncate text-sm font-medium text-foreground">{titleText}</p>
)}
<p className="mt-0.5 text-meta text-muted-foreground">
{target.kind === "entity" ? "媒体库作品" : (target.note ?? "按标题追踪,尚未建档")}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => setTarget(null)}>
<X />
更换
</Button>
</div>
) : (
<WorkPicker onPick={pickTarget} error={errors.target} />
)}
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="类型" htmlFor={f("kind")}>
<Pick
id={f("kind")}
value={d.mediaKind}
onChange={(k) => {
const mk = (k || "series") as SubscriptionMediaKind;
patch({ mediaKind: mk, ...(mk === "movie" ? { mode: "once", upgrade: "off" } : {}) });
}}
options={(["series", "movie", "variety"] as const).map((v) => ({
value: v,
label: SUB_KIND_LABEL[v],
}))}
disabled={editing || target?.kind === "entity"}
/>
</FormField>
<FormField label="方式" htmlFor={f("mode")}>
<Pick
id={f("mode")}
value={d.mode}
onChange={(v) => patch({ mode: (v || "continuous") as SubscriptionMode })}
options={(["once", "continuous"] as const).map((v) => ({ value: v, label: SUB_MODE[v] }))}
/>
</FormField>
</div>
{isMovie ? null : (
<div className="grid gap-3 sm:grid-cols-2">
<FormField
label="季"
htmlFor={f("season")}
hint={editing ? "季建好后不能改;要换季就新建订阅" : entityId ? undefined : "作品建档后可选季"}
>
<Pick
id={f("season")}
value={d.seasonUnitId}
onChange={(v) => patch({ seasonUnitId: v })}
placeholder="全部季"
options={seasons.map((s) => ({
value: s.id,
label: s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`,
}))}
disabled={editing || !entityId}
/>
</FormField>
<FormField label="季范围" htmlFor={f("scope")}>
<Pick
id={f("scope")}
value={d.seasonScope}
onChange={(v) => patch({ seasonScope: (v || "following_seasons") as SeasonScope })}
options={(["current_season", "following_seasons"] as const).map((v) => ({
value: v,
label: SEASON_SCOPE[v],
}))}
/>
</FormField>
<FormField label="开始集" htmlFor={f("start")} error={err("episodeStart")}>
<Input
id={f("start")}
inputMode="numeric"
placeholder="1"
value={d.episodeStart}
aria-invalid={!!err("episodeStart")}
onChange={(e) => patch({ episodeStart: e.target.value })}
/>
</FormField>
<FormField label="结束集" htmlFor={f("end")} error={err("episodeEnd")} hint="空 = 到最后一集">
<Input
id={f("end")}
inputMode="numeric"
value={d.episodeEnd}
aria-invalid={!!err("episodeEnd")}
onChange={(e) => patch({ episodeEnd: e.target.value })}
/>
</FormField>
<FormField
label="总集数"
htmlFor={f("total")}
error={err("total")}
hint={d.totalLocked ? "锁定后元数据刷新不再改写" : "空 = 按 TMDB;不锁定时只升不降"}
>
<Input
id={f("total")}
inputMode="numeric"
value={d.total}
aria-invalid={!!err("total")}
onChange={(e) => {
setTotalTouched(true);
patch({ total: e.target.value });
}}
/>
</FormField>
<label
htmlFor={f("lock")}
className="flex h-9 items-center gap-2 self-end pb-5 text-sm text-foreground sm:self-center sm:pb-0"
>
<Switch
id={f("lock")}
checked={d.totalLocked}
onCheckedChange={(v) => {
setTotalTouched(true);
patch({ totalLocked: v });
}}
/>
锁定总集数
</label>
</div>
)}
{d.mode === "continuous" && d.seasonScope === "following_seasons" && !isMovie ? (
<p className="text-meta text-muted-foreground">特别篇、衍生节目和同名重启不算作下一季。</p>
) : null}
</Section>
<Section title="下载">
<FormField label="站点范围" htmlFor={f("sites")} hint="不选 = 全部启用的站点">
{sites.isPending ? (
<p className="text-caption text-muted-foreground">读取站点…</p>
) : (
<ChipToggleGroup
label="站点范围"
options={(sites.data?.items ?? [])
.filter((s) => s.enabled || d.siteIds.includes(s.id))
.map((s) => ({ value: s.id, label: s.name }))}
value={d.siteIds}
onChange={(v) => patch({ siteIds: v })}
/>
)}
</FormField>
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="下载器" htmlFor={f("dl")}>
<Pick
id={f("dl")}
value={d.downloaderInstanceId}
onChange={(v) => patch({ downloaderInstanceId: v })}
placeholder="默认下载器"
options={(dls.data?.items ?? []).map((x) => ({ value: x.id, label: x.name }))}
/>
</FormField>
<FormField label="库根" htmlFor={f("root")}>
<Pick
id={f("root")}
value={d.libraryRootId}
onChange={(v) => patch({ libraryRootId: v })}
placeholder="按类型默认"
options={(roots.data?.items ?? []).map((x) => ({ value: x.id, label: x.name }))}
/>
</FormField>
</div>
<FormField label="下载保存路径" htmlFor={f("path")} hint="空 = 下载器默认路径">
<Input
id={f("path")}
value={d.downloadSavePath}
placeholder="/downloads/tv"
onChange={(e) => patch({ downloadSavePath: e.target.value })}
/>
</FormField>
</Section>
<Section title="版本目标">
{editing ? (
slotsQ.isPending ? (
<p className="text-caption text-muted-foreground">读取版本目标…</p>
) : subSlots.length === 0 ? (
<p className="text-caption text-muted-foreground">还没有版本目标。在对比视图里新建。</p>
) : (
<FormField label="顺序" htmlFor={f("order")} hint="第一个是主版本;其他版本按自动补全设置补齐">
<SlotOrderList
items={order ?? subSlots.map((s) => ({ id: s.id, name: s.name }))}
onChange={setOrderState}
disabled={pending}
/>
</FormField>
)
) : (
<FormField
label="模板"
htmlFor={f("tpl")}
hint={
target?.kind === "entity"
? "作品还没有版本目标时按模板顺序创建,第一个是主版本"
: "作品建档后按模板创建版本目标"
}
>
<Pick
id={f("tpl")}
value={d.policyRevisionId}
onChange={(v) => patch({ policyRevisionId: v })}
placeholder={defaultTpl ? `按已发布收录策略(${defaultTpl.title})` : "按已发布收录策略"}
options={templates.map((p) => ({
value: p.id,
label: `${p.title} r${p.revision}:${tplNames(p.id).join(" → ") || "无模板"}`,
}))}
/>
</FormField>
)}
<FormField
label="升级策略"
htmlFor={f("upgrade")}
hint="作用于这个订阅的全部版本目标;已入库低于目标档位时继续找更好的"
>
<Pick
id={f("upgrade")}
value={d.upgrade}
onChange={(v) => {
setUpgradeTouched(true);
patch({ upgrade: (v || "off") as UpgradeStrategy });
}}
options={(isMovie ? (["off", "movie"] as const) : (["off", "per_unit", "full_pack"] as const)).map(
(v) => ({ value: v, label: UPGRADE_LABEL[v] }),
)}
/>
</FormField>
</Section>
</TabsContent>
<TabsContent value="advanced" className="mt-4 min-w-0 space-y-5">
<Section title="过滤">
<FormField
label="规则组"
htmlFor={f("groups")}
hint="按选择顺序串联;不选 = 订阅默认规则组。版本目标自己的规则组另算"
>
{groups.isPending ? (
<p className="text-caption text-muted-foreground">读取规则组…</p>
) : (groups.data?.items ?? []).length === 0 ? (
<p className="text-caption text-muted-foreground">
还没有规则组。
<Link className="underline underline-offset-4" to={consolePath("rules")}>
去规则页
</Link>
</p>
) : (
<ChipToggleGroup
label="规则组"
ordered
options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))}
value={d.ruleGroupIds}
onChange={(v) => patch({ ruleGroupIds: v })}
/>
)}
</FormField>
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="包含" htmlFor={f("inc")} hint="每行一个正则,匹配标题、副标题和标签">
<Textarea
id={f("inc")}
rows={3}
value={d.include}
placeholder="中字|简繁"
onChange={(e) => patch({ include: e.target.value })}
/>
</FormField>
<FormField label="排除" htmlFor={f("exc")} hint="命中任一行即不要">
<Textarea
id={f("exc")}
rows={3}
value={d.exclude}
placeholder="HDTV"
onChange={(e) => patch({ exclude: e.target.value })}
/>
</FormField>
</div>
<FormField
label="搜索关键词"
htmlFor={f("kw")}
error={err("keywords")}
hint="每行一个,追加在标题和别名之后;最多 10 个"
>
<Textarea
id={f("kw")}
rows={2}
value={d.keywords}
onChange={(e) => patch({ keywords: e.target.value })}
/>
</FormField>
</Section>
<Section title="搜索与优先级">
<div className="grid gap-3 sm:grid-cols-3">
<FormField
label="主动搜索间隔(小时)"
htmlFor={f("interval")}
error={err("searchIntervalHours")}
hint="空 = 24;0 = 关"
>
<Input
id={f("interval")}
inputMode="decimal"
value={d.searchIntervalHours}
aria-invalid={!!err("searchIntervalHours")}
onChange={(e) => patch({ searchIntervalHours: e.target.value })}
/>
</FormField>
<FormField label="截止时间" htmlFor={f("stop")} error={err("stopAfter")} hint="到时不再搜索">
<Input
id={f("stop")}
type="datetime-local"
value={d.stopAfter}
onChange={(e) => patch({ stopAfter: e.target.value })}
/>
</FormField>
<FormField label="优先级" htmlFor={f("prio")} hint="只影响资源分配顺序">
<Pick
id={f("prio")}
value={d.priority}
onChange={(v) => patch({ priority: (v || "tracking") as AcquisitionPriority })}
options={AcquisitionPriorityEnum.options.map((v) => ({ value: v, label: PRIORITY[v] }))}
/>
</FormField>
</div>
</Section>
<Section title="自动补全其他版本">
<AutoFillFields d={d} patch={patch} errors={errors} idPrefix={id} />
</Section>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={pending}>
取消
</Button>
<Button onClick={submit} disabled={pending}>
{pending ? "保存中…" : editing ? "保存" : "创建订阅"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** 自动补全开关 + 补全预算(表单与对比视图共用) */
export function AutoFillFields({
d,
patch,
errors,
idPrefix,
compact,
}: {
d: Pick<SubscriptionDraft, "autoFill" | "unitsPerDay" | "gbPerDay">;
patch: (p: Partial<SubscriptionDraft>) => void;
errors: DraftErrors;
idPrefix: string;
compact?: boolean;
}) {
return (
<div className="space-y-3">
{compact ? null : (
<p className="text-meta text-muted-foreground">
主版本缺的照常追更。开启后,主版本已有的单元也会为其他版本搜索下载;关闭时只记为缺失,可手动补全。
</p>
)}
<div className="grid gap-3 sm:grid-cols-3">
<FormField label="开关" htmlFor={`${idPrefix}-af`}>
<fieldset aria-label="自动补全" className="m-0 flex gap-1 border-0 p-0" id={`${idPrefix}-af`}>
{(["inherit", "on", "off"] as AutoFillMode[]).map((m) => (
<button
key={m}
type="button"
aria-pressed={d.autoFill === m}
onClick={() => patch({ autoFill: m })}
className={cn(
"h-8 cursor-pointer rounded-full border px-3 text-sm font-medium whitespace-nowrap transition-colors duration-micro",
d.autoFill === m
? "pill-selected text-foreground"
: "border-border text-muted-foreground hover:bg-foreground/[0.06]",
)}
>
{AUTOFILL_LABEL[m]}
</button>
))}
</fieldset>
</FormField>
<FormField label="每天最多(个单元)" htmlFor={`${idPrefix}-units`} error={errors.unitsPerDay}>
<Input
id={`${idPrefix}-units`}
inputMode="numeric"
placeholder="不限"
value={d.unitsPerDay}
aria-invalid={!!errors.unitsPerDay}
onChange={(e) => patch({ unitsPerDay: e.target.value })}
/>
</FormField>
<FormField label="每天最多(GB)" htmlFor={`${idPrefix}-gb`} error={errors.gbPerDay}>
<Input
id={`${idPrefix}-gb`}
inputMode="decimal"
placeholder="不限"
value={d.gbPerDay}
aria-invalid={!!errors.gbPerDay}
onChange={(e) => patch({ gbPerDay: e.target.value })}
/>
</FormField>
</div>
<p className="text-meta text-muted-foreground">预算只算自动补全的计划,超出的延到次日;同时受全局每日预算限制。</p>
</div>
);
}
+1
View File
@@ -18,6 +18,7 @@ export default [
route("files", "routes/console/files.tsx"),
route("files/trash", "routes/console/files.trash.tsx"),
route("subscriptions", "routes/console/subscriptions.tsx"),
route("subscriptions/:id", "routes/console/subscriptions.$id.tsx"),
route("preferences", "routes/console/preferences.tsx"),
route("sites", "routes/console/sites.tsx"),
route("sites/:siteId", "routes/console/sites.$siteId.tsx"),
@@ -0,0 +1,291 @@
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}
</>
);
}
File diff suppressed because it is too large. Load diff
+2
View File
@@ -35,6 +35,7 @@ const STATIC_ROUTES = [
"/console/files?mock=0",
"/console/files/trash",
"/console/subscriptions",
"/console/subscriptions?group=pending&sort=missing",
"/console/rules",
"/console/preferences",
"/console/sites",
@@ -73,6 +74,7 @@ const DETAIL_ROUTES = [
{ route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=organized" },
{ route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=snapshots" },
{ route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=accounts" },
{ route: "/console/subscriptions/:id", list: "/api/v1/subscriptions" },
{ route: "/console/downloads/:id", list: "/api/v1/download-tasks" },
{ route: "/console/tasks/:id", list: "/api/v1/engine/runs" },
{ route: "/console/tasks?run=:id", list: "/api/v1/engine/runs" },