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:
1 parent
4deaaab6fc
commit
b7a7fd3958
13 files changed
+3241
-726
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user