Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|