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
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-07 01:44:32 +08:00
1 parent 6549ab92c3
commit caf5a1f2f4
8 files changed
+370 -118

No files matched your search

@@ -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: createElement(StatusBadge, { tone: "success" }, "正常"),
});
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("展开");
});
});