merge: feat/detail-hero
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
ecc7eec7ef
18 files changed
+448
-72
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { simplifiedChineseTagline } from "@umt/adapters";
|
||||||
import {
|
import {
|
||||||
type AcquisitionPlan,
|
type AcquisitionPlan,
|
||||||
type AcquisitionSlot,
|
type AcquisitionSlot,
|
||||||
@@ -405,6 +406,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : null,
|
finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : null,
|
||||||
})),
|
})),
|
||||||
scrape: await latestEntityScrape(d, id),
|
scrape: await latestEntityScrape(d, id),
|
||||||
|
displayTagline: heroTagline(e!.metadata),
|
||||||
};
|
};
|
||||||
return detail;
|
return detail;
|
||||||
},
|
},
|
||||||
@@ -642,3 +644,11 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
{ tag: TAG },
|
{ tag: TAG },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 详情页头部的标语:只显示简体中文(见 simplifiedChineseTagline);存储的标语与语言不变 */
|
||||||
|
function heroTagline(metadata: unknown): string | null {
|
||||||
|
const m = (metadata ?? {}) as Record<string, { value?: unknown; language?: string | null } | undefined>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
fetchRichDetail,
|
fetchRichDetail,
|
||||||
fetchRichSeason,
|
fetchRichSeason,
|
||||||
type MetadataCandidate,
|
type MetadataCandidate,
|
||||||
|
simplifiedChineseTagline,
|
||||||
TmdbError,
|
TmdbError,
|
||||||
type TmdbRichDetail,
|
type TmdbRichDetail,
|
||||||
} from "@umt/adapters";
|
} from "@umt/adapters";
|
||||||
@@ -453,7 +454,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
|
|||||||
out.title = rich.title || out.title;
|
out.title = rich.title || out.title;
|
||||||
out.overview = rich.overview ?? out.overview;
|
out.overview = rich.overview ?? out.overview;
|
||||||
out.titleZh = rich.titleZh;
|
out.titleZh = rich.titleZh;
|
||||||
out.tagline = rich.tagline;
|
out.tagline = simplifiedChineseTagline(rich.tagline, rich.taglineLanguage, rich.originalLanguage);
|
||||||
out.genres = rich.genres;
|
out.genres = rich.genres;
|
||||||
out.voteAverage = rich.voteAverage;
|
out.voteAverage = rich.voteAverage;
|
||||||
out.voteCount = rich.voteCount;
|
out.voteCount = rich.voteCount;
|
||||||
|
|||||||
@@ -336,4 +336,32 @@ describe.skipIf(!hasPg)("LIBV-1 入库版本", () => {
|
|||||||
expect(response.body.data.downloads).toHaveLength(105);
|
expect(response.body.data.downloads).toHaveLength(105);
|
||||||
expect(response.body.data.droppedPlans).toHaveLength(51);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -33,6 +33,7 @@ function detail(
|
|||||||
overview: null,
|
overview: null,
|
||||||
overviewLanguage: null,
|
overviewLanguage: null,
|
||||||
tagline: null,
|
tagline: null,
|
||||||
|
taglineLanguage: null,
|
||||||
year: null,
|
year: null,
|
||||||
releaseDate: null,
|
releaseDate: null,
|
||||||
lastAirDate: null,
|
lastAirDate: null,
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ const detail = (entityType: "movie" | "tv", externalId: string): TmdbRichDetail
|
|||||||
overview: null,
|
overview: null,
|
||||||
overviewLanguage: null,
|
overviewLanguage: null,
|
||||||
tagline: null,
|
tagline: null,
|
||||||
|
taglineLanguage: null,
|
||||||
year: 2022,
|
year: 2022,
|
||||||
releaseDate: null,
|
releaseDate: null,
|
||||||
lastAirDate: null,
|
lastAirDate: null,
|
||||||
|
|||||||
+79
-1
@@ -352,6 +352,16 @@
|
|||||||
--mat-pill-secondary-bg: rgba(20, 22, 28, 0.03);
|
--mat-pill-secondary-bg: rgba(20, 22, 28, 0.03);
|
||||||
--mat-pill-secondary-bg-hover: rgba(20, 22, 28, 0.07);
|
--mat-pill-secondary-bg-hover: rgba(20, 22, 28, 0.07);
|
||||||
--mat-pill-secondary-border: rgba(20, 22, 28, 0.12);
|
--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):日间是托起的白片,夜间是薄白玻璃 */
|
/* 分段控件的滑块(侧栏当前项、SegmentedTabs):日间是托起的白片,夜间是薄白玻璃 */
|
||||||
--mat-seg-thumb-bg: rgba(255, 255, 255, 0.8);
|
--mat-seg-thumb-bg: rgba(255, 255, 255, 0.8);
|
||||||
--mat-seg-thumb-border: rgba(20, 22, 28, 0.06);
|
--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: rgba(255, 255, 255, 0.04);
|
||||||
--mat-pill-secondary-bg-hover: rgba(255, 255, 255, 0.1);
|
--mat-pill-secondary-bg-hover: rgba(255, 255, 255, 0.1);
|
||||||
--mat-pill-secondary-border: rgba(255, 255, 255, 0.15);
|
--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-bg: rgba(255, 255, 255, 0.08);
|
||||||
--mat-seg-thumb-border: rgba(255, 255, 255, 0.15);
|
--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);
|
--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);
|
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 同一手感 */
|
/* 按下回弹,与 .btn-glass 同一手感 */
|
||||||
.pill-primary:active,
|
.pill-primary:active,
|
||||||
.pill-secondary:active {
|
.pill-secondary:active,
|
||||||
|
.pill-solid:active {
|
||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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> = {}): 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<typeof MediaHero>[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("</div>", 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("展开");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type MediaCredit, tmdbImageUrl } from "@umt/contracts";
|
import { type MediaCredit, tmdbImageUrl } from "@umt/contracts";
|
||||||
import { ChevronDown, ExternalLink, ImageOff, Lock, PencilLine, Star, UserRound } from "lucide-react";
|
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 { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||||
import { CoverPlaceholder } from "~/components/library/parts.tsx";
|
import { CoverPlaceholder } from "~/components/library/parts.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
@@ -8,7 +8,7 @@ import { cn } from "~/lib/utils.ts";
|
|||||||
|
|
||||||
// 媒体详情(MD-1,对照 MP 媒体详情页):背景图 + 海报 + 标题 / 原名 / 年份 / 类型 / 评分 / 状态 / 集数 + 简介、
|
// 媒体详情(MD-1,对照 MP 媒体详情页):背景图 + 海报 + 标题 / 原名 / 年份 / 类型 / 评分 / 状态 / 集数 + 简介、
|
||||||
// 主创、季折叠与每集行。媒体库详情(本地作品)与仅 TMDB 的作品详情共用;数据由页面整理成下面的结构传入。
|
// 主创、季折叠与每集行。媒体库详情(本地作品)与仅 TMDB 的作品详情共用;数据由页面整理成下面的结构传入。
|
||||||
// 图片一律经图片代理(tmdbImageUrl);背景图上压 scrim 遮罩,文字用 on-media 色(不随日夜翻转)。
|
// 图片一律经图片代理(tmdbImageUrl);背景图渐隐到页面底色(--mat-hero-*),文字用常规前景色,随日夜翻转。
|
||||||
|
|
||||||
/** TMDB status 原文 → 界面用词 */
|
/** TMDB status 原文 → 界面用词 */
|
||||||
export const AIR_STATUS: Record<string, { label: string; tone: StatusTone }> = {
|
export const AIR_STATUS: Record<string, { label: string; tone: StatusTone }> = {
|
||||||
@@ -59,33 +59,44 @@ function runtimeText(min: number | null, kind: string): string | null {
|
|||||||
return kind === "movie" ? s : `单集 ${s}`;
|
return kind === "movie" ? s : `单集 ${s}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 可折叠的长文本:默认最多 lines 行,超出给「展开」 */
|
/** 可折叠的长文本:默认最多 lines 行;放不下时最后一行行尾渐隐,「展开」压在渐隐处(只有这一个入口,不另加省略号) */
|
||||||
export function ClampText({
|
export function ClampText({ text, lines = 3, className }: { text: string; lines?: 2 | 3 | 4; className?: string }) {
|
||||||
text,
|
|
||||||
lines = 3,
|
|
||||||
className,
|
|
||||||
onMedia = false,
|
|
||||||
}: {
|
|
||||||
text: string;
|
|
||||||
lines?: 2 | 3 | 4;
|
|
||||||
className?: string;
|
|
||||||
onMedia?: boolean;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const long = text.length > lines * 38;
|
const ref = useRef<HTMLParagraphElement>(null);
|
||||||
const clamp = lines === 2 ? "line-clamp-2" : lines === 3 ? "line-clamp-3" : "line-clamp-4";
|
// 首屏(含服务端渲染)按字数估计,挂载后按实际排版量:内容高度超过 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 (
|
return (
|
||||||
<div className={className}>
|
<div className={cn("relative", className)}>
|
||||||
<p className={cn("break-words whitespace-pre-line", !open && long && clamp)}>{text}</p>
|
<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 ? (
|
{long ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-0.5 cursor-pointer text-meta underline-offset-4 hover:underline",
|
"cursor-pointer text-meta font-medium text-foreground/80 underline-offset-4 hover:text-foreground hover:underline",
|
||||||
onMedia
|
clamped ? "absolute right-0 bottom-0 leading-[inherit]" : "mt-0.5",
|
||||||
? "text-on-media-tertiary hover:text-on-media-strong"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{open ? "收起" : "展开"}
|
{open ? "收起" : "展开"}
|
||||||
@@ -95,8 +106,12 @@ export function ClampText({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 头部:背景图(压暗)+ 海报 + 标题与事实行 + 简介 + 外部链接;badges 放标题下方,操作按钮由页面放在头部之后 */
|
/**
|
||||||
export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: ReactNode }) {
|
* 头部(方案 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 [bgFailed, setBgFailed] = useState(false);
|
||||||
const bg = bgFailed ? null : tmdbImageUrl(facts.backdropPath, "w1280");
|
const bg = bgFailed ? null : tmdbImageUrl(facts.backdropPath, "w1280");
|
||||||
const status = facts.airStatus ? (AIR_STATUS[facts.airStatus] ?? { label: facts.airStatus, tone: "neutral" }) : null;
|
const status = facts.airStatus ? (AIR_STATUS[facts.airStatus] ?? { label: facts.airStatus, tone: "neutral" }) : null;
|
||||||
@@ -120,73 +135,77 @@ export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: React
|
|||||||
facts.kind !== "movie" && facts.lastAirDate ? `最近播出 ${facts.lastAirDate}` : null,
|
facts.kind !== "movie" && facts.lastAirDate ? `最近播出 ${facts.lastAirDate}` : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return (
|
return (
|
||||||
<section className="relative isolate overflow-hidden rounded-2xl border border-border bg-scrim text-on-media">
|
<section className="relative isolate" data-backdrop={bg ? "image" : "none"}>
|
||||||
{bg ? (
|
{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
|
<img
|
||||||
src={bg}
|
src={bg}
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden
|
|
||||||
onError={() => setBgFailed(true)}
|
onError={() => setBgFailed(true)}
|
||||||
className="absolute inset-0 -z-10 h-full w-full object-cover object-top opacity-55"
|
className="absolute inset-0 h-full w-full object-cover object-top"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div aria-hidden className="absolute inset-0 -z-10 bg-gradient-to-r from-scrim/90 via-scrim/70 to-scrim/35" />
|
<div className={cn("flex flex-col gap-4 sm:flex-row sm:gap-6", bg && "pt-24 sm:pt-6")}>
|
||||||
<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
|
<CoverPlaceholder
|
||||||
title={facts.title}
|
title={facts.title}
|
||||||
kind={facts.kind}
|
kind={facts.kind}
|
||||||
posterPath={facts.posterPath}
|
posterPath={facts.posterPath}
|
||||||
className="h-40 w-28 self-start border-on-media-wash/20 shadow-lg sm:h-60 sm:w-40"
|
className="h-40 w-28 self-start shadow-lg sm:h-60 sm:w-40"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<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">
|
<h1 className="text-title-lg font-semibold tracking-tight break-words text-foreground sm:text-headline-sm">
|
||||||
{facts.title}
|
{facts.title}
|
||||||
{facts.year ? <span className="font-normal text-on-media-tertiary"> ({facts.year})</span> : null}
|
{facts.year ? <span className="font-normal text-muted-foreground"> ({facts.year})</span> : null}
|
||||||
</h1>
|
</h1>
|
||||||
{sub.length ? <p className="mt-1 text-body break-words text-on-media-secondary">{sub.join(" · ")}</p> : null}
|
{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-on-media-secondary">
|
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-caption text-muted-foreground">
|
||||||
{facts.voteAverage ? (
|
{facts.voteAverage ? (
|
||||||
<span className="inline-flex items-center gap-1 font-medium text-on-media-strong">
|
<span className="inline-flex items-center gap-1 font-medium text-foreground">
|
||||||
<Star className="h-3.5 w-3.5 fill-current" aria-hidden />
|
<Star className="h-3.5 w-3.5 fill-current" aria-hidden />
|
||||||
{facts.voteAverage.toFixed(1)}
|
{facts.voteAverage.toFixed(1)}
|
||||||
{facts.voteCount ? (
|
{facts.voteCount ? (
|
||||||
<span className="font-normal text-on-media-tertiary">({facts.voteCount} 人评分)</span>
|
<span className="font-normal text-muted-foreground">({facts.voteCount} 人评分)</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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}
|
{status ? <StatusBadge tone={status.tone}>{status.label}</StatusBadge> : null}
|
||||||
|
{badges}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{line.length ? <span>{line.join(" · ")}</span> : null}
|
{line.length ? <span>{line.join(" · ")}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{facts.genres.length ? (
|
{facts.genres.length ? (
|
||||||
<ul className="mt-2 flex flex-wrap gap-1.5" aria-label="类型">
|
<ul className="mt-2 flex flex-wrap gap-1.5" aria-label="类型">
|
||||||
{facts.genres.map((g) => (
|
{facts.genres.map((g) => (
|
||||||
<li
|
<li key={g} className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">
|
||||||
key={g}
|
|
||||||
className="rounded-full border border-on-media-wash/25 px-2 py-0.5 text-meta text-on-media-secondary"
|
|
||||||
>
|
|
||||||
{g}
|
{g}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
{badges ? <div className="mt-2 flex flex-wrap items-center gap-1.5">{badges}</div> : null}
|
{facts.tagline ? (
|
||||||
{facts.tagline ? <p className="mt-3 text-caption text-on-media-tertiary italic">{facts.tagline}</p> : null}
|
<p data-slot="media-hero-tagline" className="mt-3 text-caption text-muted-foreground italic">
|
||||||
|
{facts.tagline}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{facts.overview ? (
|
{facts.overview ? (
|
||||||
<ClampText
|
<ClampText
|
||||||
text={facts.overview}
|
text={facts.overview}
|
||||||
lines={4}
|
lines={4}
|
||||||
onMedia
|
className="mt-2 max-w-2xl text-caption leading-relaxed text-foreground/90"
|
||||||
className="mt-2 max-w-3xl text-caption leading-relaxed text-on-media"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2 text-meta text-on-media-tertiary">暂无简介</p>
|
<p className="mt-2 text-meta text-muted-foreground">暂无简介</p>
|
||||||
)}
|
)}
|
||||||
{dates.length || facts.externalLinks.length ? (
|
{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">
|
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
|
||||||
{dates.map((d) => (
|
{dates.map((d) => (
|
||||||
<span key={d}>{d}</span>
|
<span key={d}>{d}</span>
|
||||||
))}
|
))}
|
||||||
@@ -196,7 +215,7 @@ export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: React
|
|||||||
href={l.href}
|
href={l.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
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}
|
{l.label}
|
||||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||||
@@ -204,6 +223,11 @@ export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: React
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{actions ? (
|
||||||
|
<div data-slot="media-hero-actions" className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const buttonVariants = cva(
|
|||||||
variant: {
|
variant: {
|
||||||
// 主操作:半透明胶囊 + 沿边框流转的棱镜光谱。样式见 app.css 的 .pill-primary(--mat-pill-primary-*、--prism-*)
|
// 主操作:半透明胶囊 + 沿边框流转的棱镜光谱。样式见 app.css 的 .pill-primary(--mat-pill-primary-*、--prism-*)
|
||||||
default: "pill-primary",
|
default: "pill-primary",
|
||||||
|
// 实心主操作:只给详情页头图区的唯一主操作(订阅)用,见 app.css 的 .pill-solid(--mat-pill-solid-*)
|
||||||
|
solid: "pill-solid",
|
||||||
// 次操作:描边胶囊。outline 与 secondary 是同一个样子,保留两个名字只为兼容旧调用
|
// 次操作:描边胶囊。outline 与 secondary 是同一个样子,保留两个名字只为兼容旧调用
|
||||||
outline: "pill-secondary",
|
outline: "pill-secondary",
|
||||||
secondary: "pill-secondary",
|
secondary: "pill-secondary",
|
||||||
|
|||||||
@@ -1091,7 +1091,8 @@ function factsOf(d: LibraryDetail, entity: MediaEntityDetail | undefined): Media
|
|||||||
platform: strOf(m("platform")),
|
platform: strOf(m("platform")),
|
||||||
originCountry: Array.isArray(country) ? country.filter((x): x is string => typeof x === "string") : [],
|
originCountry: Array.isArray(country) ? country.filter((x): x is string => typeof x === "string") : [],
|
||||||
overview: strOf(m("overview")),
|
overview: strOf(m("overview")),
|
||||||
tagline: strOf(m("tagline")),
|
// 头部标语只显示简体中文,由服务端按取值语言 / 文字判断(存储的 metadata.tagline 不变)
|
||||||
|
tagline: d.displayTagline ?? null,
|
||||||
posterPath: posterPathOf(e.metadata),
|
posterPath: posterPathOf(e.metadata),
|
||||||
backdropPath: strOf(m("backdropPath")),
|
backdropPath: strOf(m("backdropPath")),
|
||||||
externalLinks: externalLinks({
|
externalLinks: externalLinks({
|
||||||
@@ -1116,7 +1117,7 @@ export default function LibraryDetailPage() {
|
|||||||
const back = (
|
const back = (
|
||||||
<Link
|
<Link
|
||||||
to={consolePath("library")}
|
to={consolePath("library")}
|
||||||
className="mb-4 inline-flex items-center gap-1 text-caption text-muted-foreground hover:text-foreground"
|
className="relative z-10 mb-4 inline-flex items-center gap-1 text-caption text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
媒体库
|
媒体库
|
||||||
@@ -1174,15 +1175,20 @@ export default function LibraryDetailPage() {
|
|||||||
{(ms.data?.items.length ?? 0) > 0 ? <EmbyBadge refOf={{ mediaEntityId: e.id }} /> : null}
|
{(ms.data?.items.length ?? 0) > 0 ? <EmbyBadge refOf={{ mediaEntityId: e.id }} /> : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
actions={
|
||||||
<div className="mt-4 mb-5 flex flex-wrap items-center gap-2">
|
<>
|
||||||
{e.status !== "merged" ? (
|
{e.status !== "merged" ? (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" onClick={() => setSubscribing(true)}>
|
{/* 头图区唯一的主操作:实心;其余保持描边 */}
|
||||||
|
<Button variant="solid" size="sm" onClick={() => setSubscribing(true)}>
|
||||||
<CalendarPlus />
|
<CalendarPlus />
|
||||||
订阅
|
订阅
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => launcher.open({ title: e.title, ref: `local:${e.id}` })}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => launcher.open({ title: e.title, ref: `local:${e.id}` })}
|
||||||
|
>
|
||||||
<PackageSearch />
|
<PackageSearch />
|
||||||
搜索资源
|
搜索资源
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1202,14 +1208,21 @@ export default function LibraryDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{/* 作品页操作区插槽(ADR-006):补字幕等功能模块的入口 */}
|
{/* 作品页操作区插槽(ADR-006):补字幕等功能模块的入口 */}
|
||||||
{entityActions.items({ entity: slotEntity }).map((a) => (
|
{entityActions.items({ entity: slotEntity }).map((a) => (
|
||||||
<Button key={a.id} variant="outline" size="sm" onClick={() => entityActions.open(a, { entity: slotEntity })}>
|
<Button
|
||||||
|
key={a.id}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => entityActions.open(a, { entity: slotEntity })}
|
||||||
|
>
|
||||||
{a.icon ? <a.icon /> : null}
|
{a.icon ? <a.icon /> : null}
|
||||||
{a.label}
|
{a.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
<EntityNamingButton entityId={e.id} kind={e.kind} />
|
<EntityNamingButton entityId={e.id} kind={e.kind} />
|
||||||
</div>
|
</>
|
||||||
<TechInfo className="-mt-3 mb-5" items={[{ label: "作品 ID", value: e.id, wide: true }]} />
|
}
|
||||||
|
/>
|
||||||
|
<TechInfo className="mt-5 mb-5" items={[{ label: "作品 ID", value: e.id, wide: true }]} />
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{credits.cast.length || credits.crew.length ? (
|
{credits.cast.length || credits.crew.length ? (
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function TmdbMediaPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="mb-4 inline-flex cursor-pointer items-center gap-1 text-caption text-muted-foreground hover:text-foreground"
|
className="relative z-10 mb-4 inline-flex cursor-pointer items-center gap-1 text-caption text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
返回
|
返回
|
||||||
@@ -128,32 +128,38 @@ function Detail({ d }: { d: TmdbMediaDetail }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MediaHero facts={facts} badges={<InventoryBadges inv={d.inventory} />} />
|
<MediaHero
|
||||||
<div className="mt-4 mb-5 flex flex-wrap gap-2">
|
facts={facts}
|
||||||
<Button onClick={() => setSubscribing(true)}>
|
badges={<InventoryBadges inv={d.inventory} />}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{/* 头图区唯一的主操作:实心;其余保持描边 */}
|
||||||
|
<Button variant="solid" size="sm" onClick={() => setSubscribing(true)}>
|
||||||
<CalendarPlus />
|
<CalendarPlus />
|
||||||
订阅
|
订阅
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => launcher.open({ title: d.title, ref })}>
|
<Button variant="outline" size="sm" onClick={() => launcher.open({ title: d.title, ref })}>
|
||||||
<PackageSearch />
|
<PackageSearch />
|
||||||
搜索资源
|
搜索资源
|
||||||
</Button>
|
</Button>
|
||||||
{d.localEntityId ? (
|
{d.localEntityId ? (
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link to={consolePath("library", d.localEntityId)}>
|
<Link to={consolePath("library", d.localEntityId)}>
|
||||||
<BookmarkPlus />
|
<BookmarkPlus />
|
||||||
媒体库详情
|
媒体库详情
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" onClick={createEntity} disabled={entity.isPending}>
|
<Button variant="outline" size="sm" onClick={createEntity} disabled={entity.isPending}>
|
||||||
<FolderPlus />
|
<FolderPlus />
|
||||||
{entity.isPending ? "加入中…" : "加入媒体库"}
|
{entity.isPending ? "加入中…" : "加入媒体库"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="mt-8 space-y-5">
|
||||||
{d.cast?.length || d.crew?.length ? (
|
{d.cast?.length || d.crew?.length ? (
|
||||||
<Panel title="主创">
|
<Panel title="主创">
|
||||||
<CreditsStrip cast={d.cast ?? []} crew={d.crew ?? []} />
|
<CreditsStrip cast={d.cast ?? []} crew={d.crew ?? []} />
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
- 选中态是静态的 `pill-selected`;
|
- 选中态是静态的 `pill-selected`;
|
||||||
- 分段控件的滑块是 `seg-thumb`。
|
- 分段控件的滑块是 `seg-thumb`。
|
||||||
|
|
||||||
全站没有实心按钮,也没有实心的选中块。
|
全站没有实心按钮,也没有实心的选中块。唯一例外是作品详情头图区(`MediaHero`)的「订阅」:压在剧照上时棱镜环看不清,改用主色实心 `pill-solid`(2026-10-07 定),其余页面不用。
|
||||||
5. **日夜两套,画面跟随页面**。大图、横向滚动行两侧的渐隐和压暗层都取页面底色,不硬切成黑色。播放页永远是夜间,落地页暂时固定夜间。
|
5. **日夜两套,画面跟随页面**。大图、横向滚动行两侧的渐隐和压暗层都取页面底色,不硬切成黑色。播放页永远是夜间,落地页暂时固定夜间。
|
||||||
6. **同一种信息用同一种结构**:
|
6. **同一种信息用同一种结构**:
|
||||||
- 套餐卡的零件在公共定价页和订阅服务页共用;
|
- 套餐卡的零件在公共定价页和订阅服务页共用;
|
||||||
@@ -96,6 +96,15 @@
|
|||||||
|
|
||||||
列表页(电影、剧集、收藏、合集、演职人员)不做色带:标题、筛选条和内容坐在同一张页面底上。吸顶筛选条 `StickyFilterBar` 平时透明,滚到顶栏下面吸住之后才出现磨砂底,和顶栏连成一条。
|
列表页(电影、剧集、收藏、合集、演职人员)不做色带:标题、筛选条和内容坐在同一张页面底上。吸顶筛选条 `StickyFilterBar` 平时透明,滚到顶栏下面吸住之后才出现磨砂底,和顶栏连成一条。
|
||||||
|
|
||||||
|
## 控制台作品详情头图
|
||||||
|
|
||||||
|
媒体库作品页与发现页 TMDB 详情共用 `MediaHero`(2026-10-07 方案 A):
|
||||||
|
|
||||||
|
- 剧照铺在内容区顶部(左右伸到内容区的内边距,向上伸到返回链接后面),`.media-hero-backdrop` 让剧照上下和右缘渐隐成透明,露出页面底色与光斑,不做圆角卡片;
|
||||||
|
- 剧照上压一层页面底色的渐变(`--mat-hero-scrim-side`):左侧文字区近乎不透明、右侧露出剧照;窄屏海报与文字上下排,改为自上而下压底色(`--mat-hero-scrim-stack`);
|
||||||
|
- 文字用常规前景色 token,日夜同一结构,只是 `--background` 不同;没有背景图时不渲染剧照层,就是普通头部;
|
||||||
|
- 播出状态与作品状态 / Emby 徽标在同一行;操作按钮在外链行下方,「订阅」是唯一的实心主操作,其余描边。
|
||||||
|
|
||||||
## 其他
|
## 其他
|
||||||
|
|
||||||
- 侧栏的选中项由滑块(`seg-thumb`)承担主要形态,导航文字与 hover 保持清晰;分组展开、滚动和路由变化都会影响滑块位置。
|
- 侧栏的选中项由滑块(`seg-thumb`)承担主要形态,导航文字与 hover 保持清晰;分组展开、滚动和路由变化都会影响滑块位置。
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
| 图标钮 | `<Button variant="outline" size="icon*">` | `btn-glass-icon` | 描边正圆 |
|
| 图标钮 | `<Button variant="outline" size="icon*">` | `btn-glass-icon` | 描边正圆 |
|
||||||
| 只有文字 / 图标 | `<Button variant="ghost">` | 各自的 ghost 行 | 悬停出淡底 |
|
| 只有文字 / 图标 | `<Button variant="ghost">` | 各自的 ghost 行 | 悬停出淡底 |
|
||||||
| 危险操作 | `<Button variant="destructive">` | — | 淡红底红字胶囊 |
|
| 危险操作 | `<Button variant="destructive">` | — | 淡红底红字胶囊 |
|
||||||
|
| 头图区主操作(例外) | `<Button variant="solid">` | — | 主色实心胶囊(`pill-solid`),只用于作品详情头图区的「订阅」 |
|
||||||
|
|
||||||
一屏(或一张卡片)只放一个主操作,并列的其余动作退为描边;前置条件没满足时,真正该做的那一步是主操作(例如团队页没有团队套餐时,「创建团队套餐」是主操作,「添加成员」退为描边)。
|
一屏(或一张卡片)只放一个主操作,并列的其余动作退为描边;前置条件没满足时,真正该做的那一步是主操作(例如团队页没有团队套餐时,「创建团队套餐」是主操作,「添加成员」退为描边)。
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export interface TmdbRichDetail {
|
|||||||
overview: string | null;
|
overview: string | null;
|
||||||
overviewLanguage: string | null;
|
overviewLanguage: string | null;
|
||||||
tagline: string | null;
|
tagline: string | null;
|
||||||
|
/** 标语实际取到的语言(链内语言标签;落空为 null) */
|
||||||
|
taglineLanguage: string | null;
|
||||||
year: number | null;
|
year: number | null;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
lastAirDate: string | null;
|
lastAirDate: string | null;
|
||||||
@@ -275,6 +277,7 @@ export async function fetchRichDetail(
|
|||||||
overview: overview?.value ?? null,
|
overview: overview?.value ?? null,
|
||||||
overviewLanguage: overview?.language ?? null,
|
overviewLanguage: overview?.language ?? null,
|
||||||
tagline: tagline?.value ?? null,
|
tagline: tagline?.value ?? null,
|
||||||
|
taglineLanguage: tagline?.language ?? null,
|
||||||
year: yearOf(date),
|
year: yearOf(date),
|
||||||
releaseDate: date,
|
releaseDate: date,
|
||||||
lastAirDate: s(j.last_air_date),
|
lastAirDate: s(j.last_air_date),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
chineseScriptOf,
|
chineseScriptOf,
|
||||||
createTmdbClient,
|
createTmdbClient,
|
||||||
fetchRichDetail,
|
fetchRichDetail,
|
||||||
|
simplifiedChineseTagline,
|
||||||
textMatchesLanguage,
|
textMatchesLanguage,
|
||||||
untaggedTextAllowed,
|
untaggedTextAllowed,
|
||||||
} from "./index.ts";
|
} from "./index.ts";
|
||||||
@@ -144,3 +145,52 @@ describe("多语言保留规则:TMDB 详情", () => {
|
|||||||
expect(rich.titleLanguage).toBe("zh-CN");
|
expect(rich.titleLanguage).toBe("zh-CN");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("详情页标语只显示简体中文", () => {
|
||||||
|
it("取值语言是简中(zh-CN / zh-SG / zh)显示;繁中(zh-TW / zh-HK)和非中文不显示,不退到繁中", () => {
|
||||||
|
expect(simplifiedChineseTagline("人类的勇气就是人类的赞歌", "zh-CN", "en")).toBe("人类的勇气就是人类的赞歌");
|
||||||
|
expect(simplifiedChineseTagline("一场冒险", "zh-SG", "en")).toBe("一场冒险");
|
||||||
|
expect(simplifiedChineseTagline("一场冒险", "zh", "en")).toBe("一场冒险");
|
||||||
|
expect(simplifiedChineseTagline("一場冒險", "zh-TW", "en")).toBeNull();
|
||||||
|
expect(simplifiedChineseTagline("一场冒险", "zh-HK", "en")).toBeNull();
|
||||||
|
expect(simplifiedChineseTagline("Mankind was born on Earth.", "en-US", "en")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("语言未知(媒体库存的标语)按文字判断:繁体特有字、假名、谚文、没有汉字的都不显示", () => {
|
||||||
|
expect(simplifiedChineseTagline("我们注定要离开地球", null, "en")).toBe("我们注定要离开地球");
|
||||||
|
expect(simplifiedChineseTagline("我們註定要離開地球", null, "en")).toBeNull();
|
||||||
|
expect(simplifiedChineseTagline("沙丘", null, "en")).toBe("沙丘");
|
||||||
|
expect(simplifiedChineseTagline("君の名は。", null, "ja")).toBeNull();
|
||||||
|
expect(simplifiedChineseTagline("Mankind was born on Earth.", null, "en")).toBeNull();
|
||||||
|
expect(simplifiedChineseTagline(" ", null, "en")).toBeNull();
|
||||||
|
expect(simplifiedChineseTagline(null, "zh-CN", "en")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("原语言为中文(zh / 粤语 cn)的作品按简中处理", () => {
|
||||||
|
expect(simplifiedChineseTagline("我們註定要離開地球", null, "zh")).toBe("我們註定要離開地球");
|
||||||
|
expect(simplifiedChineseTagline("一場冒險", "zh-HK", "cn")).toBe("一場冒險");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("富详情带回标语的取值语言:简中没有、回退到繁中时取值不变(存储照旧),展示层丢掉", async () => {
|
||||||
|
const client = {
|
||||||
|
getJson: async <T>() =>
|
||||||
|
({
|
||||||
|
id: 1,
|
||||||
|
name: "冒险",
|
||||||
|
original_name: "Adventure",
|
||||||
|
original_language: "en",
|
||||||
|
origin_country: ["US"],
|
||||||
|
tagline: "",
|
||||||
|
translations: {
|
||||||
|
translations: [
|
||||||
|
{ iso_639_1: "zh", iso_3166_1: "CN", data: { name: "冒险", overview: "简介", tagline: "" } },
|
||||||
|
{ iso_639_1: "zh", iso_3166_1: "TW", data: { name: "冒險", overview: "簡介", tagline: "一場冒險" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}) as T,
|
||||||
|
};
|
||||||
|
const rich = await fetchRichDetail(client, "tv", 1, ["zh-CN", "zh-TW", "en-US"]);
|
||||||
|
expect(rich).toMatchObject({ tagline: "一場冒險", taglineLanguage: "zh-TW" });
|
||||||
|
expect(simplifiedChineseTagline(rich.tagline, rich.taglineLanguage, rich.originalLanguage)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -147,6 +147,32 @@ export function chineseScriptOf(text: string): "zh-Hans" | "zh-Hant" | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 原语言是否中文(TMDB 用 zh,粤语作品是 cn) */
|
||||||
|
const isChineseOriginal = (lang: string | null | undefined) => {
|
||||||
|
const p = primaryOf(lang);
|
||||||
|
return p === "zh" || p === "cn";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情页头部展示的标语:只要简体中文,没有就不显示(不退到繁中或其它语言)。只决定展示,不改存储的取值。
|
||||||
|
* - language 是取值语言(TMDB 富详情的 taglineLanguage;媒体库存的标语语言多为 null,只能看文字);
|
||||||
|
* - 原语言为中文的作品按简中处理(TMDB 主记录就是中文原文);
|
||||||
|
* - 要求含汉字且不含假名 / 谚文;语言标为繁中、非中文,或文字明显是繁体特有字的,不显示。
|
||||||
|
*/
|
||||||
|
export function simplifiedChineseTagline(
|
||||||
|
text: string | null | undefined,
|
||||||
|
language: string | null | undefined,
|
||||||
|
originalLanguage: string | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
const t = text?.trim();
|
||||||
|
if (!t || !HAN.test(t) || KANA.test(t) || HANGUL.test(t)) return null;
|
||||||
|
if (isChineseOriginal(originalLanguage)) return t;
|
||||||
|
if (language) {
|
||||||
|
if (primaryOf(language) !== "zh" || chineseVariant(language) === "zh-Hant") return null;
|
||||||
|
}
|
||||||
|
return chineseScriptOf(t) === "zh-Hant" ? null : t;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标题文字与语言标签是否相符:zh 要求汉字且不含假名 / 谚文(带地区或 Hans / Hant 时再看简繁特有字,明显是另一种的不符);
|
* 标题文字与语言标签是否相符:zh 要求汉字且不含假名 / 谚文(带地区或 Hans / Hant 时再看简繁特有字,明显是另一种的不符);
|
||||||
* ja 要求假名或汉字;ko 要求谚文;泰 / 俄 / 阿 / 希伯来 / 印地等按各自文字;`-Latn` 与其余语言按拉丁字母且不含非拉丁强文字。
|
* ja 要求假名或汉字;ko 要求谚文;泰 / 俄 / 阿 / 希伯来 / 印地等按各自文字;`-Latn` 与其余语言按拉丁字母且不含非拉丁强文字。
|
||||||
|
|||||||
@@ -553,6 +553,8 @@ export const LibraryDetail = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
/** SCR-1:最近一次刮削(写 NFO 与图片);没刮削过为 null */
|
/** SCR-1:最近一次刮削(写 NFO 与图片);没刮削过为 null */
|
||||||
scrape: LibraryScrapeState.nullable().optional(),
|
scrape: LibraryScrapeState.nullable().optional(),
|
||||||
|
/** 头部展示的标语:只取简体中文(原语言为中文的按简中),没有为 null;存储的 metadata.tagline 不变 */
|
||||||
|
displayTagline: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
export type LibraryDetail = z.infer<typeof LibraryDetail>;
|
export type LibraryDetail = z.infer<typeof LibraryDetail>;
|
||||||
|
|
||||||
|
|||||||
@@ -426,6 +426,7 @@ export const TmdbMediaDetail = z.object({
|
|||||||
// MD-1:按语言回退链取的详情字段(媒体详情页与媒体库详情共用展示)
|
// MD-1:按语言回退链取的详情字段(媒体详情页与媒体库详情共用展示)
|
||||||
/** 中文名:回退链中第一个中文值(zh-CN 优先);没有为 null */
|
/** 中文名:回退链中第一个中文值(zh-CN 优先);没有为 null */
|
||||||
titleZh: z.string().nullable().optional(),
|
titleZh: z.string().nullable().optional(),
|
||||||
|
/** 标语(展示用):只取简体中文(原语言为中文的按简中),没有为 null,不退到繁中 */
|
||||||
tagline: z.string().nullable().optional(),
|
tagline: z.string().nullable().optional(),
|
||||||
genres: z.array(z.string()).optional(),
|
genres: z.array(z.string()).optional(),
|
||||||
voteAverage: z.number().nullable().optional(),
|
voteAverage: z.number().nullable().optional(),
|
||||||
|
|||||||
Reference in new issue
Block a user