diff --git a/apps/web/app/api/subscriptions.ts b/apps/web/app/api/subscriptions.ts index 89caad1a..f4382228 100644 --- a/apps/web/app/api/subscriptions.ts +++ b/apps/web/app/api/subscriptions.ts @@ -7,6 +7,7 @@ import { Subscription, type SubscriptionBatch, SubscriptionBatchResult, + SubscriptionCalendar, SubscriptionCoverage, type SubscriptionCreate, type SubscriptionGroup, @@ -14,6 +15,7 @@ import { type SubscriptionOrigin, SubscriptionOverviewPage, SubscriptionProgress, + SubscriptionResetResult, type SubscriptionSort, type SubscriptionStatus, type SubscriptionUpdate, @@ -30,6 +32,8 @@ export const subscriptionKeys = { overview: (f: OverviewFilters) => ["subscriptions", "overview", f] as const, detail: (id: string) => ["subscriptions", "detail", id] as const, coverage: (id: string) => ["subscriptions", "coverage", id] as const, + calendar: (from: string, to: string, kind?: SubscriptionMediaKind) => + ["subscriptions", "calendar", from, to, kind ?? ""] as const, }; export type SubscriptionFilters = { @@ -254,6 +258,28 @@ export function useSetSlotOrder() { }); } +/** 重置进度:取消未提交/下载已不在的计划,清延后,重评;仍在下载的保留,不删资产 */ +export function useResetSubscription() { + const done = useInvalidateSubs(); + return useMutation({ + mutationFn: (id: string) => + api + .post(`/subscriptions/${id}/reset`) + .then((d) => parseOrThrow(SubscriptionResetResult, d, "/subscriptions/reset")), + onSuccess: done, + }); +} + +/** 日历:from / to 为 YYYY-MM-DD,跨度不超过 62 天 */ +export function useSubscriptionCalendar(from: string, to: string, mediaKind?: SubscriptionMediaKind) { + return useQuery({ + queryKey: subscriptionKeys.calendar(from, to, mediaKind), + queryFn: ({ signal }) => + getParsed(SubscriptionCalendar, "/subscriptions/calendar", { from, to, mediaKind }, signal), + placeholderData: keepPreviousData, + }); +} + /** 409 重复订阅:消息里带已有订阅 id */ export function duplicateSubscriptionId(message: string): string | null { const m = /已有订阅[::]\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(message); diff --git a/apps/web/app/components/subscriptions/actions.tsx b/apps/web/app/components/subscriptions/actions.tsx index 73e9ec2e..0cc00559 100644 --- a/apps/web/app/components/subscriptions/actions.tsx +++ b/apps/web/app/components/subscriptions/actions.tsx @@ -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({ 恢复 ) : null} + {!ended ? ( + } disabled={ops.pending} onClick={run(() => setConfirmReset(true))}> + 重置 + + ) : null} {ended ? ( } disabled={ops.pending} onClick={run(() => ops.act(sub.id, "resubscribe"))}> 重新订阅 @@ -169,6 +182,19 @@ export function SubscriptionMenu({ + + ops.reset(sub.id).catch((e) => { + toast.error(errorMessage(e)); + throw e; + }) + } + /> ); } diff --git a/apps/web/app/components/subscriptions/calendar.tsx b/apps/web/app/components/subscriptions/calendar.tsx new file mode 100644 index 00000000..b67136ae --- /dev/null +++ b/apps/web/app/components/subscriptions/calendar.tsx @@ -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 = { + 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 = { + have: "bg-success", + partial: "bg-warning", + missing: "bg-destructive", + upcoming: "bg-muted-foreground/50", +}; + +function Entry({ e }: { e: SubscriptionCalendarEntry }) { + return ( + 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], + )} + > + + {e.title} + {e.label} + {CALENDAR_STATE_LABEL[e.state]} + + ); +} + +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 ( +
+
+ +

{title}

+ + +
    + {(Object.keys(CALENDAR_STATE_LABEL) as CalendarEntryState[]).map((k) => ( +
  • + + {CALENDAR_STATE_LABEL[k]} +
  • + ))} +
+
+ + {q.isPending ? ( + + ) : q.isError ? ( + q.refetch()} /> + ) : ( + <> +
+
+ {WEEKDAYS.map((w) => ( +
+ 周{w} +
+ ))} + {grid.map((d, i) => { + const list = byDate.get(d.date) ?? []; + return ( +
+ + {list.map((e) => ( + + ))} +
+ ); + })} +
+
+ {!q.data?.entries.length && !unknown.length ? ( + + ) : null} + {unknown.length ? ( +
+

+ 播出日期未知 + + {unknown.length} + +

+

TMDB 还没有给出这些集的播出日期,元数据刷新后更新。

+
    + {unknown.map((u) => ( +
  • + + {u.title} + + {SUB_KIND_LABEL[u.mediaKind]} + {u.seasonNumber !== null ? ` · S${String(u.seasonNumber).padStart(2, "0")}` : ""} + {u.episodes.length ? ` · ${u.episodes.length} 集` : ""} + + +
  • + ))} +
+
+ ) : null} + + )} +
+ ); +} diff --git a/apps/web/app/components/subscriptions/model.test.ts b/apps/web/app/components/subscriptions/model.test.ts index 9f32aea4..a0e3aef6 100644 --- a/apps/web/app/components/subscriptions/model.test.ts +++ b/apps/web/app/components/subscriptions/model.test.ts @@ -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(); + }); }); diff --git a/apps/web/app/components/subscriptions/model.ts b/apps/web/app/components/subscriptions/model.ts index 96fead07..bd0096aa 100644 --- a/apps/web/app/components/subscriptions/model.ts +++ b/apps/web/app/components/subscriptions/model.ts @@ -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(arr: readonly T[], from: number, to: number): T[] { // ── 表单 ── +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; @@ -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 = { + 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); diff --git a/apps/web/app/components/subscriptions/subscription-form.tsx b/apps/web/app/components/subscriptions/subscription-form.tsx index e2f44009..081c79fa 100644 --- a/apps/web/app/components/subscriptions/subscription-form.tsx +++ b/apps/web/app/components/subscriptions/subscription-form.tsx @@ -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({ /> +
+ + patch({ resolution: v })} + placeholder="不限" + options={Object.entries(RESOLUTION_LABEL).map(([value, label]) => ({ + value: value as keyof typeof RESOLUTION_LABEL, + label, + }))} + /> + + + patch({ source: v })} + placeholder="不限" + options={Object.entries(SOURCE_PRESET_LABEL).map(([value, label]) => ({ + value: value as keyof typeof SOURCE_PRESET_LABEL, + label, + }))} + /> + + + patch({ effect: v })} + placeholder="不限" + options={Object.entries(EFFECT_LABEL).map(([value, label]) => ({ + value: value as keyof typeof EFFECT_LABEL, + label, + }))} + /> + +
+

