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

+79 -1
View File
@@ -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);
}
@@ -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("展开");
});
});
+80 -56
View File
@@ -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<string, { label: string; tone: StatusTone }> = {
@@ -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<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={className}>
<p className={cn("break-words whitespace-pre-line", !open && long && clamp)}>{text}</p>
<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(
"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",
"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 ? "收起" : "展开"}
@@ -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 bg = bgFailed ? null : tmdbImageUrl(facts.backdropPath, "w1280");
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,
].filter(Boolean);
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 ? (
<img
src={bg}
alt=""
<div
aria-hidden
onError={() => setBgFailed(true)}
className="absolute inset-0 -z-10 h-full w-full object-cover object-top opacity-55"
/>
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 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">
<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 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">
<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.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>
{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">
{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-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 />
{facts.voteAverage.toFixed(1)}
{facts.voteCount ? (
<span className="font-normal text-on-media-tertiary">({facts.voteCount} 人评分)</span>
<span className="font-normal text-muted-foreground">({facts.voteCount} 人评分)</span>
) : null}
</span>
) : null}
{status ? <StatusBadge tone={status.tone}>{status.label}</StatusBadge> : 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-on-media-wash/25 px-2 py-0.5 text-meta text-on-media-secondary"
>
<li key={g} className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">
{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.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}
onMedia
className="mt-2 max-w-3xl text-caption leading-relaxed text-on-media"
className="mt-2 max-w-2xl text-caption leading-relaxed text-foreground/90"
/>
) : (
<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 ? (
<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) => (
<span key={d}>{d}</span>
))}
@@ -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}
<ExternalLink className="h-3 w-3" aria-hidden />
@@ -204,6 +223,11 @@ export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: React
))}
</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>
+2
View File
@@ -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",
+46 -34
View File
@@ -1117,7 +1117,7 @@ export default function LibraryDetailPage() {
const back = (
<Link
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" />
媒体库
@@ -1175,42 +1175,54 @@ export default function LibraryDetailPage() {
{(ms.data?.items.length ?? 0) > 0 ? <EmbyBadge refOf={{ mediaEntityId: e.id }} /> : null}
</>
}
/>
<div className="mt-4 mb-5 flex flex-wrap items-center gap-2">
{e.status !== "merged" ? (
actions={
<>
<Button size="sm" onClick={() => setSubscribing(true)}>
<CalendarPlus />
订阅
</Button>
<Button variant="outline" size="sm" onClick={() => launcher.open({ title: e.title, ref: `local:${e.id}` })}>
<PackageSearch />
搜索资源
{e.status !== "merged" ? (
<>
{/* 头图区唯一的主操作:实心;其余保持描边 */}
<Button variant="solid" size="sm" onClick={() => setSubscribing(true)}>
<CalendarPlus />
订阅
</Button>
<Button
variant="outline"
size="sm"
onClick={() => launcher.open({ title: e.title, ref: `local:${e.id}` })}
>
<PackageSearch />
搜索资源
</Button>
</>
) : null}
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}>
<CalendarClock />
查看订阅
</Link>
</Button>
{e.status !== "merged" ? (
<Button variant="outline" size="sm" onClick={() => setMerging(true)}>
<GitMerge />
合并作品
</Button>
) : null}
{/* 作品页操作区插槽(ADR-006):补字幕等功能模块的入口 */}
{entityActions.items({ entity: slotEntity }).map((a) => (
<Button
key={a.id}
variant="outline"
size="sm"
onClick={() => entityActions.open(a, { entity: slotEntity })}
>
{a.icon ? <a.icon /> : null}
{a.label}
</Button>
))}
<EntityNamingButton entityId={e.id} kind={e.kind} />
</>
) : null}
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}>
<CalendarClock />
查看订阅
</Link>
</Button>
{e.status !== "merged" ? (
<Button variant="outline" size="sm" onClick={() => setMerging(true)}>
<GitMerge />
合并作品
</Button>
) : null}
{/* 作品页操作区插槽(ADR-006):补字幕等功能模块的入口 */}
{entityActions.items({ entity: slotEntity }).map((a) => (
<Button key={a.id} variant="outline" size="sm" onClick={() => entityActions.open(a, { entity: slotEntity })}>
{a.icon ? <a.icon /> : null}
{a.label}
</Button>
))}
<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">
{credits.cast.length || credits.crew.length ? (
@@ -41,7 +41,7 @@ export default function TmdbMediaPage() {
<button
type="button"
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" />
返回
@@ -128,32 +128,38 @@ function Detail({ d }: { d: TmdbMediaDetail }) {
return (
<>
<MediaHero facts={facts} badges={<InventoryBadges inv={d.inventory} />} />
<div className="mt-4 mb-5 flex flex-wrap gap-2">
<Button onClick={() => setSubscribing(true)}>
<CalendarPlus />
订阅
</Button>
<Button variant="outline" onClick={() => launcher.open({ title: d.title, ref })}>
<PackageSearch />
搜索资源
</Button>
{d.localEntityId ? (
<Button asChild variant="outline">
<Link to={consolePath("library", d.localEntityId)}>
<BookmarkPlus />
媒体库详情
</Link>
</Button>
) : (
<Button variant="outline" onClick={createEntity} disabled={entity.isPending}>
<FolderPlus />
{entity.isPending ? "加入中…" : "加入媒体库"}
</Button>
)}
</div>
<MediaHero
facts={facts}
badges={<InventoryBadges inv={d.inventory} />}
actions={
<>
{/* 头图区唯一的主操作:实心;其余保持描边 */}
<Button variant="solid" size="sm" onClick={() => setSubscribing(true)}>
<CalendarPlus />
订阅
</Button>
<Button variant="outline" size="sm" onClick={() => launcher.open({ title: d.title, ref })}>
<PackageSearch />
搜索资源
</Button>
{d.localEntityId ? (
<Button asChild variant="outline" size="sm">
<Link to={consolePath("library", d.localEntityId)}>
<BookmarkPlus />
媒体库详情
</Link>
</Button>
) : (
<Button variant="outline" size="sm" onClick={createEntity} disabled={entity.isPending}>
<FolderPlus />
{entity.isPending ? "加入中…" : "加入媒体库"}
</Button>
)}
</>
}
/>
<div className="space-y-5">
<div className="mt-8 space-y-5">
{d.cast?.length || d.crew?.length ? (
<Panel title="主创">
<CreditsStrip cast={d.cast ?? []} crew={d.crew ?? []} />