From 7d5ba35280ea7f0cfb5e894e8c138b024caacbbf Mon Sep 17 00:00:00 2001
From: root
Date: Tue, 6 Oct 2026 15:45:12 +0800
Subject: [PATCH] =?UTF-8?q?feat(web):=20=E4=BD=9C=E5=93=81=E9=A1=B5?=
=?UTF-8?q?=E4=B8=8E=E8=AE=A2=E9=98=85=E9=80=89=E5=AD=A3=E7=9A=84=E5=89=A7?=
=?UTF-8?q?=E9=9B=86=E7=BB=84=E8=BD=A8=E9=81=93=E5=92=8C=E5=AD=A3=E5=8D=A1?=
=?UTF-8?q?=E7=89=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
---
apps/web/app/api/episode-mapping.ts | 54 +++
.../episode-mapping/mapping-panel.tsx | 5 +-
.../components/episode-mapping/model.test.ts | 1 +
.../app/components/episode-mapping/model.ts | 1 +
.../library/episode-structure-model.ts | 67 ++++
.../library/episode-structure.test.ts | 177 +++++++++
.../components/library/episode-structure.tsx | 366 ++++++++++++++++++
.../subscriptions/subscription-form.tsx | 55 ++-
apps/web/app/routes/console/library.$id.tsx | 239 ++++++------
9 files changed, 827 insertions(+), 138 deletions(-)
create mode 100644 apps/web/app/components/library/episode-structure-model.ts
create mode 100644 apps/web/app/components/library/episode-structure.test.ts
create mode 100644 apps/web/app/components/library/episode-structure.tsx
diff --git a/apps/web/app/api/episode-mapping.ts b/apps/web/app/api/episode-mapping.ts
index f1da0756..74557600 100644
--- a/apps/web/app/api/episode-mapping.ts
+++ b/apps/web/app/api/episode-mapping.ts
@@ -1,11 +1,14 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
+ EpisodeGroupList,
+ EpisodeGroupSetResult,
EpisodeMappingPreview,
type EpisodeMappingPreviewInput,
type EpisodeMappingRuleCreate,
EpisodeMappingRuleList,
type EpisodeMappingRuleUpdate,
EpisodeMappingRuleWriteResult,
+ EpisodeStructureSeasons,
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { decisionKeys } from "./decisions.ts";
@@ -18,6 +21,9 @@ export const episodeMappingKeys = {
rules: (entityId: string) => ["episode-mapping", "rules", entityId] as const,
preview: (entityId: string, input: EpisodeMappingPreviewInput) =>
["episode-mapping", "preview", entityId, input] as const,
+ groups: (entityId: string) => ["episode-mapping", "groups", entityId] as const,
+ structure: (entityId: string, groupId: string | null) =>
+ ["episode-mapping", "structure", entityId, groupId ?? "default"] as const,
};
export function useEpisodeMappingRules(entityId: string | undefined) {
@@ -73,3 +79,51 @@ export function useEpisodeMappingWrite(entityId: string) {
},
});
}
+
+// ── 剧集组(季集结构) ──
+
+/** 作品可选的 TMDB 剧集组与当前选择 */
+export function useEpisodeGroups(entityId: string | undefined, enabled = true) {
+ return useQuery({
+ queryKey: episodeMappingKeys.groups(entityId ?? ""),
+ enabled: !!entityId && enabled,
+ staleTime: 5 * 60_000,
+ queryFn: ({ signal }) =>
+ getParsed(EpisodeGroupList, `/media-entities/${entityId}/episode-groups`, undefined, signal),
+ });
+}
+
+/** 某套季集结构(null = TMDB 默认季)的季卡片与每集,带本地已入库 */
+export function useEpisodeStructure(entityId: string | undefined, groupId: string | null, enabled = true) {
+ return useQuery({
+ queryKey: episodeMappingKeys.structure(entityId ?? "", groupId),
+ enabled: !!entityId && enabled,
+ placeholderData: keepPreviousData,
+ queryFn: ({ signal }) =>
+ getParsed(
+ EpisodeStructureSeasons,
+ `/media-entities/${entityId}/episode-structure`,
+ groupId ? { groupId } : undefined,
+ signal,
+ ),
+ });
+}
+
+/** 切换作品的季集结构:成功后刷新剧集组、预览、规则、待处理与作品详情(季集都变了) */
+export function useSetEpisodeGroup(entityId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (groupId: string | null) =>
+ parseOrThrow(
+ EpisodeGroupSetResult,
+ await api.put(`/media-entities/${entityId}/episode-group`, { groupId }),
+ "/media-entities/{id}/episode-group",
+ ),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: episodeMappingKeys.all });
+ void qc.invalidateQueries({ queryKey: decisionKeys.all });
+ void qc.invalidateQueries({ queryKey: libraryKeys.all });
+ void qc.invalidateQueries({ queryKey: ["subscriptions"] });
+ },
+ });
+}
diff --git a/apps/web/app/components/episode-mapping/mapping-panel.tsx b/apps/web/app/components/episode-mapping/mapping-panel.tsx
index 99cd777d..861ea9bb 100644
--- a/apps/web/app/components/episode-mapping/mapping-panel.tsx
+++ b/apps/web/app/components/episode-mapping/mapping-panel.tsx
@@ -65,7 +65,10 @@ function SeasonSummary({ p }: { p: EpisodeMappingPreview["seasons"] }) {
{s.season === p.latestSeason ? " · 最新" : ""}
))}
- {p.ended ? "已完结" : "未完结"}
+
+ {p.ended ? "已完结" : "未完结"}
+ {p.structure ? ` · 按剧集组「${p.structure.name}」分季` : ""}
+
);
}
diff --git a/apps/web/app/components/episode-mapping/model.test.ts b/apps/web/app/components/episode-mapping/model.test.ts
index c6350228..ea492b19 100644
--- a/apps/web/app/components/episode-mapping/model.test.ts
+++ b/apps/web/app/components/episode-mapping/model.test.ts
@@ -28,6 +28,7 @@ describe("集数映射文案", () => {
absolute_in_season: "季内绝对集号",
absolute_cross_season: "跨季绝对集号",
air_date: "按播出日期",
+ tmdb_season: "按 TMDB 默认季集换算",
unverified: "元数据未覆盖,先按原号",
unresolved: "待确认",
skipped: "不适用",
diff --git a/apps/web/app/components/episode-mapping/model.ts b/apps/web/app/components/episode-mapping/model.ts
index f6a556f3..d3062756 100644
--- a/apps/web/app/components/episode-mapping/model.ts
+++ b/apps/web/app/components/episode-mapping/model.ts
@@ -22,6 +22,7 @@ export const METHOD_LABEL: Record = {
absolute_in_season: "季内绝对集号",
absolute_cross_season: "跨季绝对集号",
air_date: "按播出日期",
+ tmdb_season: "按 TMDB 默认季集换算",
unverified: "元数据未覆盖,先按原号",
unresolved: "待确认",
skipped: "不适用",
diff --git a/apps/web/app/components/library/episode-structure-model.ts b/apps/web/app/components/library/episode-structure-model.ts
new file mode 100644
index 00000000..d4e59103
--- /dev/null
+++ b/apps/web/app/components/library/episode-structure-model.ts
@@ -0,0 +1,67 @@
+import type { EpisodeGroupList, EpisodeStructureSeason } from "@umt/contracts";
+
+// 季集结构(TMDB 默认季 / 剧集组)的界面纯函数:轨道选项、季卡片文案、切换确认文案。学 MoviePilot 的订阅选季对话框:
+// 横向轨道选「TMDB 默认 / 各剧集组」,下面按所选结构显示季卡片(海报、季名、首播年、集数、已入库)。
+
+export interface StructureOption {
+ /** 剧集组 id;null = TMDB 默认季 */
+ groupId: string | null;
+ label: string;
+ /** 第二行:类型 · 季数 · 集数 */
+ meta: string | null;
+ current: boolean;
+}
+
+export const DEFAULT_STRUCTURE_LABEL = "TMDB 默认季";
+
+/** 轨道选项:TMDB 默认季在最前,其后按 TMDB 顺序列剧集组;当前选择打标 */
+export function structureOptions(list: EpisodeGroupList | undefined): StructureOption[] {
+ const cur = list?.current?.groupId ?? null;
+ const out: StructureOption[] = [{ groupId: null, label: DEFAULT_STRUCTURE_LABEL, meta: null, current: cur === null }];
+ for (const g of list?.groups ?? []) {
+ out.push({
+ groupId: g.id,
+ label: g.name,
+ meta: [g.typeLabel, `${g.groupCount} 季`, `${g.episodeCount} 集`].filter(Boolean).join(" · "),
+ current: cur === g.id,
+ });
+ }
+ // 当前选的剧集组已从 TMDB 删除:仍列出来,免得界面上看不到当前状态
+ if (cur && !out.some((o) => o.groupId === cur))
+ out.push({ groupId: cur, label: list?.current?.name ?? cur, meta: "TMDB 上已不存在", current: true });
+ return out;
+}
+
+/** 有剧集组可选时才显示轨道 */
+export const hasStructureChoice = (list: EpisodeGroupList | undefined) =>
+ !!list?.applicable && (list.groups.length > 0 || !!list.current);
+
+export function seasonTitle(s: Pick): string {
+ return s.name ?? (s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`);
+}
+
+/** 季卡片第二行:首播年 · 集数 */
+export function seasonLine(s: Pick): string {
+ return [s.airDate ? s.airDate.slice(0, 4) : null, `${s.episodeCount} 集`].filter(Boolean).join(" · ");
+}
+
+/** 已入库 x/y;一集都没有时返回 null(只列非零项) */
+export function inLibraryText(s: Pick): string | null {
+ return s.inLibrary > 0 ? `已入库 ${s.inLibrary}/${s.episodeCount}` : null;
+}
+
+const pad = (n: number) => String(n).padStart(2, "0");
+
+/** 剧集组里的集与 TMDB 默认季集号不同时给「原 S01E14」对照 */
+export function originText(
+ seasonNumber: number,
+ e: { episodeNumber: number; tmdbSeason: number; tmdbEpisode: number },
+): string | null {
+ if (e.tmdbSeason === seasonNumber && e.tmdbEpisode === e.episodeNumber) return null;
+ return `原 S${pad(e.tmdbSeason)}E${pad(e.tmdbEpisode)}`;
+}
+
+/** 切换确认的说明:改的是作品设置,影响季表、集数映射、订阅与刮削 */
+export function switchDescription(target: string): string {
+ return `作品改按「${target}」分季:季与集按 TMDB 原季集迁到新位置,已入库文件跟着走;集数映射、订阅和刮削都按新季集,已绑定的资源会重新换算。之后可以再切回来。`;
+}
diff --git a/apps/web/app/components/library/episode-structure.test.ts b/apps/web/app/components/library/episode-structure.test.ts
new file mode 100644
index 00000000..ff814ad3
--- /dev/null
+++ b/apps/web/app/components/library/episode-structure.test.ts
@@ -0,0 +1,177 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { EpisodeGroupList, EpisodeStructureSeasons } from "@umt/contracts";
+import { createElement, type ReactElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import { episodeMappingKeys } from "~/api/episode-mapping.ts";
+import {
+ EpisodeStructureSwitcher,
+ SeasonCardGrid,
+ StructureTrack,
+ SubscriptionSeasonPicker,
+} from "./episode-structure.tsx";
+import { hasStructureChoice, inLibraryText, originText, seasonLine, structureOptions } from "./episode-structure-model.ts";
+
+const ENTITY = "6b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d";
+
+const groups = (current: string | null): EpisodeGroupList => ({
+ applicable: true,
+ current: current ? { groupId: current, name: "制作季", setAt: null, setBy: null } : null,
+ groups: [
+ {
+ id: "grp-production",
+ name: "制作季",
+ type: 6,
+ typeLabel: "制作季",
+ description: null,
+ groupCount: 2,
+ episodeCount: 25,
+ network: null,
+ },
+ {
+ id: "grp-aired",
+ name: "播出顺序",
+ type: 1,
+ typeLabel: "原始播出",
+ description: null,
+ groupCount: 1,
+ episodeCount: 25,
+ network: null,
+ },
+ ],
+ error: null,
+});
+
+const structure: EpisodeStructureSeasons = {
+ groupId: "grp-production",
+ name: "制作季",
+ current: true,
+ seasons: [
+ {
+ seasonNumber: 1,
+ name: "第一部",
+ episodeCount: 12,
+ airDate: "2024-01-07",
+ posterPath: null,
+ inLibrary: 0,
+ seasonUnitId: "1b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
+ episodes: [],
+ },
+ {
+ seasonNumber: 2,
+ name: "第二部",
+ episodeCount: 13,
+ airDate: "2024-07-07",
+ posterPath: null,
+ inLibrary: 1,
+ seasonUnitId: "2b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
+ episodes: [
+ {
+ episodeNumber: 2,
+ name: "下篇第2话",
+ airDate: "2024-07-14",
+ tmdbSeason: 1,
+ tmdbEpisode: 14,
+ inLibrary: true,
+ },
+ ],
+ },
+ ],
+};
+
+function withCache(el: ReactElement, list: EpisodeGroupList | null, seasons?: EpisodeStructureSeasons) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } } });
+ if (list) qc.setQueryData(episodeMappingKeys.groups(ENTITY), list);
+ if (seasons) qc.setQueryData(episodeMappingKeys.structure(ENTITY, seasons.groupId), seasons);
+ return renderToStaticMarkup(createElement(QueryClientProvider, { client: qc }, el));
+}
+
+describe("季集结构:纯函数", () => {
+ it("轨道选项:TMDB 默认季在前,剧集组带类型 · 季数 · 集数,当前选择打标", () => {
+ const opts = structureOptions(groups("grp-production"));
+ expect(opts.map((o) => [o.groupId, o.label, o.meta, o.current])).toEqual([
+ [null, "TMDB 默认季", null, false],
+ ["grp-production", "制作季", "制作季 · 2 季 · 25 集", true],
+ ["grp-aired", "播出顺序", "原始播出 · 1 季 · 25 集", false],
+ ]);
+ // 当前剧集组已从 TMDB 删除:仍列出
+ const gone = structureOptions({ ...groups("old"), groups: [] });
+ expect(gone.at(-1)).toMatchObject({ groupId: "old", current: true, meta: "TMDB 上已不存在" });
+ expect(hasStructureChoice({ ...groups(null), groups: [] })).toBe(false);
+ expect(hasStructureChoice(groups(null))).toBe(true);
+ });
+
+ it("季卡片文案与原季集对照", () => {
+ expect(seasonLine({ airDate: "2024-07-07", episodeCount: 13 })).toBe("2024 · 13 集");
+ expect(inLibraryText({ inLibrary: 0, episodeCount: 13 })).toBeNull();
+ expect(inLibraryText({ inLibrary: 3, episodeCount: 13 })).toBe("已入库 3/13");
+ expect(originText(2, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 14 })).toBe("原 S01E14");
+ expect(originText(1, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 2 })).toBeNull();
+ });
+});
+
+describe("季集结构:组件", () => {
+ it("剧集组轨道:每项一张小卡,选中的 aria-pressed", () => {
+ const html = renderToStaticMarkup(
+ createElement(StructureTrack, {
+ options: structureOptions(groups(null)),
+ value: "grp-aired",
+ onChange: () => {},
+ }),
+ );
+ expect(html).toContain("TMDB 默认季");
+ expect(html).toContain("制作季 · 2 季 · 25 集");
+ expect(html).toMatch(/aria-pressed="true"[^>]*>.*播出顺序/);
+ expect(html).toContain('aria-label="当前"');
+ });
+
+ it("季卡片:季名、首播年 · 集数、已入库 x/y,选中的高亮", () => {
+ const html = renderToStaticMarkup(
+ createElement(SeasonCardGrid, { seasons: structure.seasons, selected: 2, onPick: () => {} }),
+ );
+ expect(html).toContain("第一部");
+ expect(html).toContain("2024 · 12 集");
+ expect(html).toContain("已入库 1/13");
+ expect(html).not.toContain("已入库 0/12");
+ expect(html).toMatch(/aria-pressed="true"[^>]*class="[^"]*pill-selected/);
+ });
+
+ it("订阅选季:有剧集组时显示轨道 + 全部季 + 季卡片,当前结构下按季单元回显选中", () => {
+ const html = withCache(
+ createElement(SubscriptionSeasonPicker, {
+ entityId: ENTITY,
+ value: "2b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
+ onChange: () => {},
+ }),
+ groups("grp-production"),
+ structure,
+ );
+ expect(html).toContain("季集结构");
+ expect(html).toContain("全部季");
+ expect(html).toContain("第二部");
+ expect(html).toMatch(/aria-pressed="true"[^>]*class="[^"]*pill-selected[^"]*"[^>]*>.*?第二部/);
+ expect(html).not.toContain("不是作品当前的季集结构");
+ });
+
+ it("没有剧集组时:订阅选季不出现(表单用下拉),作品页只显示原来的季与集", () => {
+ const empty = { ...groups(null), groups: [] };
+ expect(
+ withCache(createElement(SubscriptionSeasonPicker, { entityId: ENTITY, value: "", onChange: () => {} }), empty),
+ ).toBe("");
+ const html = withCache(
+ createElement(EpisodeStructureSwitcher, { entityId: ENTITY, children: createElement("p", null, "原季列表") }),
+ empty,
+ );
+ expect(html).toBe("原季列表
");
+ });
+
+ it("作品页:有剧集组时轨道在季列表上方,当前结构显示原来的季与集", () => {
+ const html = withCache(
+ createElement(EpisodeStructureSwitcher, { entityId: ENTITY, children: createElement("p", null, "原季列表") }),
+ groups("grp-production"),
+ );
+ expect(html).toContain("季集结构");
+ expect(html).toContain("原季列表");
+ expect(html).not.toContain("改用这套结构");
+ });
+});
diff --git a/apps/web/app/components/library/episode-structure.tsx b/apps/web/app/components/library/episode-structure.tsx
new file mode 100644
index 00000000..3b3ab782
--- /dev/null
+++ b/apps/web/app/components/library/episode-structure.tsx
@@ -0,0 +1,366 @@
+import type { EpisodeGroupSetResult, EpisodeStructureSeason } from "@umt/contracts";
+import { Check, Layers } from "lucide-react";
+import { type ReactNode, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { useEpisodeGroups, useEpisodeStructure, useSetEpisodeGroup } from "~/api/episode-mapping.ts";
+import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
+import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
+import { PaginatedList } from "~/components/console/paginated-list.tsx";
+import { StatusBadge } from "~/components/console/status-badge.tsx";
+import { CoverPlaceholder } from "~/components/library/parts.tsx";
+import { EpisodeRow, SeasonSection } from "~/components/media/media-detail.tsx";
+import { Button } from "~/components/ui/button.tsx";
+import { errorMessage } from "~/lib/api.ts";
+import { cn } from "~/lib/utils.ts";
+import {
+ DEFAULT_STRUCTURE_LABEL,
+ hasStructureChoice,
+ inLibraryText,
+ originText,
+ type StructureOption,
+ seasonLine,
+ seasonTitle,
+ structureOptions,
+ switchDescription,
+} from "./episode-structure-model.ts";
+
+// 季集结构选择(学 MoviePilot SubscribeSeasonDialog):横向轨道选「TMDB 默认季 / 各剧集组」,
+// 下面按所选结构显示季卡片;选了非当前结构时可以「改用这套结构」(改的是作品设置,先确认)。
+
+/** 横向轨道:每个选项一张小卡(名称 + 类型 · 季数 · 集数),当前结构带勾 */
+export function StructureTrack({
+ options,
+ value,
+ onChange,
+}: {
+ options: StructureOption[];
+ value: string | null;
+ onChange: (groupId: string | null) => void;
+}) {
+ return (
+
+ );
+}
+
+/** 季卡片网格(订阅选季):海报、季名、首播年 · 集数、已入库;可选中 */
+export function SeasonCardGrid({
+ seasons,
+ selected,
+ onPick,
+ disabled,
+}: {
+ seasons: EpisodeStructureSeason[];
+ /** 选中的季号 */
+ selected: number | null;
+ onPick: (s: EpisodeStructureSeason) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ {seasons.map((s) => {
+ const on = s.seasonNumber === selected;
+ const lib = inLibraryText(s);
+ return (
+ -
+
+
+ );
+ })}
+
+ );
+}
+
+/** 某套结构的季折叠列表(作品页预览):季头 + 每集(集号、标题、播出日期、入库状态、原季集对照) */
+export function StructurePreview({ seasons }: { seasons: EpisodeStructureSeason[] }) {
+ const [open, setOpen] = useState>(new Set());
+ const toggle = (n: number) => {
+ const next = new Set(open);
+ if (next.has(n)) next.delete(n);
+ else next.add(n);
+ setOpen(next);
+ };
+ return (
+
+ {(s) => (
+ = s.episodeCount ? "success" : "neutral"}>
+ 已入库 {s.inLibrary}/{s.episodeCount}
+
+ ),
+ }}
+ open={open.has(s.seasonNumber)}
+ onToggle={() => toggle(s.seasonNumber)}
+ >
+ {s.episodes.length === 0 ? (
+ TMDB 上这一季还没有集信息。
+ ) : (
+
+ {(e) => (
+
+ {originText(s.seasonNumber, e)}
+
+ ) : null,
+ }}
+ />
+ )}
+
+ )}
+
+ )}
+
+ );
+}
+
+function resultToast(r: EpisodeGroupSetResult) {
+ const name = r.current?.name ?? DEFAULT_STRUCTURE_LABEL;
+ const parts = [`改按「${name}」分季`];
+ if (r.moved) parts.push(`${r.moved} 集换了季集号`);
+ if (r.unmatched.length) parts.push(`${r.unmatched.length} 集对不上、已标临时`);
+ if (r.reresolve.changed) parts.push(`${r.reresolve.changed} 条资源重新换算`);
+ toast.success(parts.join(","));
+}
+
+/**
+ * 作品页「季与集」顶部的季集结构切换:只有 TMDB 有剧集组(或作品已选了剧集组)时出现。
+ * 选中当前结构时显示 children(现有的季与集);选中其它结构时显示该结构的季卡片预览与「改用这套结构」。
+ */
+export function EpisodeStructureSwitcher({ entityId, children }: { entityId: string; children: ReactNode }) {
+ const groups = useEpisodeGroups(entityId);
+ const options = structureOptions(groups.data);
+ const current = groups.data?.current?.groupId ?? null;
+ const [picked, setPicked] = useState(undefined);
+ const selected = picked === undefined ? current : picked;
+ const viewingOther = selected !== current;
+ const structure = useEpisodeStructure(entityId, selected, viewingOther);
+ const setGroup = useSetEpisodeGroup(entityId);
+ const [confirm, setConfirm] = useState(false);
+ if (!hasStructureChoice(groups.data)) return <>{children}>;
+ const target = options.find((o) => o.groupId === selected);
+ return (
+
+
+ {!viewingOther ? (
+ children
+ ) : (
+
+
+
+ 预览「{target?.label ?? DEFAULT_STRUCTURE_LABEL}」的分季,已入库按 TMDB 原季集对照。
+
+
+
+ {structure.isPending ? (
+
+ ) : structure.error ? (
+
structure.refetch()} />
+ ) : (
+
+ )}
+
+ )}
+
{
+ try {
+ resultToast(await setGroup.mutateAsync(selected));
+ setPicked(undefined);
+ } catch (err) {
+ toast.error(errorMessage(err, "切换失败"));
+ throw err;
+ }
+ }}
+ />
+
+ );
+}
+
+/**
+ * 订阅表单的选季(学 MP):作品有剧集组时用轨道 + 季卡片代替下拉;选了非当前结构的季要先确认改作品设置,
+ * 改完按新结构的同号季单元回填。没有剧集组时返回 null,表单照用下拉。
+ */
+export function SubscriptionSeasonPicker({
+ entityId,
+ value,
+ onChange,
+ disabled,
+}: {
+ entityId: string;
+ /** 选中的季单元 id;"" = 全部季 */
+ value: string;
+ onChange: (seasonUnitId: string) => void;
+ disabled?: boolean;
+}) {
+ const groups = useEpisodeGroups(entityId);
+ const current = groups.data?.current?.groupId ?? null;
+ const [picked, setPicked] = useState(undefined);
+ const selected = picked === undefined ? current : picked;
+ const structure = useEpisodeStructure(entityId, selected, hasStructureChoice(groups.data));
+ const setGroup = useSetEpisodeGroup(entityId);
+ const [ask, setAsk] = useState(null);
+ const [after, setAfter] = useState(null);
+ const currentData = structure.data?.current ? structure.data : null;
+ // 改完结构后,按新结构里同号季的季单元回填
+ useEffect(() => {
+ if (after === null || !currentData) return;
+ const hit = currentData.seasons.find((s) => s.seasonNumber === after);
+ if (hit?.seasonUnitId) onChange(hit.seasonUnitId);
+ setAfter(null);
+ }, [after, currentData, onChange]);
+ if (!hasStructureChoice(groups.data)) return null;
+ const options = structureOptions(groups.data);
+ const seasons = structure.data?.seasons ?? [];
+ const selectedSeason = structure.data?.current
+ ? (seasons.find((s) => s.seasonUnitId && s.seasonUnitId === value)?.seasonNumber ?? null)
+ : null;
+ const target = options.find((o) => o.groupId === selected);
+ const pick = (s: EpisodeStructureSeason) => {
+ if (structure.data?.current) {
+ onChange(s.seasonUnitId && s.seasonUnitId !== value ? s.seasonUnitId : "");
+ return;
+ }
+ setAsk(s);
+ };
+ return (
+
+
+
+
+ {!structure.data?.current && structure.data ? (
+
+ 不是作品当前的季集结构,选季时会先确认改用「{target?.label}」
+
+ ) : null}
+
+ {structure.isPending ? (
+
+ ) : structure.error ? (
+
structure.refetch()} />
+ ) : (
+ s.seasonNumber > 0 || s.episodeCount > 0)}
+ selected={selectedSeason}
+ onPick={pick}
+ disabled={disabled || setGroup.isPending}
+ />
+ )}
+ !o && setAsk(null)}
+ title={`改按「${target?.label ?? DEFAULT_STRUCTURE_LABEL}」分季?`}
+ description={`订阅「${ask ? seasonTitle(ask) : ""}」要先改作品设置。${switchDescription(target?.label ?? DEFAULT_STRUCTURE_LABEL)}`}
+ confirmLabel="改用并选季"
+ onConfirm={async () => {
+ const s = ask;
+ try {
+ resultToast(await setGroup.mutateAsync(selected));
+ setPicked(undefined);
+ if (s) setAfter(s.seasonNumber);
+ setAsk(null);
+ } catch (err) {
+ toast.error(errorMessage(err, "切换失败"));
+ throw err;
+ }
+ }}
+ />
+
+ );
+}
diff --git a/apps/web/app/components/subscriptions/subscription-form.tsx b/apps/web/app/components/subscriptions/subscription-form.tsx
index ec52f86c..e9286ae4 100644
--- a/apps/web/app/components/subscriptions/subscription-form.tsx
+++ b/apps/web/app/components/subscriptions/subscription-form.tsx
@@ -13,6 +13,7 @@ import { ExternalLink, Search, X } from "lucide-react";
import { type ReactNode, useEffect, useId, useMemo, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
+import { useEpisodeGroups } from "~/api/episode-mapping.ts";
import { useEntityFromCandidate } from "~/api/files.ts";
import { downloaders, useSiteOptions } from "~/api/instances.ts";
import { useEntityDetail, useLibraryRoots } from "~/api/library.ts";
@@ -29,6 +30,8 @@ import {
import { FormField } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { AbsoluteNumberingHint } from "~/components/episode-mapping/subscription-summary.tsx";
+import { SubscriptionSeasonPicker } from "~/components/library/episode-structure.tsx";
+import { hasStructureChoice } from "~/components/library/episode-structure-model.ts";
import { PRIORITY, SEASON_SCOPE, SUB_MODE } from "~/components/library/labels.ts";
import { ChipToggleGroup } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
@@ -333,6 +336,9 @@ export function SubscriptionFormDialog({
const isMovie = kind === "movie";
const detail = useEntityDetail(entityId && !isMovie ? entityId : undefined);
+ // 作品有剧集组可选时,新建订阅用「季集结构轨道 + 季卡片」选季(学 MP);编辑时季不能改,仍是下拉
+ const epGroups = useEpisodeGroups(entityId && !isMovie && !editing ? entityId : undefined);
+ const seasonCards = !editing && !!entityId && hasStructureChoice(epGroups.data);
const seasons = (detail.data?.contentUnits ?? [])
.filter((u) => u.kind === "season" && u.seasonNumber !== null)
.sort((a, b) => (a.seasonNumber ?? 0) - (b.seasonNumber ?? 0));
@@ -537,27 +543,38 @@ export function SubscriptionFormDialog({
/>
+ {!isMovie && seasonCards && entityId ? (
+
+ patch({ seasonUnitId: v })}
+ />
+
+ ) : null}
{isMovie ? null : (
-
- patch({ seasonUnitId: v })}
- placeholder="全部季"
- options={seasons.map((s) => ({
- value: s.id,
- label: s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`,
- }))}
- disabled={editing || !entityId}
- />
-
+ {seasonCards ? null : (
+
+ patch({ seasonUnitId: v })}
+ placeholder="全部季"
+ options={seasons.map((s) => ({
+ value: s.id,
+ label: s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`,
+ }))}
+ disabled={editing || !entityId}
+ />
+
+ )}
}
>
- {rows.length > 1 ? (
-
- {(r, i) => (
-
- )}
-
- ) : null}
- {seasonNums.length === 0 ? (
-
- ) : (
-
- {(n) => {
- const su = seasonUnitOf(n);
- const eps = episodes.filter((u) => u.seasonNumber === n);
- const total = su?.episodeTotal ?? (eps.length || null);
- const nameV = su ? finalOf(su, "name")?.value : null;
- const posterV = su ? finalOf(su, "posterPath")?.value : null;
- const ovV = su ? finalOf(su, "overview")?.value : null;
- const airV = su ? (finalOf(su, "airDate")?.value ?? su.airDate) : null;
- const have = haveIn(n);
- return (
- {
- const vs = versionSeasons.find((x) => x.seasonNumber === n);
- return vs ? seasonLine(vs, !anyVersion) : null;
- })(),
- summary: (
- = total ? "success" : "neutral"}>
- 已入库 {have}/{total ?? "?"}
-
- ),
- }}
- open={openSet.has(n)}
- onToggle={() => toggle(n)}
+
+ {rows.length > 1 ? (
+
+ {(r, i) => (
+
- );
- }}
-
- )}
-
+ {r.name}
+
+ )}
+
+ ) : null}
+ {seasonNums.length === 0 ? (
+
+ ) : (
+
+ {(n) => {
+ const su = seasonUnitOf(n);
+ const eps = episodes.filter((u) => u.seasonNumber === n);
+ const total = su?.episodeTotal ?? (eps.length || null);
+ const nameV = su ? finalOf(su, "name")?.value : null;
+ const posterV = su ? finalOf(su, "posterPath")?.value : null;
+ const ovV = su ? finalOf(su, "overview")?.value : null;
+ const airV = su ? (finalOf(su, "airDate")?.value ?? su.airDate) : null;
+ const have = haveIn(n);
+ return (
+ {
+ const vs = versionSeasons.find((x) => x.seasonNumber === n);
+ return vs ? seasonLine(vs, !anyVersion) : null;
+ })(),
+ summary: (
+ = total ? "success" : "neutral"}>
+ 已入库 {have}/{total ?? "?"}
+
+ ),
+ }}
+ open={openSet.has(n)}
+ onToggle={() => toggle(n)}
+ >
+ {eps.length === 0 ? (
+ 这一季还没有集信息。
+ ) : (
+
+ {(u) => {
+ const v = (f: string) => finalOf(u, f)?.value;
+ const str = (x: unknown) => (typeof x === "string" && x ? x : null);
+ const num = (x: unknown) => (typeof x === "number" ? x : null);
+ return (
+ 0,
+ onEdit: () => setEditing(u),
+ extra: (() => {
+ const ev = versionsOf(u);
+ return ev ? (
+
+ ) : null;
+ })(),
+ }}
+ />
+ );
+ }}
+
+ )}
+
+ );
+ }}
+
+ )}
+
+
!o && setEditing(null)} />
);