feat(web): simplify subscription and manual download flows and sync UHD styles

This commit is contained in:
root committed 2026-10-13 14:37:03 +08:00
1 parent 27c0f27434
commit c3c4456913
33 files changed
+1083 -633

No files matched your search

+25 -96
View File
@@ -1,5 +1,4 @@
import type {
AcquisitionSlot,
AutoFillMode,
CalendarEntryState,
CoverageCell,
@@ -7,13 +6,10 @@ import type {
Subscription,
SubscriptionCalendarEntry,
SubscriptionCoverage,
SubscriptionEffect,
SubscriptionGroup,
SubscriptionMediaKind,
SubscriptionOverviewItem,
SubscriptionResolution,
SubscriptionSlotSummary,
SubscriptionSourcePreset,
} from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
@@ -75,7 +71,7 @@ export function foldNames(names: string[], max = 2, emptyLabel = "全部站点")
return names.length > max ? `${head} +${names.length - max}` : head;
}
export const AUTOFILL_LABEL: Record<AutoFillMode, string> = { inherit: "跟随规则组", on: "开", off: "关" };
export const AUTOFILL_LABEL: Record<AutoFillMode, string> = { inherit: "跟随主版本规则", on: "开", off: "关" };
export const AUTOFILL_SOURCE: Record<"subscription" | "rule_group" | "default", string> = {
subscription: "订阅设置",
rule_group: "规则组",
@@ -155,27 +151,6 @@ export function seasonTitle(s: SubscriptionCoverage["seasons"][number], kind: Su
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();
@@ -187,30 +162,6 @@ export function moveItem<T>(arr: readonly T[], from: number, to: number): T[] {
// ── 表单 ──
export const RESOLUTION_LABEL: Record<SubscriptionResolution, string> = {
"4k": "4K",
"1080p": "1080p",
"720p": "720p",
};
export const SOURCE_PRESET_LABEL: Record<SubscriptionSourcePreset, string> = {
bluray_disc: "蓝光原盘",
remux: "Remux",
bluray: "BluRay",
uhd: "UHD",
web: "WEB-DL",
hdtv: "HDTV",
h265: "H.265",
h264: "H.264",
};
export const EFFECT_LABEL: Record<SubscriptionEffect, string> = {
dv: "杜比视界",
hdr: "HDR",
sdr: "SDR",
atmos: "杜比全景声",
};
const oneOf = <K extends string>(v: unknown, labels: Record<K, string>): K | "" =>
typeof v === "string" && v in labels ? (v as K) : "";
export type SubscriptionDraft = {
mediaKind: SubscriptionMediaKind;
seasonUnitId: string;
@@ -226,15 +177,9 @@ export type SubscriptionDraft = {
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;
@@ -267,14 +212,9 @@ export function emptyDraft(kind: SubscriptionMediaKind = "series"): Subscription
libraryRootId: "",
downloadSavePath: "",
policyRevisionId: "",
upgrade: "off",
ruleGroupIds: [],
include: "",
exclude: "",
keywords: "",
resolution: "",
source: "",
effect: "",
searchIntervalHours: "",
stopAfter: "",
autoFill: "inherit",
@@ -284,6 +224,7 @@ export function emptyDraft(kind: SubscriptionMediaKind = "series"): Subscription
}
const GB = 1024 ** 3;
const gbStr = (v: unknown) => (typeof v === "number" ? String(Math.round((v / GB) * 10) / 10) : "");
/** datetime-local 值 ↔ ISO */
export function toLocalInput(iso: string): string {
@@ -293,11 +234,10 @@ export function toLocalInput(iso: string): string {
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 {
export function draftFromSubscription(sub: Subscription): 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,
@@ -312,19 +252,14 @@ export function draftFromSubscription(sub: Subscription, upgrade: UpgradeStrateg
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) : "",
gbPerDay: gbStr(budget.bytesPerDay),
};
}
@@ -334,32 +269,27 @@ 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 },
opts: { totalChanged: boolean; autoFillChanged: boolean; clearQualityPreset?: boolean },
): { params: JsonObject; errors: DraftErrors } {
const errors: DraftErrors = {};
const out: Record<string, unknown> = { ...base };
if (opts.clearQualityPreset) for (const k of ["resolution", "source", "effect"]) delete out[k];
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) => {
@@ -370,15 +300,11 @@ export function draftToParams(
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();
@@ -407,21 +333,24 @@ export function draftToParams(
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";
if (opts.autoFillChanged) {
delete out.autoFill;
if (d.autoFill !== "inherit") out.autoFill = d.autoFill;
const original = (base.fillBudget ?? {}) as Record<string, unknown>;
const budget = { ...original };
if (d.unitsPerDay.trim() && !isInt(d.unitsPerDay)) errors.unitsPerDay = "填非负整数";
if (d.unitsPerDay !== numStr(original.unitsPerDay)) {
delete budget.unitsPerDay;
if (d.unitsPerDay.trim() && !errors.unitsPerDay) budget.unitsPerDay = Number(d.unitsPerDay);
}
if (d.gbPerDay.trim() && (!Number.isFinite(Number(d.gbPerDay)) || Number(d.gbPerDay) < 0))
errors.gbPerDay = "填非负数";
if (d.gbPerDay !== gbStr(original.bytesPerDay)) {
delete budget.bytesPerDay;
if (d.gbPerDay.trim() && !errors.gbPerDay) budget.bytesPerDay = Math.round(Number(d.gbPerDay) * GB);
}
delete out.fillBudget;
if (Object.keys(budget).length) out.fillBudget = budget;
}
return { params: out as JsonObject, errors };
}