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 }; }