+ 分辨率、资源质量、特效写进这个订阅自己的版本目标;UHD 和杜比全景声按标题匹配,与「包含」同时生效。 +

({ } export default function SubscriptionsPage() { - const f = useQueryState(["kind", "group", "q", "site", "rg", "origin", "up", "sort"] as const); + const f = useQueryState(["kind", "group", "q", "site", "rg", "origin", "up", "sort", "view", "month"] as const); const v = f.values; const [q, setQ] = useState(v.q); const dq = useDebounced(q.trim()); @@ -111,7 +113,9 @@ export default function SubscriptionsPage() { /* 无存储时用卡片 */ } }, []); + const calendar = v.view === "calendar"; const setView = (x: "card" | "list") => { + if (calendar) f.set("view", ""); setViewState(x); try { localStorage.setItem(VIEW_KEY, x); @@ -219,21 +223,23 @@ export default function SubscriptionsPage() { ]} />
- + {calendar ? null : ( + + )}
+
-
- f.set("group", x)} - items={[ - { value: "", label: "全部", count: allCount }, - ...GROUP_ORDER.map((g) => ({ value: g, label: GROUP_LABEL[g], count: counts?.[g] })), - ]} + {calendar ? ( + f.set("month", m)} + mediaKind={(v.kind || undefined) as "series" | "movie" | "variety" | undefined} /> -
- { - setQ(""); - for (const k of ["q", "site", "rg", "origin", "up"] as const) f.set(k, ""); - } - : undefined - } - > - f.set("site", x)} - options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))} - /> - f.set("rg", x)} - options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))} - /> - f.set("origin", x)} options={opts(SUB_ORIGIN)} /> - f.set("up", x)} - options={[ - { value: "true", label: "洗版中" }, - { value: "false", label: "未洗版" }, - ]} - /> - f.set("sort", x)} - allLabel="按分组" - options={opts(SORT_LABEL)} - /> - - - {selecting ? ( -
- 已选 {selIds.length} - - - - - - - -
- ) : null} - - {query.isPending ? ( - - ) : query.isError ? ( - query.refetch()} /> - ) : items.length === 0 ? ( - - ) : grouped ? ( -
- {GROUP_ORDER.map((g) => { - const list = items.filter((x) => x.group === g); - if (!list.length) return null; - const closed = collapsed.has(g); - return ( -
- - {closed ? null : renderItems(list)} -
- ); - })} -
) : ( - renderItems(items) + <> +
+ f.set("group", x)} + items={[ + { value: "", label: "全部", count: allCount }, + ...GROUP_ORDER.map((g) => ({ value: g, label: GROUP_LABEL[g], count: counts?.[g] })), + ]} + /> +
+ { + setQ(""); + for (const k of ["q", "site", "rg", "origin", "up"] as const) f.set(k, ""); + } + : undefined + } + > + f.set("site", x)} + options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))} + /> + f.set("rg", x)} + options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))} + /> + f.set("origin", x)} + options={opts(SUB_ORIGIN)} + /> + f.set("up", x)} + options={[ + { value: "true", label: "洗版中" }, + { value: "false", label: "未洗版" }, + ]} + /> + f.set("sort", x)} + allLabel="按分组" + options={opts(SORT_LABEL)} + /> + + + {selecting ? ( +
+ 已选 {selIds.length} + + + + + + + +
+ ) : null} + + {query.isPending ? ( + + ) : query.isError ? ( + query.refetch()} /> + ) : items.length === 0 ? ( + + ) : grouped ? ( +
+ {GROUP_ORDER.map((g) => { + const list = items.filter((x) => x.group === g); + if (!list.length) return null; + const closed = collapsed.has(g); + return ( +
+ + {closed ? null : renderItems(list)} +
+ ); + })} +
+ ) : ( + renderItems(items) + )} + {query.hasNextPage ? ( +
+ +
+ ) : null} + )} - {query.hasNextPage ? ( -
- -
- ) : null} {creating ? setCreating(false)} /> : null} {editing ? (