- adapters: overviewLanguages() limits overview/tagline chain to Chinese variants (zh-CN→zh-SG→zh-TW→zh-HK); titles keep full chain; season supplement fetch skips languages useless for overview - setting metadata.overviewNonChineseFallback (default off) + UI switch - media detail shows "暂无简介" for empty entity/episode overview - scripts/overview-recompute.ts: recompute stored non-Chinese overviews from kv_cache only (no TMDB requests), dry-run by default, --apply - tests: chain unit tests, Heart Signal (103147) fixture, server recompute test, MP differential for episode overview - runbook + CHANGELOG Co-Authored-By: Claude Opus 5.5 <[email protected]>
416 lines
16 KiB
TypeScript
416 lines
16 KiB
TypeScript
import { type MediaCredit, tmdbImageUrl } from "@umt/contracts";
|
||
import { ChevronDown, ExternalLink, ImageOff, Lock, PencilLine, Star, UserRound } from "lucide-react";
|
||
import { type ReactNode, useState } from "react";
|
||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||
import { CoverPlaceholder } from "~/components/library/parts.tsx";
|
||
import { Button } from "~/components/ui/button.tsx";
|
||
import { cn } from "~/lib/utils.ts";
|
||
|
||
// 媒体详情(MD-1,对照 MP 媒体详情页):背景图 + 海报 + 标题 / 原名 / 年份 / 类型 / 评分 / 状态 / 集数 + 简介、
|
||
// 主创、季折叠与每集行。媒体库详情(本地作品)与仅 TMDB 的作品详情共用;数据由页面整理成下面的结构传入。
|
||
// 图片一律经图片代理(tmdbImageUrl);背景图上压 scrim 遮罩,文字用 on-media 色(不随日夜翻转)。
|
||
|
||
/** TMDB status 原文 → 界面用词 */
|
||
export const AIR_STATUS: Record<string, { label: string; tone: StatusTone }> = {
|
||
"Returning Series": { label: "连载中", tone: "success" },
|
||
"In Production": { label: "制作中", tone: "neutral" },
|
||
Planned: { label: "计划中", tone: "neutral" },
|
||
Pilot: { label: "试播", tone: "neutral" },
|
||
Ended: { label: "已完结", tone: "neutral" },
|
||
Canceled: { label: "已取消", tone: "warning" },
|
||
Released: { label: "已上映", tone: "neutral" },
|
||
"Post Production": { label: "后期制作", tone: "neutral" },
|
||
Rumored: { label: "传闻", tone: "neutral" },
|
||
};
|
||
|
||
export interface MediaFacts {
|
||
title: string;
|
||
originalTitle: string | null;
|
||
/** 中文名与标题不同时显示在原名一行 */
|
||
titleZh?: string | null;
|
||
year: number | null;
|
||
/** movie / series / variety … */
|
||
kind: string;
|
||
kindLabel: string;
|
||
genres: string[];
|
||
voteAverage: number | null;
|
||
voteCount: number | null;
|
||
airStatus: string | null;
|
||
runtime: number | null;
|
||
seasonCount: number | null;
|
||
episodeCount: number | null;
|
||
releaseDate: string | null;
|
||
lastAirDate: string | null;
|
||
platform: string | null;
|
||
originCountry: string[];
|
||
overview: string | null;
|
||
tagline: string | null;
|
||
posterPath: string | null;
|
||
backdropPath: string | null;
|
||
externalLinks: { label: string; href: string }[];
|
||
}
|
||
|
||
const pad = (n: number) => String(n).padStart(2, "0");
|
||
export const today = () => new Date().toISOString().slice(0, 10);
|
||
|
||
function runtimeText(min: number | null, kind: string): string | null {
|
||
if (!min) return null;
|
||
const s = min >= 60 ? `${Math.floor(min / 60)} 小时${min % 60 ? ` ${min % 60} 分钟` : ""}` : `${min} 分钟`;
|
||
return kind === "movie" ? s : `单集 ${s}`;
|
||
}
|
||
|
||
/** 可折叠的长文本:默认最多 lines 行,超出给「展开」 */
|
||
export function ClampText({
|
||
text,
|
||
lines = 3,
|
||
className,
|
||
onMedia = false,
|
||
}: {
|
||
text: string;
|
||
lines?: 2 | 3 | 4;
|
||
className?: string;
|
||
onMedia?: boolean;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const long = text.length > lines * 38;
|
||
const clamp = lines === 2 ? "line-clamp-2" : lines === 3 ? "line-clamp-3" : "line-clamp-4";
|
||
return (
|
||
<div className={className}>
|
||
<p className={cn("break-words whitespace-pre-line", !open && long && clamp)}>{text}</p>
|
||
{long ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((v) => !v)}
|
||
className={cn(
|
||
"mt-0.5 cursor-pointer text-meta underline-offset-4 hover:underline",
|
||
onMedia
|
||
? "text-on-media-tertiary hover:text-on-media-strong"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
{open ? "收起" : "展开"}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 头部:背景图(压暗)+ 海报 + 标题与事实行 + 简介 + 外部链接;badges 放标题下方,操作按钮由页面放在头部之后 */
|
||
export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: ReactNode }) {
|
||
const [bgFailed, setBgFailed] = useState(false);
|
||
const bg = bgFailed ? null : tmdbImageUrl(facts.backdropPath, "w1280");
|
||
const status = facts.airStatus ? (AIR_STATUS[facts.airStatus] ?? { label: facts.airStatus, tone: "neutral" }) : null;
|
||
const sub = [
|
||
facts.titleZh && facts.titleZh !== facts.title ? facts.titleZh : null,
|
||
facts.originalTitle && facts.originalTitle !== facts.title && facts.originalTitle !== facts.titleZh
|
||
? facts.originalTitle
|
||
: null,
|
||
].filter(Boolean);
|
||
const line = [
|
||
facts.kindLabel,
|
||
facts.year ? String(facts.year) : null,
|
||
facts.kind !== "movie" && facts.seasonCount ? `${facts.seasonCount} 季` : null,
|
||
facts.kind !== "movie" && facts.episodeCount ? `${facts.episodeCount} 集` : null,
|
||
runtimeText(facts.runtime, facts.kind),
|
||
facts.platform,
|
||
facts.originCountry.join(" / ") || null,
|
||
].filter(Boolean);
|
||
const dates = [
|
||
facts.releaseDate ? `${facts.kind === "movie" ? "上映" : "首播"} ${facts.releaseDate}` : null,
|
||
facts.kind !== "movie" && facts.lastAirDate ? `最近播出 ${facts.lastAirDate}` : null,
|
||
].filter(Boolean);
|
||
return (
|
||
<section className="relative isolate overflow-hidden rounded-2xl border border-border bg-scrim text-on-media">
|
||
{bg ? (
|
||
<img
|
||
src={bg}
|
||
alt=""
|
||
aria-hidden
|
||
onError={() => setBgFailed(true)}
|
||
className="absolute inset-0 -z-10 h-full w-full object-cover object-top opacity-55"
|
||
/>
|
||
) : null}
|
||
<div aria-hidden className="absolute inset-0 -z-10 bg-gradient-to-r from-scrim/90 via-scrim/70 to-scrim/35" />
|
||
<div
|
||
aria-hidden
|
||
className="absolute inset-0 -z-10 bg-gradient-to-t from-scrim/80 via-transparent to-transparent"
|
||
/>
|
||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:gap-6 sm:p-6">
|
||
<CoverPlaceholder
|
||
title={facts.title}
|
||
kind={facts.kind}
|
||
posterPath={facts.posterPath}
|
||
className="h-40 w-28 self-start border-on-media-wash/20 shadow-lg sm:h-60 sm:w-40"
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<h1 className="text-title-lg font-semibold tracking-tight break-words text-on-media-strong sm:text-headline-sm">
|
||
{facts.title}
|
||
{facts.year ? <span className="font-normal text-on-media-tertiary"> ({facts.year})</span> : null}
|
||
</h1>
|
||
{sub.length ? <p className="mt-1 text-body break-words text-on-media-secondary">{sub.join(" · ")}</p> : null}
|
||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-caption text-on-media-secondary">
|
||
{facts.voteAverage ? (
|
||
<span className="inline-flex items-center gap-1 font-medium text-on-media-strong">
|
||
<Star className="h-3.5 w-3.5 fill-current" aria-hidden />
|
||
{facts.voteAverage.toFixed(1)}
|
||
{facts.voteCount ? (
|
||
<span className="font-normal text-on-media-tertiary">({facts.voteCount} 人评分)</span>
|
||
) : null}
|
||
</span>
|
||
) : null}
|
||
{status ? <StatusBadge tone={status.tone}>{status.label}</StatusBadge> : null}
|
||
{line.length ? <span>{line.join(" · ")}</span> : null}
|
||
</div>
|
||
{facts.genres.length ? (
|
||
<ul className="mt-2 flex flex-wrap gap-1.5" aria-label="类型">
|
||
{facts.genres.map((g) => (
|
||
<li
|
||
key={g}
|
||
className="rounded-full border border-on-media-wash/25 px-2 py-0.5 text-meta text-on-media-secondary"
|
||
>
|
||
{g}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
{badges ? <div className="mt-2 flex flex-wrap items-center gap-1.5">{badges}</div> : null}
|
||
{facts.tagline ? <p className="mt-3 text-caption text-on-media-tertiary italic">{facts.tagline}</p> : null}
|
||
{facts.overview ? (
|
||
<ClampText
|
||
text={facts.overview}
|
||
lines={4}
|
||
onMedia
|
||
className="mt-2 max-w-3xl text-caption leading-relaxed text-on-media"
|
||
/>
|
||
) : (
|
||
<p className="mt-2 text-meta text-on-media-tertiary">暂无简介</p>
|
||
)}
|
||
{dates.length || facts.externalLinks.length ? (
|
||
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-on-media-tertiary">
|
||
{dates.map((d) => (
|
||
<span key={d}>{d}</span>
|
||
))}
|
||
{facts.externalLinks.map((l) => (
|
||
<a
|
||
key={l.href}
|
||
href={l.href}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
className="inline-flex items-center gap-1 underline-offset-4 hover:text-on-media-strong hover:underline"
|
||
>
|
||
{l.label}
|
||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||
</a>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Avatar({ path, name }: { path: string | null; name: string }) {
|
||
const [failed, setFailed] = useState(false);
|
||
const src = failed ? null : tmdbImageUrl(path, "w185");
|
||
return src ? (
|
||
<img
|
||
src={src}
|
||
alt=""
|
||
loading="lazy"
|
||
onError={() => setFailed(true)}
|
||
className="h-16 w-16 shrink-0 rounded-full border border-border bg-foreground/[0.05] object-cover"
|
||
/>
|
||
) : (
|
||
<span
|
||
aria-hidden
|
||
title={name}
|
||
className="grid h-16 w-16 shrink-0 place-items-center rounded-full border border-border bg-foreground/[0.05] text-muted-foreground"
|
||
>
|
||
<UserRound className="h-6 w-6" />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
const CREW_ROLE: Record<string, string> = { Director: "导演", Creator: "主创" };
|
||
|
||
/** 主创:导演 / 主创在前,演员前 10;头像经图片代理,点姓名去 TMDB 人物页 */
|
||
export function CreditsStrip({ cast, crew }: { cast: MediaCredit[]; crew: MediaCredit[] }) {
|
||
const people = [
|
||
...crew.map((c) => ({ ...c, role: c.role ? (CREW_ROLE[c.role] ?? c.role) : null, key: `c${c.id}` })),
|
||
...cast.slice(0, 10).map((c) => ({ ...c, key: `a${c.id}` })),
|
||
];
|
||
if (!people.length) return null;
|
||
return (
|
||
<ul className="scroll-bleed -mx-1 flex gap-3 overflow-x-auto px-1 pb-1 sm:flex-wrap sm:overflow-visible">
|
||
{people.map((p) => (
|
||
<li key={p.key} className="flex w-20 shrink-0 flex-col items-center text-center">
|
||
<Avatar path={p.profilePath} name={p.name} />
|
||
<a
|
||
href={`https://www.themoviedb.org/person/${p.id}`}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
className="mt-1.5 line-clamp-2 text-meta font-medium break-words text-foreground hover:underline"
|
||
>
|
||
{p.name}
|
||
</a>
|
||
{p.role ? <span className="line-clamp-2 text-micro break-words text-muted-foreground">{p.role}</span> : null}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
export interface EpisodeRowData {
|
||
key: string;
|
||
seasonNumber: number;
|
||
episodeNumber: number;
|
||
name: string | null;
|
||
overview: string | null;
|
||
airDate: string | null;
|
||
runtime: number | null;
|
||
stillPath: string | null;
|
||
voteAverage: number | null;
|
||
/** 库内状态;null = 不显示(仅 TMDB 作品未收录) */
|
||
status?: { label: string; tone: StatusTone } | null;
|
||
/** 有锁定的字段 */
|
||
locked?: boolean;
|
||
onEdit?: () => void;
|
||
/** 简介下方的附加内容(入库版本) */
|
||
extra?: ReactNode;
|
||
}
|
||
|
||
function Still({ path }: { path: string | null }) {
|
||
const [failed, setFailed] = useState(false);
|
||
const src = failed ? null : tmdbImageUrl(path, "w300");
|
||
return (
|
||
<div className="aspect-video w-28 shrink-0 overflow-hidden rounded-lg border border-border bg-foreground/[0.05] sm:w-40">
|
||
{src ? (
|
||
<img src={src} alt="" loading="lazy" onError={() => setFailed(true)} className="h-full w-full object-cover" />
|
||
) : (
|
||
<div aria-hidden className="grid h-full w-full place-items-center text-muted-foreground">
|
||
<ImageOff className="h-4 w-4 opacity-60" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 每集一行:剧照 · 集号与标题 · 播出日期 · 时长 · 简介(折叠)· 库内状态 */
|
||
export function EpisodeRow({ e }: { e: EpisodeRowData }) {
|
||
const unaired = !!e.airDate && e.airDate > today();
|
||
const meta = [
|
||
e.airDate ?? "播出日期未知",
|
||
e.runtime ? `${e.runtime} 分钟` : null,
|
||
e.voteAverage ? `★ ${e.voteAverage.toFixed(1)}` : null,
|
||
].filter(Boolean);
|
||
const status = e.status === undefined ? (unaired ? { label: "未播出", tone: "neutral" as const } : null) : e.status;
|
||
return (
|
||
<li className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
|
||
<Still path={e.stillPath} />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1">
|
||
<p className="min-w-0 text-sm font-medium break-words text-foreground">
|
||
<span className="mr-1.5 font-mono text-caption text-muted-foreground">
|
||
S{pad(e.seasonNumber)}E{pad(e.episodeNumber)}
|
||
</span>
|
||
{e.name ?? `第 ${e.episodeNumber} 集`}
|
||
{e.locked ? <Lock className="ml-1 inline h-3 w-3 text-muted-foreground" aria-label="有锁定字段" /> : null}
|
||
</p>
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
{status ? <StatusBadge tone={status.tone}>{status.label}</StatusBadge> : null}
|
||
{e.onEdit ? (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label={`修改 S${pad(e.seasonNumber)}E${pad(e.episodeNumber)}`}
|
||
onClick={e.onEdit}
|
||
>
|
||
<PencilLine />
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">{meta.join(" · ")}</p>
|
||
{e.overview ? (
|
||
<ClampText text={e.overview} lines={2} className="mt-1 text-caption text-foreground/85" />
|
||
) : (
|
||
<p className="mt-0.5 text-meta text-muted-foreground">暂无简介</p>
|
||
)}
|
||
{e.extra}
|
||
</div>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
export interface SeasonHeaderData {
|
||
seasonNumber: number;
|
||
name: string | null;
|
||
airDate: string | null;
|
||
episodeCount: number | null;
|
||
posterPath: string | null;
|
||
overview: string | null;
|
||
/** 季头右侧的摘要,如「已入库 5/9」 */
|
||
summary?: ReactNode;
|
||
/** 季头第二行下方的一行说明(入库版本汇总) */
|
||
detail?: ReactNode;
|
||
}
|
||
|
||
/** 季折叠:季头(海报缩略、季名、首播年、集数、摘要)+ 展开后的内容(集列表由页面传入) */
|
||
export function SeasonSection({
|
||
s,
|
||
open,
|
||
onToggle,
|
||
children,
|
||
}: {
|
||
s: SeasonHeaderData;
|
||
open: boolean;
|
||
onToggle: () => void;
|
||
children: ReactNode;
|
||
}) {
|
||
const title = s.name ?? (s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`);
|
||
return (
|
||
<section className="rounded-xl border border-border/70">
|
||
<button
|
||
type="button"
|
||
aria-expanded={open}
|
||
onClick={onToggle}
|
||
className="flex w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-base hover:bg-wash/[0.04]"
|
||
>
|
||
<CoverPlaceholder title={title} kind="series" small posterPath={s.posterPath} className="h-14 w-10" />
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-sm font-medium break-words text-foreground">
|
||
{title}
|
||
<span className="ml-2 font-mono text-meta font-normal text-muted-foreground">S{pad(s.seasonNumber)}</span>
|
||
</p>
|
||
<p className="text-meta text-muted-foreground">
|
||
{[s.airDate ? s.airDate.slice(0, 4) : null, s.episodeCount !== null ? `${s.episodeCount} 集` : null]
|
||
.filter(Boolean)
|
||
.join(" · ") || "—"}
|
||
</p>
|
||
{s.detail ? (
|
||
<p className="mt-0.5 text-meta break-words text-muted-foreground tabular-nums">{s.detail}</p>
|
||
) : null}
|
||
</div>
|
||
{s.summary ? <div className="hidden shrink-0 sm:block">{s.summary}</div> : null}
|
||
<ChevronDown
|
||
aria-hidden
|
||
className={cn(
|
||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-base",
|
||
open && "rotate-180",
|
||
)}
|
||
/>
|
||
</button>
|
||
{open ? (
|
||
<div className="border-t border-border/60 px-3 py-3">
|
||
{s.summary ? <div className="mb-2 sm:hidden">{s.summary}</div> : null}
|
||
{s.overview ? (
|
||
<ClampText text={s.overview} lines={2} className="mb-3 text-caption text-muted-foreground" />
|
||
) : null}
|
||
{children}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|