mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 08:45:41 +08:00
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:
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
+471
-217
File diff suppressed because it is too large
Load Diff
@@ -1292,6 +1292,36 @@ src/
|
||||
|
||||
任何一项都可以在小改动内回归 plan 原设计,等统一确认后再动。
|
||||
|
||||
### 14.5 视频详情/播放页视觉重写(2026-05-21)
|
||||
|
||||
第一版详情页视觉过于"列表化",标题 + 一行 `·` 分隔 meta + 上下灰线工具条 + 两张分离的简介/标签卡,缺少视觉重心和氛围感。本次按"沉浸感 + 信息层级清晰"的方向重写,仅改 UI,不动数据流和后端接口。
|
||||
|
||||
变化点:
|
||||
|
||||
- **Hero ambient 背景**:详情页根加 `.vd-ambient` 层,把当前视频海报作为模糊底色(桌面 48 px blur,opacity 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 ms,scale 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. 后端集成方案(网盘驱动 + 元数据 + 预览生成)
|
||||
|
||||
本节记录接入真实网盘后端的架构和关键决策。
|
||||
|
||||
Reference in New Issue
Block a user