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 ( +
+
+ {options.map((o) => { + const selected = o.groupId === value; + 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) => ( + + )} + + ) : 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)} /> );