feat(web): 作品页与订阅选季的剧集组轨道和季卡片

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 15:45:12 +08:00
1 parent 265340751c
commit 7d5ba35280
9 files changed
+827 -138

No files matched your search

+54
View File
@@ -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<unknown>(`/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"] });
},
});
}
@@ -65,7 +65,10 @@ function SeasonSummary({ p }: { p: EpisodeMappingPreview["seasons"] }) {
{s.season === p.latestSeason ? " · 最新" : ""}
</Chip>
))}
<span className="text-meta text-muted-foreground">{p.ended ? "已完结" : "未完结"}</span>
<span className="text-meta text-muted-foreground">
{p.ended ? "已完结" : "未完结"}
{p.structure ? ` · 按剧集组「${p.structure.name}」分季` : ""}
</span>
</div>
);
}
@@ -28,6 +28,7 @@ describe("集数映射文案", () => {
absolute_in_season: "季内绝对集号",
absolute_cross_season: "跨季绝对集号",
air_date: "按播出日期",
tmdb_season: "按 TMDB 默认季集换算",
unverified: "元数据未覆盖,先按原号",
unresolved: "待确认",
skipped: "不适用",
@@ -22,6 +22,7 @@ export const METHOD_LABEL: Record<EpisodeResolutionMethod, string> = {
absolute_in_season: "季内绝对集号",
absolute_cross_season: "跨季绝对集号",
air_date: "按播出日期",
tmdb_season: "按 TMDB 默认季集换算",
unverified: "元数据未覆盖,先按原号",
unresolved: "待确认",
skipped: "不适用",
@@ -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<EpisodeStructureSeason, "seasonNumber" | "name">): string {
return s.name ?? (s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`);
}
/** 季卡片第二行:首播年 · 集数 */
export function seasonLine(s: Pick<EpisodeStructureSeason, "airDate" | "episodeCount">): string {
return [s.airDate ? s.airDate.slice(0, 4) : null, `${s.episodeCount} 集`].filter(Boolean).join(" · ");
}
/** 已入库 x/y;一集都没有时返回 null(只列非零项) */
export function inLibraryText(s: Pick<EpisodeStructureSeason, "inLibrary" | "episodeCount">): 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 原季集迁到新位置,已入库文件跟着走;集数映射、订阅和刮削都按新季集,已绑定的资源会重新换算。之后可以再切回来。`;
}
@@ -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("<p>原季列表</p>");
});
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("改用这套结构");
});
});
@@ -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 (
<fieldset aria-label="季集结构" className="m-0 min-w-0 border-0 p-0">
<div className="scroll-bleed -mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
{options.map((o) => {
const selected = o.groupId === value;
return (
<button
key={o.groupId ?? "default"}
type="button"
aria-pressed={selected}
onClick={() => onChange(o.groupId)}
className={cn(
"flex min-w-[9.5rem] shrink-0 cursor-pointer flex-col items-start rounded-xl border px-3 py-2 text-left transition-colors duration-base",
selected
? "pill-selected text-foreground"
: "border-border text-muted-foreground hover:bg-wash/[0.06] hover:text-foreground",
)}
>
<span className="flex items-center gap-1 text-sm font-medium text-foreground">
{o.current ? <Check className="h-3.5 w-3.5" aria-label="当前" /> : null}
{o.label}
</span>
<span className="text-meta text-muted-foreground">{o.meta ?? (o.current ? "当前" : "按 TMDB 季")}</span>
</button>
);
})}
</div>
</fieldset>
);
}
/** 季卡片网格(订阅选季):海报、季名、首播年 · 集数、已入库;可选中 */
export function SeasonCardGrid({
seasons,
selected,
onPick,
disabled,
}: {
seasons: EpisodeStructureSeason[];
/** 选中的季号 */
selected: number | null;
onPick: (s: EpisodeStructureSeason) => void;
disabled?: boolean;
}) {
return (
<ul className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{seasons.map((s) => {
const on = s.seasonNumber === selected;
const lib = inLibraryText(s);
return (
<li key={s.seasonNumber}>
<button
type="button"
aria-pressed={on}
disabled={disabled}
onClick={() => onPick(s)}
className={cn(
"flex w-full cursor-pointer flex-col gap-1.5 rounded-xl border p-1.5 text-left transition-colors duration-base disabled:cursor-not-allowed disabled:opacity-50",
on ? "pill-selected" : "border-border hover:bg-wash/[0.06]",
)}
>
<CoverPlaceholder
title={seasonTitle(s)}
kind="series"
small
posterPath={s.posterPath}
className="aspect-[2/3] h-auto w-full"
/>
<span className="min-w-0 px-0.5">
<span className="block truncate text-caption font-medium text-foreground">{seasonTitle(s)}</span>
<span className="block text-meta text-muted-foreground tabular-nums">{seasonLine(s)}</span>
{lib ? <span className="block text-meta text-muted-foreground tabular-nums">{lib}</span> : null}
</span>
</button>
</li>
);
})}
</ul>
);
}
/** 某套结构的季折叠列表(作品页预览):季头 + 每集(集号、标题、播出日期、入库状态、原季集对照) */
export function StructurePreview({ seasons }: { seasons: EpisodeStructureSeason[] }) {
const [open, setOpen] = useState<Set<number>>(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 (
<PaginatedList rows={seasons} label="季列表" className="space-y-2">
{(s) => (
<SeasonSection
key={s.seasonNumber}
s={{
seasonNumber: s.seasonNumber,
name: s.name,
airDate: s.airDate,
episodeCount: s.episodeCount,
posterPath: s.posterPath,
overview: null,
summary: (
<StatusBadge tone={s.episodeCount && s.inLibrary >= s.episodeCount ? "success" : "neutral"}>
已入库 {s.inLibrary}/{s.episodeCount}
</StatusBadge>
),
}}
open={open.has(s.seasonNumber)}
onToggle={() => toggle(s.seasonNumber)}
>
{s.episodes.length === 0 ? (
<p className="text-meta text-muted-foreground">TMDB 上这一季还没有集信息。</p>
) : (
<PaginatedList
rows={s.episodes}
as="ul"
compact
label={`第 ${s.seasonNumber} 季集列表`}
className="divide-y divide-border/60"
>
{(e) => (
<EpisodeRow
key={e.episodeNumber}
e={{
key: String(e.episodeNumber),
seasonNumber: s.seasonNumber,
episodeNumber: e.episodeNumber,
name: e.name,
overview: null,
airDate: e.airDate,
runtime: null,
stillPath: null,
voteAverage: null,
status: e.inLibrary ? { label: "已入库", tone: "success" } : { label: "未入库", tone: "neutral" },
extra: originText(s.seasonNumber, e) ? (
<p className="mt-0.5 font-mono text-meta text-muted-foreground">
{originText(s.seasonNumber, e)}
</p>
) : null,
}}
/>
)}
</PaginatedList>
)}
</SeasonSection>
)}
</PaginatedList>
);
}
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<string | null | undefined>(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 (
<div className="space-y-3">
<div className="space-y-1.5">
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
<Layers className="h-3.5 w-3.5" aria-hidden />
季集结构
</p>
<StructureTrack options={options} value={selected} onChange={setPicked} />
</div>
{!viewingOther ? (
children
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-dashed border-border px-3 py-2">
<p className="text-caption text-muted-foreground">
预览「{target?.label ?? DEFAULT_STRUCTURE_LABEL}」的分季,已入库按 TMDB 原季集对照。
</p>
<Button size="sm" disabled={setGroup.isPending} onClick={() => setConfirm(true)}>
改用这套结构
</Button>
</div>
{structure.isPending ? (
<LoadingState rows={2} />
) : structure.error ? (
<ErrorState error={structure.error} onRetry={() => structure.refetch()} />
) : (
<StructurePreview seasons={structure.data?.seasons ?? []} />
)}
</div>
)}
<ConfirmDialog
open={confirm}
onOpenChange={setConfirm}
title={`改按「${target?.label ?? DEFAULT_STRUCTURE_LABEL}」分季?`}
description={switchDescription(target?.label ?? DEFAULT_STRUCTURE_LABEL)}
confirmLabel="改用"
onConfirm={async () => {
try {
resultToast(await setGroup.mutateAsync(selected));
setPicked(undefined);
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
throw err;
}
}}
/>
</div>
);
}
/**
* 订阅表单的选季(学 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<string | null | undefined>(undefined);
const selected = picked === undefined ? current : picked;
const structure = useEpisodeStructure(entityId, selected, hasStructureChoice(groups.data));
const setGroup = useSetEpisodeGroup(entityId);
const [ask, setAsk] = useState<EpisodeStructureSeason | null>(null);
const [after, setAfter] = useState<number | null>(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 (
<div className="space-y-2">
<StructureTrack options={options} value={selected} onChange={setPicked} />
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
aria-pressed={value === ""}
disabled={disabled}
className={value === "" ? "pill-selected" : undefined}
onClick={() => onChange("")}
>
全部季
</Button>
{!structure.data?.current && structure.data ? (
<span className="text-meta text-muted-foreground">
不是作品当前的季集结构,选季时会先确认改用「{target?.label}」
</span>
) : null}
</div>
{structure.isPending ? (
<LoadingState rows={2} />
) : structure.error ? (
<ErrorState error={structure.error} onRetry={() => structure.refetch()} />
) : (
<SeasonCardGrid
seasons={seasons.filter((s) => s.seasonNumber > 0 || s.episodeCount > 0)}
selected={selectedSeason}
onPick={pick}
disabled={disabled || setGroup.isPending}
/>
)}
<ConfirmDialog
open={!!ask}
onOpenChange={(o) => !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;
}
}}
/>
</div>
);
}
@@ -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({
/>
</FormField>
</div>
{!isMovie && seasonCards && entityId ? (
<FormField label="季" hint="按作品当前的季集结构订阅;换剧集组等于改作品设置,会先确认">
<SubscriptionSeasonPicker
entityId={entityId}
value={d.seasonUnitId}
onChange={(v) => patch({ seasonUnitId: v })}
/>
</FormField>
) : null}
{isMovie ? null : (
<div className="grid gap-3 sm:grid-cols-2">
<FormField
label="季"
htmlFor={f("season")}
hint={
editing ? "季建好后不能改;要换季就新建订阅" : entityId ? undefined : "作品加入媒体库后可选季"
}
>
<Pick
id={f("season")}
value={d.seasonUnitId}
onChange={(v) => patch({ seasonUnitId: v })}
placeholder="全部季"
options={seasons.map((s) => ({
value: s.id,
label: s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`,
}))}
disabled={editing || !entityId}
/>
</FormField>
{seasonCards ? null : (
<FormField
label="季"
htmlFor={f("season")}
hint={
editing ? "季建好后不能改;要换季就新建订阅" : entityId ? undefined : "作品加入媒体库后可选季"
}
>
<Pick
id={f("season")}
value={d.seasonUnitId}
onChange={(v) => patch({ seasonUnitId: v })}
placeholder="全部季"
options={seasons.map((s) => ({
value: s.id,
label: s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`,
}))}
disabled={editing || !entityId}
/>
</FormField>
)}
<FormField label="季范围" htmlFor={f("scope")}>
<Pick
id={f("scope")}
+121 -118
View File
@@ -50,6 +50,7 @@ import { TechInfo } from "~/components/console/tech-info.tsx";
import { DROPPED_STATUS, droppedReason, PURPOSE_LABEL } from "~/components/downloads/plan-format.ts";
import { EpisodeMappingPanel } from "~/components/episode-mapping/mapping-panel.tsx";
import { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.tsx";
import { EpisodeStructureSwitcher } from "~/components/library/episode-structure.tsx";
import {
CONTENT_TYPE,
DOWNLOAD_STATUS,
@@ -755,125 +756,127 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
</Button>
}
>
{rows.length > 1 ? (
<PaginatedList
rows={rows}
as="fieldset"
label="按收录版本看状态"
resetKey={e.id}
className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0"
>
{(r, i) => (
<button
key={r.slotId ?? "any"}
type="button"
aria-pressed={i === rowIdx}
onClick={() => setRowIdx(i)}
className={
i === rowIdx
? "pill-selected inline-flex h-7 cursor-pointer items-center rounded-full border px-2.5 text-meta font-medium text-foreground"
: "inline-flex h-7 cursor-pointer items-center rounded-full border border-border px-2.5 text-meta font-medium text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
}
>
{r.name}
</button>
)}
</PaginatedList>
) : null}
{seasonNums.length === 0 ? (
<EmptyState
plain
icon={Tv}
title="还没有每集信息"
description="点「更新每集信息」从 TMDB 拉取全部季的集标题、播出日期和简介。"
className="py-8"
/>
) : (
<PaginatedList rows={seasonNums} label="季列表" resetKey={`${e.id}:${rowIdx}`} className="space-y-2">
{(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 (
<SeasonSection
key={n}
s={{
seasonNumber: n,
name: typeof nameV === "string" ? nameV : (su?.title ?? null),
airDate: typeof airV === "string" ? airV : null,
episodeCount: total,
posterPath: typeof posterV === "string" ? posterV : null,
overview: typeof ovV === "string" ? ovV : null,
detail: (() => {
const vs = versionSeasons.find((x) => x.seasonNumber === n);
return vs ? seasonLine(vs, !anyVersion) : null;
})(),
summary: (
<StatusBadge tone={total && have >= total ? "success" : "neutral"}>
已入库 {have}/{total ?? "?"}
</StatusBadge>
),
}}
open={openSet.has(n)}
onToggle={() => toggle(n)}
<EpisodeStructureSwitcher entityId={e.id}>
{rows.length > 1 ? (
<PaginatedList
rows={rows}
as="fieldset"
label="按收录版本看状态"
resetKey={e.id}
className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0"
>
{(r, i) => (
<button
key={r.slotId ?? "any"}
type="button"
aria-pressed={i === rowIdx}
onClick={() => setRowIdx(i)}
className={
i === rowIdx
? "pill-selected inline-flex h-7 cursor-pointer items-center rounded-full border px-2.5 text-meta font-medium text-foreground"
: "inline-flex h-7 cursor-pointer items-center rounded-full border border-border px-2.5 text-meta font-medium text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
}
>
{eps.length === 0 ? (
<p className="text-meta text-muted-foreground">这一季还没有集信息。</p>
) : (
<PaginatedList
rows={eps}
as="ul"
compact
label={`第 ${n} 季集列表`}
resetKey={`${e.id}:${n}:${rowIdx}`}
className="divide-y divide-border/60"
>
{(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 (
<EpisodeRow
key={u.id}
e={{
key: u.id,
seasonNumber: n,
episodeNumber: u.episodeNumber!,
name: str(v("name")) ?? u.title,
overview: str(v("overview")),
airDate: str(v("airDate")) ?? u.airDate,
runtime: num(v("runtime")),
stillPath: str(v("stillPath")),
voteAverage: num(v("voteAverage")),
status: statusOf(u),
locked: u.fieldLocks.length > 0,
onEdit: () => setEditing(u),
extra: (() => {
const ev = versionsOf(u);
return ev ? (
<EpisodeVersionList
versions={ev.versions}
match={anyVersion ? undefined : ev.matches?.[0]}
/>
) : null;
})(),
}}
/>
);
}}
</PaginatedList>
)}
</SeasonSection>
);
}}
</PaginatedList>
)}
<UnplacedVersions versions={versions.data?.unplaced ?? []} />
{r.name}
</button>
)}
</PaginatedList>
) : null}
{seasonNums.length === 0 ? (
<EmptyState
plain
icon={Tv}
title="还没有每集信息"
description="点「更新每集信息」从 TMDB 拉取全部季的集标题、播出日期和简介。"
className="py-8"
/>
) : (
<PaginatedList rows={seasonNums} label="季列表" resetKey={`${e.id}:${rowIdx}`} className="space-y-2">
{(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 (
<SeasonSection
key={n}
s={{
seasonNumber: n,
name: typeof nameV === "string" ? nameV : (su?.title ?? null),
airDate: typeof airV === "string" ? airV : null,
episodeCount: total,
posterPath: typeof posterV === "string" ? posterV : null,
overview: typeof ovV === "string" ? ovV : null,
detail: (() => {
const vs = versionSeasons.find((x) => x.seasonNumber === n);
return vs ? seasonLine(vs, !anyVersion) : null;
})(),
summary: (
<StatusBadge tone={total && have >= total ? "success" : "neutral"}>
已入库 {have}/{total ?? "?"}
</StatusBadge>
),
}}
open={openSet.has(n)}
onToggle={() => toggle(n)}
>
{eps.length === 0 ? (
<p className="text-meta text-muted-foreground">这一季还没有集信息。</p>
) : (
<PaginatedList
rows={eps}
as="ul"
compact
label={`第 ${n} 季集列表`}
resetKey={`${e.id}:${n}:${rowIdx}`}
className="divide-y divide-border/60"
>
{(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 (
<EpisodeRow
key={u.id}
e={{
key: u.id,
seasonNumber: n,
episodeNumber: u.episodeNumber!,
name: str(v("name")) ?? u.title,
overview: str(v("overview")),
airDate: str(v("airDate")) ?? u.airDate,
runtime: num(v("runtime")),
stillPath: str(v("stillPath")),
voteAverage: num(v("voteAverage")),
status: statusOf(u),
locked: u.fieldLocks.length > 0,
onEdit: () => setEditing(u),
extra: (() => {
const ev = versionsOf(u);
return ev ? (
<EpisodeVersionList
versions={ev.versions}
match={anyVersion ? undefined : ev.matches?.[0]}
/>
) : null;
})(),
}}
/>
);
}}
</PaginatedList>
)}
</SeasonSection>
);
}}
</PaginatedList>
)}
<UnplacedVersions versions={versions.data?.unplaced ?? []} />
</EpisodeStructureSwitcher>
<UnitFieldDialog entityId={e.id} unit={editing} onOpenChange={(o) => !o && setEditing(null)} />
</Panel>
);