Files
UHD_Media_Tool/apps/web/app/components/subscriptions/model.ts
T
rootandClaude Opus 5.5 b7a7fd3958 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]>
2026-09-28 16:27:29 +08:00

383 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}