import type { AcquisitionSlot, AutoFillMode, CalendarEntryState, CoverageCell, JsonObject, Subscription, SubscriptionCalendarEntry, SubscriptionCoverage, SubscriptionEffect, SubscriptionGroup, SubscriptionMediaKind, SubscriptionOverviewItem, SubscriptionResolution, SubscriptionSlotSummary, SubscriptionSourcePreset, } 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 const RESOLUTION_LABEL: Record = { "4k": "4K", "1080p": "1080p", "720p": "720p", }; export const SOURCE_PRESET_LABEL: Record = { bluray_disc: "蓝光原盘", remux: "Remux", bluray: "BluRay", uhd: "UHD", web: "WEB-DL", hdtv: "HDTV", h265: "H.265", h264: "H.264", }; export const EFFECT_LABEL: Record = { dv: "杜比视界", hdr: "HDR", sdr: "SDR", atmos: "杜比全景声", }; const oneOf = (v: unknown, labels: Record): K | "" => typeof v === "string" && v in labels ? (v as K) : ""; 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; /** MP 分辨率 / 资源质量 / 特效预设(写到该订阅自己的收录版本) */ resolution: SubscriptionResolution | ""; source: SubscriptionSourcePreset | ""; effect: SubscriptionEffect | ""; 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: "", resolution: "", source: "", effect: "", 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"), resolution: oneOf(p.resolution, RESOLUTION_LABEL), source: oneOf(p.source, SOURCE_PRESET_LABEL), effect: oneOf(p.effect, EFFECT_LABEL), 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", "resolution", "source", "effect", ]; 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); set("resolution", d.resolution); set("source", d.source); set("effect", d.effect); 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 }; } // ── 日历(设计 §4)── export const CALENDAR_STATE_LABEL: Record = { have: "已整理", partial: "部分整理", missing: "未整理", upcoming: "未播出", }; const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; /** "YYYY-MM" → 该月第一天;无效时取今天所在月 */ export function monthStart(month: string | undefined, today = new Date()): Date { const m = /^(\d{4})-(\d{2})$/.exec(month ?? ""); if (m) { const d = new Date(Number(m[1]), Number(m[2]) - 1, 1); if (!Number.isNaN(d.getTime())) return d; } return new Date(today.getFullYear(), today.getMonth(), 1); } export const monthKey = (d: Date) => ymd(d).slice(0, 7); export function shiftMonth(d: Date, n: number): Date { return new Date(d.getFullYear(), d.getMonth() + n, 1); } /** 月视图网格:从该月 1 日所在周的周一起,整周铺满(5 或 6 周) */ export function monthGrid(first: Date): { date: string; inMonth: boolean }[] { const start = new Date(first); start.setDate(1 - ((first.getDay() + 6) % 7)); const last = new Date(first.getFullYear(), first.getMonth() + 1, 0); const days: { date: string; inMonth: boolean }[] = []; for (let d = new Date(start); days.length < 42; d.setDate(d.getDate() + 1)) { if (d > last && days.length % 7 === 0) break; days.push({ date: ymd(d), inMonth: d.getMonth() === first.getMonth() }); } return days; } /** 按日期分桶 */ export function entriesByDate(entries: readonly SubscriptionCalendarEntry[]): Map { const m = new Map(); for (const e of entries) m.set(e.date, [...(m.get(e.date) ?? []), e]); return m; } export const todayKey = (now = new Date()) => ymd(now);