feat(web): subscription calendar view, reset action and quality preset fields

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:14:40 +08:00
1 parent 70d56645fe
commit 05350460f0
7 files changed
+638 -166

No files matched your search

@@ -1,14 +1,19 @@
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";
@@ -182,6 +187,30 @@ 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;
@@ -202,6 +231,10 @@ export type SubscriptionDraft = {
include: string;
exclude: string;
keywords: string;
/** MP 分辨率 / 资源质量 / 特效预设(写到该订阅自己的版本目标) */
resolution: SubscriptionResolution | "";
source: SubscriptionSourcePreset | "";
effect: SubscriptionEffect | "";
searchIntervalHours: string;
stopAfter: string;
autoFill: AutoFillMode;
@@ -239,6 +272,9 @@ export function emptyDraft(kind: SubscriptionMediaKind = "series"): Subscription
include: "",
exclude: "",
keywords: "",
resolution: "",
source: "",
effect: "",
searchIntervalHours: "",
stopAfter: "",
autoFill: "inherit",
@@ -281,6 +317,9 @@ export function draftFromSubscription(sub: Subscription, upgrade: UpgradeStrateg
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",
@@ -318,6 +357,9 @@ export function draftToParams(
"downloadSavePath",
"autoFill",
"fillBudget",
"resolution",
"source",
"effect",
];
for (const k of known) delete out[k];
const set = (k: string, v: unknown) => {
@@ -334,6 +376,9 @@ export function draftToParams(
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();
@@ -380,3 +425,53 @@ export function draftToParams(
}
return { params: out as JsonObject, errors };
}
// ── 日历(设计 §4)──
export const CALENDAR_STATE_LABEL: Record<CalendarEntryState, string> = {
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<string, SubscriptionCalendarEntry[]> {
const m = new Map<string, SubscriptionCalendarEntry[]>();
for (const e of entries) m.set(e.date, [...(m.get(e.date) ?? []), e]);
return m;
}
export const todayKey = (now = new Date()) => ymd(now);