From b7a7fd395888f3ca8af31d1052cea5074cddfb12 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 16:27:29 +0800 Subject: [PATCH] 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/:id: 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 --- apps/web/app/api/subscriptions.ts | 163 ++- .../app/components/subscriptions/actions.tsx | 207 ++++ .../subscriptions/coverage-matrix.tsx | 325 +++++ .../components/subscriptions/model.test.ts | 156 +++ .../web/app/components/subscriptions/model.ts | 382 ++++++ .../subscriptions/overview-item.tsx | 243 ++++ .../components/subscriptions/slot-order.tsx | 91 ++ .../components/subscriptions/slot-panel.tsx | 155 +++ .../subscriptions/subscription-form.tsx | 848 +++++++++++++ apps/web/app/routes.ts | 1 + .../app/routes/console/subscriptions.$id.tsx | 291 +++++ apps/web/app/routes/console/subscriptions.tsx | 1103 ++++++----------- apps/web/scripts/smoke.mjs | 2 + 13 files changed, 3241 insertions(+), 726 deletions(-) create mode 100644 apps/web/app/components/subscriptions/actions.tsx create mode 100644 apps/web/app/components/subscriptions/coverage-matrix.tsx create mode 100644 apps/web/app/components/subscriptions/model.test.ts create mode 100644 apps/web/app/components/subscriptions/model.ts create mode 100644 apps/web/app/components/subscriptions/overview-item.tsx create mode 100644 apps/web/app/components/subscriptions/slot-order.tsx create mode 100644 apps/web/app/components/subscriptions/slot-panel.tsx create mode 100644 apps/web/app/components/subscriptions/subscription-form.tsx create mode 100644 apps/web/app/routes/console/subscriptions.$id.tsx diff --git a/apps/web/app/api/subscriptions.ts b/apps/web/app/api/subscriptions.ts index cb90630f..89caad1a 100644 --- a/apps/web/app/api/subscriptions.ts +++ b/apps/web/app/api/subscriptions.ts @@ -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(`/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(`/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(`/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("/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(`/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; +} diff --git a/apps/web/app/components/subscriptions/actions.tsx b/apps/web/app/components/subscriptions/actions.tsx new file mode 100644 index 00000000..73e9ec2e --- /dev/null +++ b/apps/web/app/components/subscriptions/actions.tsx @@ -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; + +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 ( + + ); +} + +/** 卡片 / 行的"更多"菜单 */ +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 ( + <> + + + {trigger ?? ( + + )} + + e.stopPropagation()}> + {showDetail ? ( + } onClick={run(() => navigate(consolePath("subscriptions", sub.id)))}> + 查看对比 + + ) : null} + {tracking(sub) && sub.mediaEntityId ? ( + } disabled={ops.pending} onClick={run(() => ops.search(sub.id))}> + 立即搜索 + + ) : null} + {sub.status !== "cancelled" ? ( + } onClick={run(onEdit)}> + 编辑 + + ) : null} + {tracking(sub) ? ( + } disabled={ops.pending} onClick={run(() => ops.act(sub.id, "pause"))}> + 暂停 + + ) : sub.status === "paused" ? ( + } disabled={ops.pending} onClick={run(() => ops.act(sub.id, "resume"))}> + 恢复 + + ) : null} + {ended ? ( + } disabled={ops.pending} onClick={run(() => ops.act(sub.id, "resubscribe"))}> + 重新订阅 + + ) : null} + {sub.status !== "cancelled" ? ( + } danger onClick={run(() => setConfirm(true))}> + 删除 + + ) : null} + + + + + ); +} + +export function DeleteSubscriptionsDialog({ + open, + onOpenChange, + ids, + onDone, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + ids: string[]; + onDone?: () => void; +}) { + const ops = useSubscriptionOps(); + return ( + 1 ? `删除 ${ids.length} 个订阅?` : "删除这个订阅?"} + description="不再搜索和追更。已入库文件、版本目标和下载记录保留,可在订阅历史里重新订阅。" + confirmLabel="删除" + destructive + onConfirm={() => + ops + .remove(ids) + .then(() => onDone?.()) + .catch((e) => { + toast.error(errorMessage(e)); + throw e; + }) + } + /> + ); +} diff --git a/apps/web/app/components/subscriptions/coverage-matrix.tsx b/apps/web/app/components/subscriptions/coverage-matrix.tsx new file mode 100644 index 00000000..8b1d5d91 --- /dev/null +++ b/apps/web/app/components/subscriptions/coverage-matrix.tsx @@ -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 = { + 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 ( + + {k === "deferred" ? : null} + + ); +} + +export function CoverageLegend() { + return ( +
    + {LEGEND.map((k) => ( +
  • + + {CELL_LABEL[k]} +
  • + ))} +
+ ); +} + +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 ( +

这一季还没有集信息。设置总集数后按集显示。

+ ); + const sTitle = seasonTitle(season, cov.mediaKind); + const wide = cov.mediaKind === "movie"; + return ( +
+ + + + + {cols.map((c) => ( + + ))} + + + + {cov.rows.map((row) => { + const cells = row.cells.slice(slice.start, slice.end); + const any = row.slotId === null; + return ( + + + {cells.map((c, i) => { + const col = cols[i]!; + const k = cellKind(c); + return ( + + ); + })} + + ); + })} + +
+ 版本目标 + + {wide ? columnLabel(c) : (c.episode ?? columnLabel(c))} +
+ {row.name} + {row.primary ? 主 : null} + + +
+
+ ); +} + +/** 点格打开:资产 / 质量 / 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(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 ( + { + 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 ? ( + + ) : null} + {fillable ? ( + + ) : null} + + ) : null + } + > + {sel && c && k ? ( + <> +
+ + {CELL_LABEL[k]} +
+ + + {c.assetId.slice(0, 8)} + + + ) : ( + {c.assetId} + ) + ) : 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 }, + ]} + /> + + {fillable ? ( +

+ 手动补全不受自动补全开关和补全预算限制,仍受全局每日预算与缓存卷阈值约束。 +

+ ) : null} + {sel.row.slotId === null && c.state === "missing" ? ( +

这一行汇总全部版本。要补全,点具体版本目标那一行的格。

+ ) : null} + + ) : null} +
+ ); +} diff --git a/apps/web/app/components/subscriptions/model.test.ts b/apps/web/app/components/subscriptions/model.test.ts new file mode 100644 index 00000000..9f32aea4 --- /dev/null +++ b/apps/web/app/components/subscriptions/model.test.ts @@ -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 => ({ + 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 => ({ + 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"); + }); +}); diff --git a/apps/web/app/components/subscriptions/model.ts b/apps/web/app/components/subscriptions/model.ts new file mode 100644 index 00000000..96fead07 --- /dev/null +++ b/apps/web/app/components/subscriptions/model.ts @@ -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 = { series: "剧集", movie: "电影", variety: "综艺" }; + +/** 分组展示顺序(设计 §1.1:待定、追更中、待搜索、已完成、暂停) */ +export const GROUP_ORDER: SubscriptionGroup[] = ["pending", "following", "waiting", "completed", "paused"]; +export const GROUP_LABEL: Record = { + 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, +): 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 = { 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 = { + have: "已入库", + upgradable: "可升级", + inflight: "在途", + deferred: "延后", + missing: "缺失", + suspected: "疑似缺失", + not_aired: "未播出", + skipped: "不需要", +}; + +export const PLAN_STAGE: Record = { reserved: "已预留", downloading: "下载中", verifying: "核验中" }; + +export const SKIP_REASON: Record = { + 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 = { + off: "不洗版", + per_unit: "按集洗版", + full_pack: "全集洗版", + movie: "洗版", +}; + +/** 订阅版本目标当前的升级策略:任一开启取其 mode */ +export function upgradeOf( + slots: Pick[], + 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(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; + 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>; + +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 = { ...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 = {}; + 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 }; +} diff --git a/apps/web/app/components/subscriptions/overview-item.tsx b/apps/web/app/components/subscriptions/overview-item.tsx new file mode 100644 index 00000000..19235612 --- /dev/null +++ b/apps/web/app/components/subscriptions/overview-item.tsx @@ -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" ? 暂停 : null} + {x.group === "pending" ? {x.mediaEntityId ? "待定" : "尚未建档"} : null} + {x.upgrading ? 洗版中 : null} + {x.awaitingNewSeason ? 等待新季 : null} + + ); +} + +/** 进度:数字 + 已入库/在途两段细条;总未知写"持续追踪" */ +export function ProgressBlock({ x, className }: { x: Pick; className?: string }) { + const parts = progressParts(x.progress); + const unknown = x.progress.total === null; + return ( +
+
+ + {progressText(x.progress)} + {x.progress.inflight > 0 ? ( + · 在途 {x.progress.inflight} + ) : null} + + {unknown ? 持续追踪 : null} +
+
+ {unknown ? null : ( + <> +
+
+ + )} +
+
+ ); +} + +export function SlotBadges({ slots, max = 4 }: { slots: Item["slots"]; max?: number }) { + if (slots.length === 0) return 没有版本目标; + return ( +
+ {slots.slice(0, max).map((s) => { + const b = slotBadge(s); + return ( + + + {s.name} + + {b.label} + + ); + })} + {slots.length > max ? +{slots.length - max} : null} +
+ ); +} + +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 ( +
+
上次更新
+
{relTime(x.lastUpdatedAt)}
+
下次搜索
+
{nextSearchText(x)}
+
站点
+
s.name).join("、")}> + {foldNames(x.sites.map((s) => s.name))} +
+
规则组
+
s.name).join("、")}> + {foldNames( + x.ruleGroups.map((s) => s.name), + 2, + "未设置", + )} +
+
+ ); +} + +function TitleLine({ x }: { x: Item }) { + const season = seasonLabel(x); + return ( +
+

+ {itemTitle(x)} +

+

+ {[x.year, SUB_KIND_LABEL[x.mediaKind], season].filter(Boolean).join(" · ")} +

+
+ ); +} + +export function SubscriptionCard({ + x, + onEdit, + selected, + onSelect, +}: { + x: Item; + onEdit: () => void; + selected?: boolean; + onSelect?: (v: boolean) => void; +}) { + return ( +
+
+ +
+ + + +
+ +
+
+
+ {onSelect ? ( + onSelect(e.target.checked)} + className="h-4 w-4 cursor-pointer accent-foreground" + /> + ) : null} + +
+
+ + + +

{autoFillText(x.autoFill)}

+
+ ); +} + +/** 列表视图的一行(桌面表格化,手机折成两行) */ +export function SubscriptionRow({ + x, + onEdit, + selected, + onSelect, +}: { + x: Item; + onEdit: () => void; + selected?: boolean; + onSelect?: (v: boolean) => void; +}) { + return ( +
  • +
    + {onSelect ? ( + onSelect(e.target.checked)} + className="h-4 w-4 cursor-pointer accent-foreground" + /> + ) : null} + +
    + + + + +
    + +
    + {relTime(x.lastUpdatedAt)} + {nextSearchText(x)} +
    + + + + +
    +
    + +
    +
  • + ); +} diff --git a/apps/web/app/components/subscriptions/slot-order.tsx b/apps/web/app/components/subscriptions/slot-order.tsx new file mode 100644 index 00000000..7ff0139b --- /dev/null +++ b/apps/web/app/components/subscriptions/slot-order.tsx @@ -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({ + 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(null); + const [over, setOver] = useState(null); + return ( +
      + {items.map((it, i) => ( +
    1. { + 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]", + )} + > + + {i + 1} + + {it.name} + {i === 0 ? 主版本 : null} + + + + + + {renderExtra ? renderExtra(it, i) : null} +
    2. + ))} +
    + ); +} diff --git a/apps/web/app/components/subscriptions/slot-panel.tsx b/apps/web/app/components/subscriptions/slot-panel.tsx new file mode 100644 index 00000000..243a7f0e --- /dev/null +++ b/apps/web/app/components/subscriptions/slot-panel.tsx @@ -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( + () => (cov?.rows ?? []).filter((r) => r.slotId !== null).map((r) => ({ ...r, id: r.slotId! })), + [cov], + ); + const [rows, setRows] = useState(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>(base); + useEffect(() => setAf(base), [base]); + const [errs, setErrs] = useState({}); + 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 ( + + + + + ) : null + } + > + {rows.length === 0 ? ( +

    + {sub.mediaEntityId ? "还没有版本目标。在下方规则区新建。" : "作品建档后才有版本目标。"} +

    + ) : ( + ( + + 已入库 {r.summary.have} + 缺 {r.summary.missing} + 在途 {r.summary.inflight} + {i === 0 ? null : {r.autoFill ? "自动补全" : "只登记缺失"}} + + + )} + /> + )} +
    +
    +

    自动补全其他版本

    + {item ? ( + + 当前{item.autoFill.enabled ? "开启" : "关闭"} · 来自{AUTOFILL_SOURCE[item.autoFill.source]} + + ) : null} +
    + setAf((x) => ({ ...x, ...p }))} errors={errs} idPrefix={id} /> + {afDirty ? ( +
    + + +
    + ) : null} +
    +
    + ); +} diff --git a/apps/web/app/components/subscriptions/subscription-form.tsx b/apps/web/app/components/subscriptions/subscription-form.tsx new file mode 100644 index 00000000..8c9066a4 --- /dev/null +++ b/apps/web/app/components/subscriptions/subscription-form.tsx @@ -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({ + id, + value, + onChange, + options, + placeholder, + disabled, +}: { + id: string; + value: T | ""; + onChange: (v: T | "") => void; + options: { value: T; label: string }[]; + /** 有值时提供"空"选项 */ + placeholder?: string; + disabled?: boolean; +}) { + return ( + + ); +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
    +

    {title}

    + {children} +
    + ); +} + +/** 作品选择:全局搜索 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) => ( +
  • + +
  • + ); + const term = dq.trim(); + return ( + +
    + + setQ(e.target.value)} + /> +
    + {term ? ( +
    + {s.isPending ? ( +

    搜索中…

    + ) : s.isError ? ( +

    {errorMessage(s.error)}

    + ) : ( + <> + {local.length ? ( + <> +

    媒体库

    +
      + {local.map((h) => + row( + h.entity.id, + <> + {h.entity.title} + {h.entity.year ? ({h.entity.year}) : null} + {h.subscribed ? 已订阅 : null} + , + SUB_KIND_LABEL[toSubKind(h.entity.kind)], + () => pickLocal(h), + ), + )} +
    + + ) : null} + {tmdb.length ? ( + <> +

    TMDB

    +
      + {tmdb.map((c) => + row( + `tmdb-${c.externalId}`, + <> + {c.title} + {c.year ? ({c.year}) : null} + , + c.entityType === "movie" ? "电影" : "剧集", + () => pickTmdb(c), + ), + )} +
    + + ) : null} + {!local.length && !tmdb.length ? ( +

    + 没有匹配的作品。 + +

    + ) : null} + {s.data?.media && !s.data.media.tmdb.configured ? ( +

    TMDB 未配置,只搜了媒体库。

    + ) : null} + + )} +
    + ) : null} +
    + ); +} + +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(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(() => (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({}); + const [dup, setDup] = useState(null); + // 编辑:版本目标到了再回填升级策略 + useEffect(() => { + if (sub && slotsQ.isSuccess && !upgradeTouched) setD((x) => ({ ...x, upgrade: initialUpgrade })); + }, [sub, slotsQ.isSuccess, initialUpgrade, upgradeTouched]); + + const patch = (p: Partial) => 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 ( + !o && !pending && onClose()}> + + + {editing ? "编辑订阅" : "新建订阅"} + + {editing ? titleText : "从媒体库或 TMDB 选作品;库里没有的按标题追踪,建档后自动关联。"} + + + {dup ? ( +
    + 这部作品的同一季已有订阅,不能重复创建。 + + 打开已有订阅 + + +
    + ) : null} + + + 基础 + 进阶 + + + +
    + {editing ? null : target ? ( +
    +
    + {target.kind === "title" ? ( + setTarget({ ...target, titleQuery: e.target.value })} + /> + ) : ( +

    {titleText}

    + )} +

    + {target.kind === "entity" ? "媒体库作品" : (target.note ?? "按标题追踪,尚未建档")} +

    +
    + +
    + ) : ( + + )} +
    + + { + 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"} + /> + + + patch({ mode: (v || "continuous") as SubscriptionMode })} + options={(["once", "continuous"] as const).map((v) => ({ value: v, label: SUB_MODE[v] }))} + /> + +
    + {isMovie ? null : ( +
    + + patch({ seasonUnitId: v })} + placeholder="全部季" + options={seasons.map((s) => ({ + value: s.id, + label: s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`, + }))} + disabled={editing || !entityId} + /> + + + patch({ seasonScope: (v || "following_seasons") as SeasonScope })} + options={(["current_season", "following_seasons"] as const).map((v) => ({ + value: v, + label: SEASON_SCOPE[v], + }))} + /> + + + patch({ episodeStart: e.target.value })} + /> + + + patch({ episodeEnd: e.target.value })} + /> + + + { + setTotalTouched(true); + patch({ total: e.target.value }); + }} + /> + + +
    + )} + {d.mode === "continuous" && d.seasonScope === "following_seasons" && !isMovie ? ( +

    特别篇、衍生节目和同名重启不算作下一季。

    + ) : null} +
    + +
    + + {sites.isPending ? ( +

    读取站点…

    + ) : ( + s.enabled || d.siteIds.includes(s.id)) + .map((s) => ({ value: s.id, label: s.name }))} + value={d.siteIds} + onChange={(v) => patch({ siteIds: v })} + /> + )} +
    +
    + + patch({ downloaderInstanceId: v })} + placeholder="默认下载器" + options={(dls.data?.items ?? []).map((x) => ({ value: x.id, label: x.name }))} + /> + + + patch({ libraryRootId: v })} + placeholder="按类型默认" + options={(roots.data?.items ?? []).map((x) => ({ value: x.id, label: x.name }))} + /> + +
    + + patch({ downloadSavePath: e.target.value })} + /> + +
    + +
    + {editing ? ( + slotsQ.isPending ? ( +

    读取版本目标…

    + ) : subSlots.length === 0 ? ( +

    还没有版本目标。在对比视图里新建。

    + ) : ( + + ({ id: s.id, name: s.name }))} + onChange={setOrderState} + disabled={pending} + /> + + ) + ) : ( + + patch({ policyRevisionId: v })} + placeholder={defaultTpl ? `按已发布收录策略(${defaultTpl.title})` : "按已发布收录策略"} + options={templates.map((p) => ({ + value: p.id, + label: `${p.title} r${p.revision}:${tplNames(p.id).join(" → ") || "无模板"}`, + }))} + /> + + )} + + { + 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] }), + )} + /> + +
    +
    + + +
    + + {groups.isPending ? ( +

    读取规则组…

    + ) : (groups.data?.items ?? []).length === 0 ? ( +

    + 还没有规则组。 + + 去规则页 + +

    + ) : ( + ({ value: g.id, label: g.name }))} + value={d.ruleGroupIds} + onChange={(v) => patch({ ruleGroupIds: v })} + /> + )} +
    +
    + +