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 ( ); } /** 某套结构的季折叠列表(作品页预览):季头 + 每集(集号、标题、播出日期、入库状态、原季集对照) */ 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; } }} />
); }