style(detail): redesign video detail page with ambient hero and chip meta

- Add .vd-ambient hero layer using poster as blurred backdrop with warm
  orange radial glow, fading to page color
- Wrap player in .vd-player-wrap with gradient border + soft top halo +
  deep shadow to make it the visual focal point
- Replace dotted meta list with .vd-meta__chip pills (drive-tone colors
  for source, accent for HD) plus a .vd-author chip with initial avatar
- Toolbar becomes a floating glass card; like/dislike pill shows accent
  ring on focus, hide button reveals danger color on hover only
- Merge description and tags into a single .vd-info card with section
  headers; tag editor becomes inline popover
- Recommended rail header gets glowing gradient bar + subtitle with
  count; items lift and zoom slightly on hover
- Mobile: player edge-to-edge, like/dislike split main row, hide
  collapses to 44x44 icon button, ambient height/blur scaled down
- Sync rewrite notes to plan section 14.5
This commit is contained in:
nianzhibai
2026-05-21 19:20:43 +08:00
parent 308227c031
commit b02a4a4100
7 changed files with 654 additions and 334 deletions
+9 -1
View File
@@ -37,7 +37,15 @@ export function RecommendedRail({ videos }: Props) {
return (
<aside className="vd-rail" aria-label="推荐视频">
<div className="vd-rail__head"></div>
<header className="vd-rail__head">
<span className="vd-rail__head-bar" aria-hidden="true" />
<div className="vd-rail__head-text">
<h2 className="vd-rail__head-title"></h2>
<span className="vd-rail__head-sub">
· {videos.length}
</span>
</div>
</header>
<ul className="vd-rail__list">
{videos.map((v) => (
<RecommendedItem key={v.id} video={v} />
+17 -12
View File
@@ -10,13 +10,14 @@ type Props = {
};
/**
* 视频操作
* - 点赞 + 点踩合并成一个胶囊(中间用分隔线),两侧都显示计数
* - "不再显示" 单独成一个独立按钮,靠右放置时由父级处理
* 视频操作工具条
* - 整体是一张浮起的圆角玻璃卡,比上一版的横线分隔更"成体"
* - 点赞 + 点踩组成一个胶囊(中间一道竖线分隔),两侧分别带计数
* - "不再显示" 单独成一个次要按钮,hover 时露出 danger 色。
*
* 注意:当前后端只有点赞接口(POST /api/video/:id/like),
* 点踩仅在前端记录,不会持久化。等后端补上 dislike 接口时,把
* handleDislike 里的本地 state 升级成网络请求即可
* 功能没变:
* - 后端只有点赞接口(POST /api/video/:id/like),点踩仅本地 state。
* - 失败回滚已经处理
*/
export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
const [likes, setLikes] = useState(video.likes ?? 0);
@@ -30,7 +31,7 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
setLiked(true);
setLikes((n) => n + 1);
setBursting(true);
window.setTimeout(() => setBursting(false), 280);
window.setTimeout(() => setBursting(false), 320);
if (disliked) {
setDisliked(false);
@@ -68,27 +69,31 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
}
return (
<div className="vd-actions">
<div className="vd-actions" role="toolbar" aria-label="视频操作">
<div className="vd-actions__group" role="group" aria-label="点赞和点踩">
<button
type="button"
className={`vd-actions__pill vd-actions__like ${liked ? "is-active" : ""} ${bursting ? "is-bursting" : ""}`}
className={`vd-actions__pill vd-actions__like${
liked ? " is-active" : ""
}${bursting ? " is-bursting" : ""}`}
onClick={handleLike}
aria-pressed={liked}
aria-label="点赞"
>
<ThumbsUp size={16} fill={liked ? "currentColor" : "none"} />
<ThumbsUp size={18} fill={liked ? "currentColor" : "none"} />
<span className="vd-actions__count">{formatCount(likes)}</span>
</button>
<span className="vd-actions__divider" aria-hidden="true" />
<button
type="button"
className={`vd-actions__pill vd-actions__dislike ${disliked ? "is-active" : ""}`}
className={`vd-actions__pill vd-actions__dislike${
disliked ? " is-active" : ""
}`}
onClick={handleDislike}
aria-pressed={disliked}
aria-label="点踩"
>
<ThumbsDown size={16} fill={disliked ? "currentColor" : "none"} />
<ThumbsDown size={18} fill={disliked ? "currentColor" : "none"} />
<span className="vd-actions__count">{formatCount(dislikes)}</span>
</button>
</div>
+41 -26
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Plus, X } from "lucide-react";
import { Hash, Pencil, X } from "lucide-react";
import type { TagItem, VideoDetail } from "@/types";
type Props = {
@@ -10,9 +10,14 @@ type Props = {
};
/**
* 视频信息板块:
* - 简介:默认折叠 3 行,整块可点击展开/收起简介为空时不渲染。
* - 标签:横向 chip 列表 + 一个圆形 "+" 按钮调出编辑器;编辑器内仅展示候选标签的 checkbox 网格
* 简介 + 标签合并卡。
* - 上半部分是简介:默认折叠 3 行,整块可点击展开/收起简介为空时不渲染。
* - 下半部分是标签:横向 chip 列表 + 一个"编辑"按钮调出标签编辑器
*
* 视觉上和上一版的"两张分离卡"相比,整体感更强:
* - 一张大卡内分两个小区块,区块之间用细分隔线
* - 简介区块加 "简介" 标题前缀
* - 标签区块加 # 图标暗示
*/
export function VideoInfoPanel({
video,
@@ -65,8 +70,8 @@ export function VideoInfoPanel({
<section className="vd-info" aria-label="视频信息">
{showDescription && (
<div
className={`vd-info__desc ${descExpanded ? "is-expanded" : ""} ${
descriptionLong ? "is-clickable" : ""
className={`vd-info__desc${descExpanded ? " is-expanded" : ""}${
descriptionLong ? " is-clickable" : ""
}`}
role={descriptionLong ? "button" : undefined}
tabIndex={descriptionLong ? 0 : undefined}
@@ -79,25 +84,24 @@ export function VideoInfoPanel({
}
}}
>
<div className="vd-info__section-head">
<span className="vd-info__section-title"></span>
{descriptionLong && (
<span className="vd-info__desc-toggle">
{descExpanded ? "收起" : "展开"}
</span>
)}
</div>
<p className="vd-info__desc-text">{description}</p>
{descriptionLong && (
<span className="vd-info__desc-toggle">
{descExpanded ? "收起" : "展开"}
</span>
)}
</div>
)}
<div className="vd-info__tags-row">
<div className="vd-info__tags">
{tags.length === 0 && (
<span className="vd-info__tags-empty"></span>
)}
{tags.map((t) => (
<span key={t} className="vd-tag">
#{t}
</span>
))}
<div className="vd-info__tags">
<div className="vd-info__section-head">
<span className="vd-info__section-title">
<Hash size={14} aria-hidden="true" />
</span>
{onTagsChange && (
<button
type="button"
@@ -105,11 +109,22 @@ export function VideoInfoPanel({
onClick={openTagEditor}
aria-label="编辑标签"
>
<Plus size={14} />
<Pencil size={13} />
<span></span>
</button>
)}
</div>
<div className="vd-info__tags-list">
{tags.length === 0 ? (
<span className="vd-info__tags-empty"></span>
) : (
tags.map((t) => (
<span key={t} className="vd-tag">
#{t}
</span>
))
)}
</div>
</div>
{editingTags && (
@@ -136,7 +151,9 @@ export function VideoInfoPanel({
<button
type="button"
key={tag.id}
className={`vd-tag-editor__chip ${checked ? "is-active" : ""}`}
className={`vd-tag-editor__chip${
checked ? " is-active" : ""
}`}
onClick={() =>
setDraftTags((prev) =>
prev.includes(tag.label)
@@ -147,9 +164,7 @@ export function VideoInfoPanel({
aria-pressed={checked}
>
<span>{tag.label}</span>
{typeof tag.count === "number" && (
<em>{tag.count}</em>
)}
{typeof tag.count === "number" && <em>{tag.count}</em>}
</button>
);
})
+40 -50
View File
@@ -6,9 +6,12 @@ type Props = {
};
/**
* 详情页标题块:标题 + 一行流式元信息(meta
* 元信息按照「主→次」的顺序:作者 / 来源网盘 / 画质 / 时长 / 浏览量 / 发布时间。
* 缺省字段会被自动跳过,不会留下空的分隔点。
* 详情页标题块。
*
* 视觉:
* - 标题:大、粗、最高两行
* - meta:作者首字头像 + 名字 + 一组小胶囊(来源、画质、时长、观看数、发布时间)
* 每个胶囊有自己的语义色彩,避免传统 "·" 分隔列表的列表感。
*/
export function VideoMetaHeader({ video }: Props) {
const author = (video.author ?? "").trim();
@@ -16,64 +19,51 @@ export function VideoMetaHeader({ video }: Props) {
const quality = (video.quality ?? "").trim();
const duration = (video.duration ?? "").trim();
const published = (video.publishedAt ?? "").trim();
const parts: { key: string; node: React.ReactNode; tone?: "accent" }[] = [];
if (author) {
parts.push({
key: "author",
node: <span className="vd-meta__author">{author}</span>,
});
}
if (source) {
parts.push({
key: "source",
node: (
<span
className="vd-meta__source"
data-kind={sourceKindFromLabel(source)}
>
{source}
</span>
),
});
}
if (quality) {
parts.push({ key: "quality", node: <>{quality}</> });
}
if (duration) {
parts.push({ key: "duration", node: <>{duration}</> });
}
parts.push({
key: "views",
node: <>{formatCount(video.views)} </>,
});
if (published) {
parts.push({ key: "published", node: <>{published}</> });
}
const sourceKind = sourceKindFromLabel(source);
return (
<header className="vd-header">
<h1 className="vd-header__title" title={video.title}>
{video.title}
</h1>
<ul className="vd-meta" aria-label="视频信息">
{parts.map((p, i) => (
<li key={p.key} className="vd-meta__item">
{p.node}
{i < parts.length - 1 && (
<span className="vd-meta__sep" aria-hidden="true">
·
</span>
)}
<div className="vd-header__row">
{author && (
<div className="vd-author" aria-label={`作者 ${author}`}>
<span className="vd-author__avatar" aria-hidden="true">
{author.slice(0, 1)}
</span>
<span className="vd-author__name">{author}</span>
</div>
)}
<ul className="vd-meta" aria-label="视频信息">
{source && (
<li className="vd-meta__chip" data-tone={sourceKind || "neutral"}>
<span className="vd-meta__dot" aria-hidden="true" />
{source}
</li>
)}
{quality && (
<li
className="vd-meta__chip"
data-tone={quality.toUpperCase() === "HD" ? "accent" : "neutral"}
>
{quality}
</li>
)}
{duration && <li className="vd-meta__chip">{duration}</li>}
<li className="vd-meta__chip">
<strong>{formatCount(video.views)}</strong>
</li>
))}
</ul>
{published && <li className="vd-meta__chip">{published}</li>}
</ul>
</div>
</header>
);
}
// 根据 sourceLabel 识别网盘类型,给来源徽标上色。复制自 VideoCard,避免循环依赖
// 根据 sourceLabel 识别网盘类型,用于胶囊配色
function sourceKindFromLabel(label: string): string {
const value = label.toLowerCase();
if (value.includes("夸克") || value.includes("quark")) return "quark";
+46 -28
View File
@@ -85,11 +85,14 @@ export default function VideoDetailPage() {
if (loading) {
return (
<AppShell>
<div className="container page-section">
<div className="vd-skeleton">
<div className="vd-skeleton__player" />
<div className="vd-skeleton__title" />
<div className="vd-skeleton__meta" />
<div className="vd-page">
<div className="vd-ambient" aria-hidden="true" />
<div className="container vd-page__inner">
<div className="vd-skeleton">
<div className="vd-skeleton__player" />
<div className="vd-skeleton__title" />
<div className="vd-skeleton__meta" />
</div>
</div>
</div>
</AppShell>
@@ -99,8 +102,10 @@ export default function VideoDetailPage() {
if (!detail) {
return (
<AppShell>
<div className="container page-section">
<div className="vd-empty"></div>
<div className="vd-page">
<div className="container vd-page__inner">
<div className="vd-empty"></div>
</div>
</div>
</AppShell>
);
@@ -108,37 +113,50 @@ export default function VideoDetailPage() {
return (
<AppShell>
<div className="container page-section vd-page">
<div className="vd-layout">
<div className="vd-main" ref={detailTopRef}>
<div className="vd-player">
<VideoPlayer
src={detail.videoSrc}
poster={detail.poster}
title={detail.title}
onFirstPlay={handleFirstPlay}
/>
</div>
<div className="vd-page">
{/* Ambient 背景层:用海报作模糊底色,叠加渐变过渡到页面背景 */}
<div
className="vd-ambient"
aria-hidden="true"
style={{
backgroundImage: detail.poster
? `url(${detail.poster})`
: undefined,
}}
/>
<VideoMetaHeader video={detail} />
<div className="container vd-page__inner">
<div className="vd-layout">
<div className="vd-main" ref={detailTopRef}>
<div className="vd-player-wrap">
<div className="vd-player">
<VideoPlayer
src={detail.videoSrc}
poster={detail.poster}
title={detail.title}
onFirstPlay={handleFirstPlay}
/>
</div>
</div>
<VideoMetaHeader video={detail} />
<div className="vd-toolbar">
<VideoActions
video={detail}
onHideVideo={handleHideVideo}
hideSaving={hideSaving}
/>
<VideoInfoPanel
video={detail}
availableTags={tags}
tagSaving={tagSaving}
onTagsChange={handleTagsChange}
/>
</div>
<VideoInfoPanel
video={detail}
availableTags={tags}
tagSaving={tagSaving}
onTagsChange={handleTagsChange}
/>
<RecommendedRail videos={detail.relatedVideos} />
</div>
<RecommendedRail videos={detail.relatedVideos} />
</div>
</div>
</AppShell>
File diff suppressed because it is too large Load Diff
+30
View File
@@ -1292,6 +1292,36 @@ src/
任何一项都可以在小改动内回归 plan 原设计,等统一确认后再动。
### 14.5 视频详情/播放页视觉重写(2026-05-21
第一版详情页视觉过于"列表化",标题 + 一行 `·` 分隔 meta + 上下灰线工具条 + 两张分离的简介/标签卡,缺少视觉重心和氛围感。本次按"沉浸感 + 信息层级清晰"的方向重写,仅改 UI,不动数据流和后端接口。
变化点:
- **Hero ambient 背景**:详情页根加 `.vd-ambient` 层,把当前视频海报作为模糊底色(桌面 48 px bluropacity 0.42,高度 520 px;平板/手机依次降到 36/28 px blur 和 380/280 px 高度),叠加暖橙径向光晕和"过渡到页面色"的纵向渐变,仅顶部一段,不染整页。`.vd-page` 设为 `isolation: isolate` 形成新的 stacking context。
- **播放器外光环**:播放器外加 `.vd-player-wrap`,1 px 暖橙渐变描边 + 顶部柔光 + 24/72 重阴影;移动端去除外圈和圆角,让播放器顶到容器边缘。
- **标题升级**:从 `font-2xl` 升到 `font-3xl`(手机端依次降到 xl/lg),加文字阴影提升在 ambient 上的可读性。
- **作者 + meta 重组**:删除原 `·` 分隔列表。新增 `.vd-author`(首字大写圆形渐变头像 + 名字胶囊)和 `.vd-meta__chip` 列表(来源、画质、时长、观看数、发布时间)。每个胶囊有自己的 `data-tone``accent` 用于 HD;网盘 tone`quark / p115 / pikpak / wopan / onedrive`)走对应品牌色。
- **操作工具条**:从"上下灰线"改为整体浮起的玻璃卡(毛玻璃 `backdrop-filter: blur(12px)` + 1 px 描边 + 阴影)。点赞 + 点踩组合胶囊在 hover/focus 时露出 accent 描边和 `accent-softer` 光环;"不再显示" 默认透明、hover 才露出 danger 红。点赞 burst 动画时长 280→320 msscale 1.18→1.20。
- **简介 + 标签合并卡**:原本两张分离卡合并成一张 `.vd-info` 大卡,子区用 `.vd-info__section-head` 小标题区分("简介" / `#` 图标 + "标签"),中间细分隔线。整卡 hover border 变亮。标签编辑按钮图标从 `+` 改为铅笔 `Pencil`。标签编辑器改为卡内行内弹层。
- **推荐栏头部**:从单 div 升级为 header:左侧 4×28 渐变发光竖条 + 标题 + 副标题("根据当前视频 · N 条")。item hover 时缩略图微缩放 1.04 + 整 item 上浮 1 px。
- **响应式**>=1024 双列;<=1024 折单列;<=768 平板触控目标 ≥44 px、meta 胶囊缩到 22 px 高、播放器顶到容器边缘;<=480 手机标题 3 行、操作栏点赞/点踩平分主行、"不再显示"折成 44×44 纯图标按钮(保留 `aria-label`)。
代码位置:
- `src/pages/VideoDetailPage.tsx`:外层结构从 `container page-section` 改为 `vd-page > vd-ambient + vd-page__inner`;删除 `.vd-toolbar` 包裹层(`VideoActions` 自身是工具条)。
- `src/components/VideoMetaHeader.tsx`:标题 + `.vd-header__row`(作者 + meta 胶囊)。
- `src/components/VideoActions.tsx`:图标 16→18 px;外层加 `role=toolbar`;类名拼接改为模板字符串。
- `src/components/VideoInfoPanel.tsx`:简介与标签合并到 `.vd-info`section head 模式。
- `src/components/RecommendedRail.tsx`:仅改头部 JSX,预览 hooks`previewController` / `previewIntent` / `useInViewport` / `PreviewVideo`)保持不变。
- `src/styles/video-detail.css`:全面重写。
不变项:
- 所有数据请求 (`fetchVideoDetail` / `fetchTags` / `recordView` / `updateVideoTags` / `hideVideo`) 和 `like` API 调用、点踩本地 state、转码轮询逻辑都未改动。
- 不引入新依赖,颜色全部走 `tokens.css`,未使用 `!important`
- `lint` (`tsc --noEmit`) 和 `build` (`tsc -b && vite build`) 均通过。
## 15. 后端集成方案(网盘驱动 + 元数据 + 预览生成)
本节记录接入真实网盘后端的架构和关键决策。