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 { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AcquisitionSlot,
cursorPage, cursorPage,
type EpisodeTotalSet, type EpisodeTotalSet,
MediaEntity, MediaEntity,
Subscription, Subscription,
type SubscriptionBatch,
SubscriptionBatchResult,
SubscriptionCoverage,
type SubscriptionCreate, type SubscriptionCreate,
type SubscriptionGroup,
type SubscriptionMediaKind, type SubscriptionMediaKind,
type SubscriptionOrigin,
SubscriptionOverviewPage,
SubscriptionProgress, SubscriptionProgress,
type SubscriptionSort,
type SubscriptionStatus, type SubscriptionStatus,
type SubscriptionUpdate, type SubscriptionUpdate,
} from "@umt/contracts"; } from "@umt/contracts";
import { z } from "zod";
import { api } from "~/lib/api.ts"; import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts"; import { getParsed, parseOrThrow } from "./library.ts";
@@ -18,6 +27,9 @@ import { getParsed, parseOrThrow } from "./library.ts";
export const subscriptionKeys = { export const subscriptionKeys = {
all: ["subscriptions"] as const, all: ["subscriptions"] as const,
list: (f: SubscriptionFilters) => ["subscriptions", "list", f] 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 = { export type SubscriptionFilters = {
@@ -59,7 +71,11 @@ export function useUpdateSubscription() {
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: SubscriptionUpdate & { id: string }) => mutationFn: ({ id, ...body }: SubscriptionUpdate & { id: string }) =>
api.patch<unknown>(`/subscriptions/${id}`, body).then((d) => parseOrThrow(Subscription, d, "/subscriptions")), 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() { export function useSearchSubscription() {
const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (id: string) => api.post<{ runId: string; deduplicated: boolean }>(`/subscriptions/${id}/search`), 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 api
.post<unknown>(`/subscriptions/${id}/episode-total`, body) .post<unknown>(`/subscriptions/${id}/episode-total`, body)
.then((d) => parseOrThrow(SubscriptionProgress, d, "/subscriptions/episode-total")), .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", "routes/console/files.tsx"),
route("files/trash", "routes/console/files.trash.tsx"), route("files/trash", "routes/console/files.trash.tsx"),
route("subscriptions", "routes/console/subscriptions.tsx"), route("subscriptions", "routes/console/subscriptions.tsx"),
route("subscriptions/:id", "routes/console/subscriptions.$id.tsx"),
route("preferences", "routes/console/preferences.tsx"), route("preferences", "routes/console/preferences.tsx"),
route("sites", "routes/console/sites.tsx"), route("sites", "routes/console/sites.tsx"),
route("sites/:siteId", "routes/console/sites.$siteId.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?mock=0",
"/console/files/trash", "/console/files/trash",
"/console/subscriptions", "/console/subscriptions",
"/console/subscriptions?group=pending&sort=missing",
"/console/rules", "/console/rules",
"/console/preferences", "/console/preferences",
"/console/sites", "/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=organized" },
{ route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=snapshots" }, { route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=snapshots" },
{ route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=accounts" }, { 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/downloads/:id", list: "/api/v1/download-tasks" },
{ route: "/console/tasks/:id", list: "/api/v1/engine/runs" }, { route: "/console/tasks/:id", list: "/api/v1/engine/runs" },
{ route: "/console/tasks?run=:id", list: "/api/v1/engine/runs" }, { route: "/console/tasks?run=:id", list: "/api/v1/engine/runs" },