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,10 +1,11 @@
import type { Subscription } from "@umt/contracts";
import { Grid3x3, MoreHorizontal, Pause, Pencil, Play, RotateCcw, Search, Trash2 } from "lucide-react";
import { Eraser, Grid3x3, MoreHorizontal, Pause, Pencil, Play, RotateCcw, Search, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import {
type SubscriptionAction,
useResetSubscription,
useSearchSubscription,
useSubscriptionAction,
useSubscriptionBatch,
@@ -26,7 +27,8 @@ export function useSubscriptionOps() {
const search = useSearchSubscription();
const action = useSubscriptionAction();
const batch = useSubscriptionBatch();
const pending = search.isPending || action.isPending || batch.isPending;
const reset = useResetSubscription();
const pending = search.isPending || action.isPending || batch.isPending || reset.isPending;
return {
pending,
search: (id: string) =>
@@ -42,6 +44,11 @@ export function useSubscriptionOps() {
onError: (e) => toast.error(errorMessage(e)),
},
),
reset: (id: string) =>
reset.mutateAsync(id).then((r) => {
const kept = r.keptPlans.length ? `,${r.keptPlans.length} 个仍在下载的保留` : "";
toast.success(`已重置:释放 ${r.releasedPlans.length} 个计划${kept}`);
}),
remove: (ids: string[]) =>
batch.mutateAsync({ ids, action: "delete" }).then((r) => {
const bad = r.results.filter((x) => !x.ok);
@@ -110,6 +117,7 @@ export function SubscriptionMenu({
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [confirm, setConfirm] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const run = (fn: () => void) => () => {
setOpen(false);
fn();
@@ -156,6 +164,11 @@ export function SubscriptionMenu({
恢复
</MenuItem>
) : null}
{!ended ? (
<MenuItem icon={<Eraser />} disabled={ops.pending} onClick={run(() => setConfirmReset(true))}>
重置
</MenuItem>
) : null}
{ended ? (
<MenuItem icon={<RotateCcw />} disabled={ops.pending} onClick={run(() => ops.act(sub.id, "resubscribe"))}>
重新订阅
@@ -169,6 +182,19 @@ export function SubscriptionMenu({
</PopoverContent>
</Popover>
<DeleteSubscriptionsDialog open={confirm} onOpenChange={setConfirm} ids={[sub.id]} />
<ConfirmDialog
open={confirmReset}
onOpenChange={setConfirmReset}
title="重置这个订阅的进度?"
description="取消还没提交、或下载任务已不在的计划,清掉延后记录,然后重新评估。仍在下载的任务、已入库文件和总集数保留。"
confirmLabel="重置"
onConfirm={() =>
ops.reset(sub.id).catch((e) => {
toast.error(errorMessage(e));
throw e;
})
}
/>
</>
);
}
@@ -0,0 +1,191 @@
import type { CalendarEntryState, SubscriptionCalendarEntry, SubscriptionMediaKind } from "@umt/contracts";
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
import { Link } from "react-router";
import { useSubscriptionCalendar } from "~/api/subscriptions.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
import {
CALENDAR_STATE_LABEL,
entriesByDate,
monthGrid,
monthKey,
monthStart,
SUB_KIND_LABEL,
shiftMonth,
todayKey,
} from "./model.ts";
// 订阅日历(设计 §4):月视图,事件 = 追踪中订阅范围内集的播出日期(同日同季聚合),电影取上映日期。
// 状态色只表示入库状态:已入库 success、部分 warning、未入库 destructive、未播出无彩。条目链到订阅详情。
const WEEKDAYS = ["一", "二", "三", "四", "五", "六", "日"];
const STATE_CLS: Record<CalendarEntryState, string> = {
have: "border-success/40 bg-success/10",
partial: "border-warning/40 bg-warning/10",
missing: "border-destructive/40 bg-destructive/10",
upcoming: "border-border bg-foreground/[0.04]",
};
const DOT_CLS: Record<CalendarEntryState, string> = {
have: "bg-success",
partial: "bg-warning",
missing: "bg-destructive",
upcoming: "bg-muted-foreground/50",
};
function Entry({ e }: { e: SubscriptionCalendarEntry }) {
return (
<Link
to={consolePath("subscriptions", e.subscriptionId)}
title={`${e.title} ${e.label} · ${CALENDAR_STATE_LABEL[e.state]}${e.total > 1 ? ` ${e.have}/${e.total}` : ""}`}
className={cn(
"flex min-w-0 items-center gap-1 rounded-tight border px-1.5 py-0.5 text-meta text-foreground transition-colors duration-micro hover:bg-foreground/[0.06]",
STATE_CLS[e.state],
)}
>
<span aria-hidden className={cn("h-1.5 w-1.5 shrink-0 rounded-full", DOT_CLS[e.state])} />
<span className="truncate">{e.title}</span>
<span className="shrink-0 text-muted-foreground tabular-nums">{e.label}</span>
<span className="sr-only">{CALENDAR_STATE_LABEL[e.state]}</span>
</Link>
);
}
export function SubscriptionCalendarView({
month,
onMonth,
mediaKind,
}: {
/** YYYY-MM;空 = 本月 */
month: string;
onMonth: (m: string) => void;
mediaKind?: SubscriptionMediaKind | undefined;
}) {
const first = monthStart(month);
const grid = monthGrid(first);
const from = grid[0]!.date;
const to = grid.at(-1)!.date;
const q = useSubscriptionCalendar(from, to, mediaKind);
const byDate = entriesByDate(q.data?.entries ?? []);
const today = todayKey();
const title = `${first.getFullYear()} 年 ${first.getMonth() + 1} 月`;
const unknown = q.data?.unknown ?? [];
return (
<section aria-label="订阅日历" className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="icon-sm"
aria-label="上个月"
onClick={() => onMonth(monthKey(shiftMonth(first, -1)))}
>
<ChevronLeft />
</Button>
<h2 className="min-w-28 text-center text-body font-semibold text-foreground tabular-nums">{title}</h2>
<Button
variant="ghost"
size="icon-sm"
aria-label="下个月"
onClick={() => onMonth(monthKey(shiftMonth(first, 1)))}
>
<ChevronRight />
</Button>
<Button variant="ghost" size="sm" onClick={() => onMonth("")}>
本月
</Button>
<ul className="ml-auto flex flex-wrap gap-x-3 gap-y-1 text-meta text-muted-foreground">
{(Object.keys(CALENDAR_STATE_LABEL) as CalendarEntryState[]).map((k) => (
<li key={k} className="flex items-center gap-1.5">
<span aria-hidden className={cn("h-2 w-2 rounded-full", DOT_CLS[k])} />
{CALENDAR_STATE_LABEL[k]}
</li>
))}
</ul>
</div>
{q.isPending ? (
<LoadingState rows={5} />
) : q.isError ? (
<ErrorState error={q.error} onRetry={() => q.refetch()} />
) : (
<>
<div className="overflow-x-auto scroll-bleed">
<div className="pg-slab grid min-w-[640px] grid-cols-7 overflow-hidden rounded-xl">
{WEEKDAYS.map((w) => (
<div
key={w}
className="border-b border-border/60 px-2 py-1.5 text-meta font-medium text-muted-foreground"
>
周{w}
</div>
))}
{grid.map((d, i) => {
const list = byDate.get(d.date) ?? [];
return (
<div
key={d.date}
className={cn(
"min-h-24 min-w-0 space-y-1 border-border/60 p-1.5",
i % 7 !== 6 && "border-r",
i < grid.length - 7 && "border-b",
!d.inMonth && "bg-foreground/[0.02]",
)}
>
<time
dateTime={d.date}
className={cn(
"block text-meta tabular-nums",
d.inMonth ? "text-muted-foreground" : "text-muted-foreground/50",
d.date === today && "font-semibold text-foreground",
)}
>
{Number(d.date.slice(8, 10))}
{d.date === today ? <span className="ml-1">今天</span> : null}
</time>
{list.map((e) => (
<Entry key={`${e.subscriptionId}-${e.seasonNumber ?? ""}-${e.date}`} e={e} />
))}
</div>
);
})}
</div>
</div>
{!q.data?.entries.length && !unknown.length ? (
<EmptyState icon={CalendarDays} title="这个月没有播出" description="追踪中的订阅有播出日期后出现在这里。" />
) : null}
{unknown.length ? (
<section aria-labelledby="cal-unknown" className="space-y-2">
<h3 id="cal-unknown" className="text-body font-semibold text-foreground">
播出日期未知
<span className="ml-1.5 text-meta font-normal text-muted-foreground tabular-nums">
{unknown.length}
</span>
</h3>
<p className="text-meta text-muted-foreground">TMDB 还没有给出这些集的播出日期,元数据刷新后更新。</p>
<ul className="pg-slab divide-y divide-border/60 overflow-hidden rounded-xl">
{unknown.map((u) => (
<li key={`${u.subscriptionId}-${u.seasonNumber ?? ""}`}>
<Link
to={consolePath("subscriptions", u.subscriptionId)}
className="flex min-w-0 items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-foreground/[0.04]"
>
<span className="truncate">{u.title}</span>
<span className="shrink-0 text-meta text-muted-foreground">
{SUB_KIND_LABEL[u.mediaKind]}
{u.seasonNumber !== null ? ` · S${String(u.seasonNumber).padStart(2, "0")}` : ""}
{u.episodes.length ? ` · ${u.episodes.length} 集` : ""}
</span>
</Link>
</li>
))}
</ul>
</section>
) : null}
</>
)}
</section>
);
}
@@ -5,10 +5,15 @@ import {
draftFromSubscription,
draftToParams,
emptyDraft,
entriesByDate,
foldNames,
monthGrid,
monthKey,
monthStart,
moveItem,
progressText,
seasonLabel,
shiftMonth,
slotBadge,
upgradeOf,
} from "./model.ts";
@@ -153,4 +158,51 @@ describe("表单 ↔ params", () => {
expect(d.keywords).toBe("a\nb");
expect(d.upgrade).toBe("per_unit");
});
it("质量预设:草稿 ↔ params;未知值回填为空", () => {
const d = { ...emptyDraft("movie"), resolution: "4k" as const, source: "remux" as const, effect: "dv" as const };
const { params } = draftToParams(
d,
{ resolution: "720p", effect: "sdr" },
{ upgradeChanged: false, totalChanged: false },
);
expect(params).toEqual({ resolution: "4k", source: "remux", effect: "dv" });
const cleared = draftToParams(
emptyDraft("movie"),
{ resolution: "720p" },
{ upgradeChanged: false, totalChanged: false },
);
expect(cleared.params.resolution).toBeUndefined();
const back = draftFromSubscription(
{
mediaKind: "movie",
seasonScope: "current_season",
mode: "once",
priority: "tracking",
seasonUnitId: null,
policyRevisionId: null,
params: { resolution: "1080p", source: "bogus", effect: "hdr" },
} as unknown as Subscription,
"off",
);
expect([back.resolution, back.source, back.effect]).toEqual(["1080p", "", "hdr"]);
});
});
describe("日历", () => {
it("月网格从周一开始,整周铺满;跨年切月", () => {
const first = monthStart("2026-09");
const g = monthGrid(first);
expect(g[0]).toEqual({ date: "2026-08-31", inMonth: false });
expect(g.length % 7).toBe(0);
expect(g.at(-1)).toEqual({ date: "2026-10-04", inMonth: false });
expect(g.filter((d) => d.inMonth)).toHaveLength(30);
expect(monthKey(shiftMonth(monthStart("2026-12"), 1))).toBe("2027-01");
expect(monthKey(monthStart("bad", new Date(2026, 4, 9)))).toBe("2026-05");
});
it("按日期分桶", () => {
const e = (date: string) => ({ date, subscriptionId: date }) as never;
const m = entriesByDate([e("2026-09-01"), e("2026-09-01"), e("2026-09-02")]);
expect(m.get("2026-09-01")).toHaveLength(2);
expect(m.get("2026-09-03")).toBeUndefined();
});
});
@@ -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);
@@ -51,7 +51,10 @@ import {
type DraftErrors,
draftFromSubscription,
draftToParams,
EFFECT_LABEL,
emptyDraft,
RESOLUTION_LABEL,
SOURCE_PRESET_LABEL,
SUB_KIND_LABEL,
type SubscriptionDraft,
UPGRADE_LABEL,
@@ -713,6 +716,47 @@ export function SubscriptionFormDialog({
/>
</FormField>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<FormField label="分辨率" htmlFor={f("res")}>
<Pick
id={f("res")}
value={d.resolution}
onChange={(v) => patch({ resolution: v })}
placeholder="不限"
options={Object.entries(RESOLUTION_LABEL).map(([value, label]) => ({
value: value as keyof typeof RESOLUTION_LABEL,
label,
}))}
/>
</FormField>
<FormField label="资源质量" htmlFor={f("src")}>
<Pick
id={f("src")}
value={d.source}
onChange={(v) => patch({ source: v })}
placeholder="不限"
options={Object.entries(SOURCE_PRESET_LABEL).map(([value, label]) => ({
value: value as keyof typeof SOURCE_PRESET_LABEL,
label,
}))}
/>
</FormField>
<FormField label="特效" htmlFor={f("fx")}>
<Pick
id={f("fx")}
value={d.effect}
onChange={(v) => patch({ effect: v })}
placeholder="不限"
options={Object.entries(EFFECT_LABEL).map(([value, label]) => ({
value: value as keyof typeof EFFECT_LABEL,
label,
}))}
/>
</FormField>
</div>
<p className="text-meta text-muted-foreground">
分辨率、资源质量、特效写进这个订阅自己的版本目标;UHD 和杜比全景声按标题匹配,与「包含」同时生效。
</p>
<FormField
label="搜索关键词"
htmlFor={f("kw")}