diff --git a/apps/server/src/modules/acquisition-organize/routes/library.ts b/apps/server/src/modules/acquisition-organize/routes/library.ts index 3431682e..3720b5ad 100644 --- a/apps/server/src/modules/acquisition-organize/routes/library.ts +++ b/apps/server/src/modules/acquisition-organize/routes/library.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { simplifiedChineseTagline } from "@umt/adapters"; import { type AcquisitionPlan, type AcquisitionSlot, @@ -405,6 +406,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) { finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : null, })), scrape: await latestEntityScrape(d, id), + displayTagline: heroTagline(e!.metadata), }; return detail; }, @@ -642,3 +644,11 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) { { tag: TAG }, ); } + +/** 详情页头部的标语:只显示简体中文(见 simplifiedChineseTagline);存储的标语与语言不变 */ +function heroTagline(metadata: unknown): string | null { + const m = (metadata ?? {}) as Record; + const text = typeof m.tagline?.value === "string" ? m.tagline.value : null; + const orig = typeof m.originalLanguage?.value === "string" ? m.originalLanguage.value : null; + return simplifiedChineseTagline(text, m.tagline?.language ?? null, orig); +} diff --git a/apps/server/src/modules/search/routes.ts b/apps/server/src/modules/search/routes.ts index 5c6325ca..1118d446 100644 --- a/apps/server/src/modules/search/routes.ts +++ b/apps/server/src/modules/search/routes.ts @@ -3,6 +3,7 @@ import { fetchRichDetail, fetchRichSeason, type MetadataCandidate, + simplifiedChineseTagline, TmdbError, type TmdbRichDetail, } from "@umt/adapters"; @@ -453,7 +454,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear out.title = rich.title || out.title; out.overview = rich.overview ?? out.overview; out.titleZh = rich.titleZh; - out.tagline = rich.tagline; + out.tagline = simplifiedChineseTagline(rich.tagline, rich.taglineLanguage, rich.originalLanguage); out.genres = rich.genres; out.voteAverage = rich.voteAverage; out.voteCount = rich.voteCount; diff --git a/apps/server/test/acquisition-organize/library-versions.test.ts b/apps/server/test/acquisition-organize/library-versions.test.ts index 2dee71d2..f4d157bf 100644 --- a/apps/server/test/acquisition-organize/library-versions.test.ts +++ b/apps/server/test/acquisition-organize/library-versions.test.ts @@ -336,4 +336,32 @@ describe.skipIf(!hasPg)("LIBV-1 入库版本", () => { expect(response.body.data.downloads).toHaveLength(105); expect(response.body.data.droppedPlans).toHaveLength(51); }); + + it("作品详情头部标语只给简体中文(displayTagline);存储的 metadata.tagline 不变", async () => { + const field = (value: unknown, language: string | null = null) => ({ + value, + source: "tmdb", + language, + at: "2026-10-01T00:00:00.000Z", + locked: false, + }); + const cases: [string, string, string | null][] = [ + ["我们注定要离开地球", "en", "我们注定要离开地球"], + ["我們註定要離開地球", "en", null], + ["Mankind was born on Earth.", "en", null], + ["我們註定要離開地球", "zh", "我們註定要離開地球"], + ]; + for (const [tagline, orig, want] of cases) { + const entityId = await seedEntity(h.db, { kind: "movie", title: `标语 ${tagline}`, year: 2026 }); + await h.db + .updateTable("media_entity") + .set({ metadata: JSON.stringify({ tagline: field(tagline), originalLanguage: field(orig) }) }) + .where("id", "=", entityId) + .execute(); + const r = await h.api("GET", `/library/${entityId}`); + expect(r.status).toBe(200); + expect(r.body.data.displayTagline).toBe(want); + expect(r.body.data.entity.metadata.tagline.value).toBe(tagline); + } + }); }); diff --git a/apps/server/test/file-manager/scrape.test.ts b/apps/server/test/file-manager/scrape.test.ts index 0fa34f8b..ff2a9bf5 100644 --- a/apps/server/test/file-manager/scrape.test.ts +++ b/apps/server/test/file-manager/scrape.test.ts @@ -33,6 +33,7 @@ function detail( overview: null, overviewLanguage: null, tagline: null, + taglineLanguage: null, year: null, releaseDate: null, lastAirDate: null, diff --git a/apps/server/test/scrape/scrape-layout-mp.test.ts b/apps/server/test/scrape/scrape-layout-mp.test.ts index dad8d895..efd56102 100644 --- a/apps/server/test/scrape/scrape-layout-mp.test.ts +++ b/apps/server/test/scrape/scrape-layout-mp.test.ts @@ -68,6 +68,7 @@ const detail = (entityType: "movie" | "tv", externalId: string): TmdbRichDetail overview: null, overviewLanguage: null, tagline: null, + taglineLanguage: null, year: 2022, releaseDate: null, lastAirDate: null, diff --git a/apps/web/app/app.css b/apps/web/app/app.css index e863b6d3..64d80459 100644 --- a/apps/web/app/app.css +++ b/apps/web/app/app.css @@ -352,6 +352,16 @@ --mat-pill-secondary-bg: rgba(20, 22, 28, 0.03); --mat-pill-secondary-bg-hover: rgba(20, 22, 28, 0.07); --mat-pill-secondary-border: rgba(20, 22, 28, 0.12); + /* pill-solid:详情页头图区的唯一主操作(订阅),主色实心;只用在 MediaHero 的操作行 */ + --mat-pill-solid-bg: var(--primary); + --mat-pill-solid-bg-hover: color-mix(in oklab, var(--primary) 84%, var(--background)); + --mat-pill-solid-fg: var(--primary-foreground); + --mat-pill-solid-shadow: 0 1px 2px rgba(20, 22, 28, 0.14), 0 6px 16px -8px rgba(20, 22, 28, 0.4); + /* media-hero:详情页头图。剧照铺在内容区顶部、渐隐到页面底色;左侧文字区压近乎不透明的底色,右侧露出剧照。 + 窄屏海报与文字上下排,改为自上而下压底色 */ + --mat-hero-image-opacity: 1; + --mat-hero-scrim-side: linear-gradient(90deg, var(--background) 0%, color-mix(in oklab, var(--background) 96%, transparent) 38%, color-mix(in oklab, var(--background) 82%, transparent) 55%, color-mix(in oklab, var(--background) 40%, transparent) 72%, transparent 92%); + --mat-hero-scrim-stack: linear-gradient(180deg, color-mix(in oklab, var(--background) 30%, transparent) 0%, color-mix(in oklab, var(--background) 72%, transparent) 45%, color-mix(in oklab, var(--background) 92%, transparent) 75%, var(--background) 100%); /* 分段控件的滑块(侧栏当前项、SegmentedTabs):日间是托起的白片,夜间是薄白玻璃 */ --mat-seg-thumb-bg: rgba(255, 255, 255, 0.8); --mat-seg-thumb-border: rgba(20, 22, 28, 0.06); @@ -493,6 +503,13 @@ --mat-pill-secondary-bg: rgba(255, 255, 255, 0.04); --mat-pill-secondary-bg-hover: rgba(255, 255, 255, 0.1); --mat-pill-secondary-border: rgba(255, 255, 255, 0.15); + --mat-pill-solid-bg: var(--primary); + --mat-pill-solid-bg-hover: color-mix(in oklab, var(--primary) 84%, var(--background)); + --mat-pill-solid-fg: var(--primary-foreground); + --mat-pill-solid-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 6px 16px -8px rgba(0, 0, 0, 0.6); + --mat-hero-image-opacity: 0.85; + --mat-hero-scrim-side: linear-gradient(90deg, var(--background) 0%, color-mix(in oklab, var(--background) 94%, transparent) 38%, color-mix(in oklab, var(--background) 78%, transparent) 55%, color-mix(in oklab, var(--background) 36%, transparent) 72%, transparent 92%); + --mat-hero-scrim-stack: linear-gradient(180deg, color-mix(in oklab, var(--background) 30%, transparent) 0%, color-mix(in oklab, var(--background) 72%, transparent) 45%, color-mix(in oklab, var(--background) 92%, transparent) 75%, var(--background) 100%); --mat-seg-thumb-bg: rgba(255, 255, 255, 0.08); --mat-seg-thumb-border: rgba(255, 255, 255, 0.15); --mat-seg-thumb-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 4px 18px -6px rgba(0, 0, 0, 0.4); @@ -1313,9 +1330,70 @@ color: var(--content-strong); } + /* 实心主操作:只给详情页头图区的唯一主操作(订阅)用(2026-10-07 用户定的例外), + 其余页面主操作仍是 pill-primary;hover 只微调明度,不换色 */ + .pill-solid { + background: var(--mat-pill-solid-bg); + border: 1px solid transparent; + color: var(--mat-pill-solid-fg); + box-shadow: var(--mat-pill-solid-shadow); + transition: + background-color var(--duration-base) var(--ease-standard), + transform 220ms var(--ease-spring); + } + + .pill-solid:hover { + background: var(--mat-pill-solid-bg-hover); + } + + /* 详情页头图(MediaHero):剧照层上下、右缘渐隐成透明,露出页面底色与光斑,不留硬边; + ::after 是压在剧照上的底色渐变(左侧文字区近乎不透明),参数见 --mat-hero-* */ + .media-hero-backdrop { + -webkit-mask-image: + linear-gradient(to bottom, transparent 0%, #000 14%, #000 55%, transparent 100%), + linear-gradient(to right, #000 0%, #000 90%, transparent 100%); + -webkit-mask-composite: source-in; + mask-image: + linear-gradient(to bottom, transparent 0%, #000 14%, #000 55%, transparent 100%), + linear-gradient(to right, #000 0%, #000 90%, transparent 100%); + mask-composite: intersect; + } + + .media-hero-backdrop > img { + opacity: var(--mat-hero-image-opacity); + } + + .media-hero-backdrop::after { + content: ""; + position: absolute; + inset: 0; + background: var(--mat-hero-scrim-stack); + } + + @media (min-width: 640px) { + .media-hero-backdrop::after { + background: var(--mat-hero-scrim-side); + } + } + + /* 折叠的长文本(ClampText):最后一行行尾渐隐,「展开」压在渐隐处;行数由 --clamp-lines 给 */ + .clamp-fade { + max-height: calc(var(--clamp-lines, 3) * 1lh); + overflow: hidden; + -webkit-mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 62%, transparent 88%); + -webkit-mask-size: 100% calc(100% - 1lh), 100% 1lh; + -webkit-mask-position: top, bottom; + -webkit-mask-repeat: no-repeat; + mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 62%, transparent 88%); + mask-size: 100% calc(100% - 1lh), 100% 1lh; + mask-position: top, bottom; + mask-repeat: no-repeat; + } + /* 按下回弹,与 .btn-glass 同一手感 */ .pill-primary:active, - .pill-secondary:active { + .pill-secondary:active, + .pill-solid:active { transform: scale(0.97); } diff --git a/apps/web/app/components/media/media-detail.test.ts b/apps/web/app/components/media/media-detail.test.ts new file mode 100644 index 00000000..3650e948 --- /dev/null +++ b/apps/web/app/components/media/media-detail.test.ts @@ -0,0 +1,120 @@ +import { createElement, Fragment } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { ClampText, type MediaFacts, MediaHero } from "./media-detail.tsx"; + +// 详情页头图(方案 A):背景图渐隐到页面底色、无背景图退化为普通头部;状态徽标并进事实行; +// 操作按钮并进头图区且只有一个实心主操作;简介折叠只留一个「展开」入口。 + +const facts = (over: Partial = {}): MediaFacts => ({ + title: "人生切割术", + originalTitle: "Severance", + year: 2022, + kind: "series", + kindLabel: "剧集", + genres: ["剧情"], + voteAverage: 8.4, + voteCount: 1200, + airStatus: "Returning Series", + runtime: 55, + seasonCount: 2, + episodeCount: 19, + releaseDate: "2022-02-17", + lastAirDate: "2025-03-20", + platform: "Apple TV", + originCountry: ["US"], + overview: "故事发生在一家名为卢蒙的超级大公司内。", + tagline: null, + posterPath: "/sev-poster.jpg", + backdropPath: "/severance-bg.jpg", + externalLinks: [{ label: "TMDB", href: "https://www.themoviedb.org/tv/95396" }], + ...over, +}); + +const render = (props: Parameters[0]) => renderToStaticMarkup(createElement(MediaHero, props)); + +/** 取 data-slot 标记的元素起始处之后的一段 HTML(足够断言其中的内容) */ +const slotHtml = (html: string, slot: string) => { + const i = html.indexOf(`data-slot="${slot}"`); + return i < 0 ? null : html.slice(i, html.indexOf("", i) + 6); +}; + +describe("MediaHero 头图", () => { + it("有背景图:剧照层经图片代理、带渐隐材质,不再是深色圆角卡片;文字用常规前景色", () => { + const html = render({ facts: facts() }); + expect(html).toContain('data-backdrop="image"'); + expect(html).toContain("media-hero-backdrop"); + expect(html).toContain('src="/api/v1/img/tmdb/w1280/severance-bg.jpg"'); + expect(html).not.toMatch(/bg-scrim|text-on-media|rounded-2xl/); + expect(html).toContain("text-foreground"); + }); + + it("没有背景图:退化为普通头部,不渲染剧照层和空的深色块", () => { + const html = render({ facts: facts({ backdropPath: null }) }); + expect(html).toContain('data-backdrop="none"'); + expect(html).not.toContain("media-hero-backdrop"); + expect(html).not.toContain("w1280"); + expect(html).not.toMatch(/bg-scrim/); + expect(html).toContain("人生切割术"); + }); + + it("状态徽标(正常 / Emby 等)与播出状态在同一行", () => { + const html = render({ + facts: facts(), + badges: StatusBadge({ tone: "success", children: "正常" }), + }); + const row = slotHtml(html, "media-hero-badges"); + expect(row).toContain("连载中"); + expect(row).toContain("正常"); + }); + + it("操作按钮并进头图区、在外链之后;只有订阅是实心主操作,其余是描边次操作,没有棱镜描边", () => { + const html = render({ + facts: facts(), + actions: createElement( + Fragment, + null, + createElement(Button, { variant: "solid", size: "sm" }, "订阅"), + createElement(Button, { variant: "outline", size: "sm" }, "搜索资源"), + createElement(Button, { variant: "outline", size: "sm" }, "查看订阅"), + ), + }); + const actions = slotHtml(html, "media-hero-actions"); + expect(actions).not.toBeNull(); + expect(html.indexOf('data-slot="media-hero-actions"')).toBeGreaterThan(html.indexOf("TMDB")); + expect(actions!.match(/pill-solid/g)).toHaveLength(1); + expect(actions!.match(/pill-secondary/g)).toHaveLength(2); + expect(actions).not.toContain("pill-primary"); + expect(actions!.indexOf("pill-solid")).toBeLessThan(actions!.indexOf("订阅")); + }); + + it("没有操作时不渲染操作行;标语有值才显示", () => { + const plain = render({ facts: facts() }); + expect(plain).not.toContain("media-hero-actions"); + expect(plain).not.toContain("media-hero-tagline"); + const tagged = render({ facts: facts({ tagline: "工作与生活,一刀两断" }) }); + expect(slotHtml(tagged, "media-hero-tagline")).toContain("工作与生活,一刀两断"); + }); +}); + +describe("ClampText 折叠", () => { + const long = "很长的简介。".repeat(60); + + it("超出行数:折叠成行尾渐隐 + 一个「展开」入口,不再另加省略号", () => { + const html = renderToStaticMarkup(createElement(ClampText, { text: long, lines: 4 })); + expect(html).toContain("clamp-fade"); + expect(html).toContain("--clamp-lines:4"); + expect(html).not.toContain("line-clamp"); + expect(html).not.toMatch(/…|\.\.\./); + expect(html.match(/展开/g)).toHaveLength(1); + expect(html).toContain('aria-expanded="false"'); + }); + + it("放得下时不折叠、不给「展开」", () => { + const html = renderToStaticMarkup(createElement(ClampText, { text: "短简介", lines: 4 })); + expect(html).not.toContain("clamp-fade"); + expect(html).not.toContain("展开"); + }); +}); diff --git a/apps/web/app/components/media/media-detail.tsx b/apps/web/app/components/media/media-detail.tsx index b567badf..5ce41898 100644 --- a/apps/web/app/components/media/media-detail.tsx +++ b/apps/web/app/components/media/media-detail.tsx @@ -1,6 +1,6 @@ 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 { 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"; @@ -8,7 +8,7 @@ import { cn } from "~/lib/utils.ts"; // 媒体详情(MD-1,对照 MP 媒体详情页):背景图 + 海报 + 标题 / 原名 / 年份 / 类型 / 评分 / 状态 / 集数 + 简介、 // 主创、季折叠与每集行。媒体库详情(本地作品)与仅 TMDB 的作品详情共用;数据由页面整理成下面的结构传入。 -// 图片一律经图片代理(tmdbImageUrl);背景图上压 scrim 遮罩,文字用 on-media 色(不随日夜翻转)。 +// 图片一律经图片代理(tmdbImageUrl);背景图渐隐到页面底色(--mat-hero-*),文字用常规前景色,随日夜翻转。 /** TMDB status 原文 → 界面用词 */ export const AIR_STATUS: Record = { @@ -59,33 +59,44 @@ function runtimeText(min: number | null, kind: string): string | null { 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; -}) { +/** 可折叠的长文本:默认最多 lines 行;放不下时最后一行行尾渐隐,「展开」压在渐隐处(只有这一个入口,不另加省略号) */ +export function ClampText({ text, lines = 3, className }: { text: string; lines?: 2 | 3 | 4; className?: string }) { 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"; + const ref = useRef(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 ( -
-

{text}

+
+

+ {text} +

{long ? (
) : null} -
-
-
+
-

+

{facts.title} - {facts.year ? ({facts.year}) : null} + {facts.year ? ({facts.year}) : null}

- {sub.length ?

{sub.join(" · ")}

: null} -
+ {sub.length ?

{sub.join(" · ")}

: null} +
{facts.voteAverage ? ( - + {facts.voteAverage.toFixed(1)} {facts.voteCount ? ( - ({facts.voteCount} 人评分) + ({facts.voteCount} 人评分) ) : null} ) : null} - {status ? {status.label} : null} + {status || badges ? ( + + {status ? {status.label} : null} + {badges} + + ) : null} {line.length ? {line.join(" · ")} : null}
{facts.genres.length ? (
    {facts.genres.map((g) => ( -
  • +
  • {g}
  • ))}
) : null} - {badges ?
{badges}
: null} - {facts.tagline ?

{facts.tagline}

: null} + {facts.tagline ? ( +

+ {facts.tagline} +

+ ) : null} {facts.overview ? ( ) : ( -

暂无简介

+

暂无简介

)} {dates.length || facts.externalLinks.length ? ( -
+
{dates.map((d) => ( {d} ))} @@ -196,7 +215,7 @@ export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: React 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" + className="inline-flex items-center gap-1 underline-offset-4 hover:text-foreground hover:underline" > {l.label} @@ -204,6 +223,11 @@ export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: React ))}
) : null} + {actions ? ( +
+ {actions} +
+ ) : null}
diff --git a/apps/web/app/components/ui/button.tsx b/apps/web/app/components/ui/button.tsx index 0065999c..4d391290 100644 --- a/apps/web/app/components/ui/button.tsx +++ b/apps/web/app/components/ui/button.tsx @@ -14,6 +14,8 @@ const buttonVariants = cva( variant: { // 主操作:半透明胶囊 + 沿边框流转的棱镜光谱。样式见 app.css 的 .pill-primary(--mat-pill-primary-*、--prism-*) default: "pill-primary", + // 实心主操作:只给详情页头图区的唯一主操作(订阅)用,见 app.css 的 .pill-solid(--mat-pill-solid-*) + solid: "pill-solid", // 次操作:描边胶囊。outline 与 secondary 是同一个样子,保留两个名字只为兼容旧调用 outline: "pill-secondary", secondary: "pill-secondary", diff --git a/apps/web/app/routes/console/library.$id.tsx b/apps/web/app/routes/console/library.$id.tsx index e6ebe975..2692b07b 100644 --- a/apps/web/app/routes/console/library.$id.tsx +++ b/apps/web/app/routes/console/library.$id.tsx @@ -1091,7 +1091,8 @@ function factsOf(d: LibraryDetail, entity: MediaEntityDetail | undefined): Media platform: strOf(m("platform")), originCountry: Array.isArray(country) ? country.filter((x): x is string => typeof x === "string") : [], overview: strOf(m("overview")), - tagline: strOf(m("tagline")), + // 头部标语只显示简体中文,由服务端按取值语言 / 文字判断(存储的 metadata.tagline 不变) + tagline: d.displayTagline ?? null, posterPath: posterPathOf(e.metadata), backdropPath: strOf(m("backdropPath")), externalLinks: externalLinks({ @@ -1116,7 +1117,7 @@ export default function LibraryDetailPage() { const back = ( 媒体库 @@ -1174,42 +1175,54 @@ export default function LibraryDetailPage() { {(ms.data?.items.length ?? 0) > 0 ? : null} } - /> -
- {e.status !== "merged" ? ( + actions={ <> - - + + + ) : null} + + {e.status !== "merged" ? ( + + ) : null} + {/* 作品页操作区插槽(ADR-006):补字幕等功能模块的入口 */} + {entityActions.items({ entity: slotEntity }).map((a) => ( + + ))} + - ) : null} - - {e.status !== "merged" ? ( - - ) : null} - {/* 作品页操作区插槽(ADR-006):补字幕等功能模块的入口 */} - {entityActions.items({ entity: slotEntity }).map((a) => ( - - ))} - -
- + } + /> +
{credits.cast.length || credits.crew.length ? ( diff --git a/apps/web/app/routes/console/media.tmdb.$type.$id.tsx b/apps/web/app/routes/console/media.tmdb.$type.$id.tsx index 374a7711..4f826b25 100644 --- a/apps/web/app/routes/console/media.tmdb.$type.$id.tsx +++ b/apps/web/app/routes/console/media.tmdb.$type.$id.tsx @@ -41,7 +41,7 @@ export default function TmdbMediaPage() { - - {d.localEntityId ? ( - - ) : ( - - )} -
+ } + actions={ + <> + {/* 头图区唯一的主操作:实心;其余保持描边 */} + + + {d.localEntityId ? ( + + ) : ( + + )} + + } + /> -
+
{d.cast?.length || d.crew?.length ? ( diff --git a/docs/design/interface-materials.md b/docs/design/interface-materials.md index 42f9d1a8..fc981c41 100644 --- a/docs/design/interface-materials.md +++ b/docs/design/interface-materials.md @@ -22,7 +22,7 @@ - 选中态是静态的 `pill-selected`; - 分段控件的滑块是 `seg-thumb`。 - 全站没有实心按钮,也没有实心的选中块。 + 全站没有实心按钮,也没有实心的选中块。唯一例外是作品详情头图区(`MediaHero`)的「订阅」:压在剧照上时棱镜环看不清,改用主色实心 `pill-solid`(2026-10-07 定),其余页面不用。 5. **日夜两套,画面跟随页面**。大图、横向滚动行两侧的渐隐和压暗层都取页面底色,不硬切成黑色。播放页永远是夜间,落地页暂时固定夜间。 6. **同一种信息用同一种结构**: - 套餐卡的零件在公共定价页和订阅服务页共用; @@ -96,6 +96,15 @@ 列表页(电影、剧集、收藏、合集、演职人员)不做色带:标题、筛选条和内容坐在同一张页面底上。吸顶筛选条 `StickyFilterBar` 平时透明,滚到顶栏下面吸住之后才出现磨砂底,和顶栏连成一条。 +## 控制台作品详情头图 + +媒体库作品页与发现页 TMDB 详情共用 `MediaHero`(2026-10-07 方案 A): + +- 剧照铺在内容区顶部(左右伸到内容区的内边距,向上伸到返回链接后面),`.media-hero-backdrop` 让剧照上下和右缘渐隐成透明,露出页面底色与光斑,不做圆角卡片; +- 剧照上压一层页面底色的渐变(`--mat-hero-scrim-side`):左侧文字区近乎不透明、右侧露出剧照;窄屏海报与文字上下排,改为自上而下压底色(`--mat-hero-scrim-stack`); +- 文字用常规前景色 token,日夜同一结构,只是 `--background` 不同;没有背景图时不渲染剧照层,就是普通头部; +- 播出状态与作品状态 / Emby 徽标在同一行;操作按钮在外链行下方,「订阅」是唯一的实心主操作,其余描边。 + ## 其他 - 侧栏的选中项由滑块(`seg-thumb`)承担主要形态,导航文字与 hover 保持清晰;分组展开、滚动和路由变化都会影响滑块位置。 diff --git a/docs/design/ui-controls.md b/docs/design/ui-controls.md index 535bd1fd..d7aef53e 100644 --- a/docs/design/ui-controls.md +++ b/docs/design/ui-controls.md @@ -15,6 +15,7 @@ | 图标钮 | `