Files
UHD_Media_Tool/apps/web/app/components/media/media-detail.tsx
T
rootandClaude Opus 5.5 caf5a1f2f4 feat(web): 作品详情头图改为剧照铺顶渐隐到页面底色(方案 A)
- MediaHero 去掉深色圆角卡片:剧照铺在内容区顶部,上下与右缘渐隐成透明;左侧文字区压近乎不透明的页面底色、右侧露出剧照,窄屏改为自上而下压底色;文字用常规前景色,日夜同一结构;没有背景图时不渲染剧照层
- 新增材质参数 --mat-hero-*(浅 / 深两套)与 .media-hero-backdrop
- 「正常」/ Emby / 库存等徽标并进「连载中」所在的事实行
- 操作按钮(订阅 / 搜索资源 / 查看订阅 / 合并作品 / 插槽入口如补字幕 / 命名覆盖)移进头图区外链行下方,库作品页与 TMDB 详情页一致
- 订阅改为主色实心(Button variant="solid",.pill-solid / --mat-pill-solid-*),作为头图区唯一主操作;设计文档记为例外
- 简介折叠:去掉省略号 + 单独一行「展开」的重复,改为最后一行行尾渐隐、「展开」压在渐隐处;按实际排版测量是否需要折叠
- 补 MediaHero / ClampText 渲染测试

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:44:32 +08:00

445 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type MediaCredit, tmdbImageUrl } from "@umt/contracts";
import { ChevronDown, ExternalLink, ImageOff, Lock, PencilLine, Star, UserRound } from "lucide-react";
import { type CSSProperties, type ReactNode, useLayoutEffect, useRef, 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);背景图渐隐到页面底色(--mat-hero-*),文字用常规前景色,随日夜翻转。
/** 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 }: { text: string; lines?: 2 | 3 | 4; className?: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLParagraphElement>(null);
// 首屏(含服务端渲染)按字数估计,挂载后按实际排版量:内容高度超过 lines 行才折叠
const [long, setLong] = useState(() => text.length > lines * 38);
useLayoutEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === "undefined") return;
const measure = () => {
const cs = getComputedStyle(el);
const lh = Number.parseFloat(cs.lineHeight) || Number.parseFloat(cs.fontSize) * 1.5;
setLong(el.scrollHeight > lines * lh + 1);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [lines]);
const clamped = long && !open;
return (
<div className={cn("relative", className)}>
<p
ref={ref}
data-clamped={clamped || undefined}
className={cn("break-words whitespace-pre-line", clamped && "clamp-fade")}
style={clamped ? ({ "--clamp-lines": lines } as CSSProperties) : undefined}
>
{text}
</p>
{long ? (
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={cn(
"cursor-pointer text-meta font-medium text-foreground/80 underline-offset-4 hover:text-foreground hover:underline",
clamped ? "absolute right-0 bottom-0 leading-[inherit]" : "mt-0.5",
)}
>
{open ? "收起" : "展开"}
</button>
) : null}
</div>
);
}
/**
* 头部(方案 A,2026-10-07):有背景图时剧照铺在内容区顶部,上下和右缘渐隐到页面底色,左侧文字区压近乎不透明的底色;
* 没有背景图时是普通头部。海报、标题、事实行(评分 · 播出状态 · 页面给的状态徽标 · 类型年份)、标语、简介、日期与外链、
* 操作按钮都压在背景上;文字用常规前景色 token,日夜同一结构。badges 并进事实行,actions 放在外链行下方。
*/
export function MediaHero({ facts, badges, actions }: { facts: MediaFacts; badges?: ReactNode; actions?: 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" data-backdrop={bg ? "image" : "none"}>
{bg ? (
<div
aria-hidden
data-slot="media-hero-backdrop"
className="media-hero-backdrop pointer-events-none absolute -inset-x-4 -top-16 -z-10 h-[26rem] overflow-hidden sm:-top-20 sm:h-[calc(100%+7rem)] sm:min-h-[30rem] md:-inset-x-6"
>
<img
src={bg}
alt=""
onError={() => setBgFailed(true)}
className="absolute inset-0 h-full w-full object-cover object-top"
/>
</div>
) : null}
<div className={cn("flex flex-col gap-4 sm:flex-row sm:gap-6", bg && "pt-24 sm:pt-6")}>
<CoverPlaceholder
title={facts.title}
kind={facts.kind}
posterPath={facts.posterPath}
className="h-40 w-28 self-start 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-foreground sm:text-headline-sm">
{facts.title}
{facts.year ? <span className="font-normal text-muted-foreground"> ({facts.year})</span> : null}
</h1>
{sub.length ? <p className="mt-1 text-body break-words text-muted-foreground">{sub.join(" · ")}</p> : null}
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-caption text-muted-foreground">
{facts.voteAverage ? (
<span className="inline-flex items-center gap-1 font-medium text-foreground">
<Star className="h-3.5 w-3.5 fill-current" aria-hidden />
{facts.voteAverage.toFixed(1)}
{facts.voteCount ? (
<span className="font-normal text-muted-foreground">({facts.voteCount} 人评分)</span>
) : null}
</span>
) : null}
{status || badges ? (
<span data-slot="media-hero-badges" className="inline-flex flex-wrap items-center gap-1.5">
{status ? <StatusBadge tone={status.tone}>{status.label}</StatusBadge> : null}
{badges}
</span>
) : 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-border px-2 py-0.5 text-meta text-muted-foreground">
{g}
</li>
))}
</ul>
) : null}
{facts.tagline ? (
<p data-slot="media-hero-tagline" className="mt-3 text-caption text-muted-foreground italic">
{facts.tagline}
</p>
) : null}
{facts.overview ? (
<ClampText
text={facts.overview}
lines={4}
className="mt-2 max-w-2xl text-caption leading-relaxed text-foreground/90"
/>
) : (
<p className="mt-2 text-meta text-muted-foreground">暂无简介</p>
)}
{dates.length || facts.externalLinks.length ? (
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
{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-foreground hover:underline"
>
{l.label}
<ExternalLink className="h-3 w-3" aria-hidden />
</a>
))}
</div>
) : null}
{actions ? (
<div data-slot="media-hero-actions" className="mt-4 flex flex-wrap items-center gap-2">
{actions}
</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} />
{p.id > 0 ? (
<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>
) : (
// 元数据补全写的主演没有 TMDB 人物 ID(负数占位),不链接
<span className="mt-1.5 line-clamp-2 text-meta font-medium break-words text-foreground">{p.name}</span>
)}
{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>
);
}