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

+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>
);