feat(web): 作品页与订阅选季的剧集组轨道和季卡片
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
265340751c
commit
7d5ba35280
9 files changed
+827
-138
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user