feat(detail): rewrite video detail page with mobile polish

- Restructure layout: title + inline meta first, YouTube-style action pills,
  collapsible description card, simplified tag editor.
- Replace Recommended rail !important overrides with a dedicated component
  that reuses previewController/previewIntent so cards still play teasers
  on hover (desktop) and tap-to-preview (mobile).
- Add proper mobile responsive breakpoints (768px / 480px): fix player
  edge-bleed margin, raise touch targets to 44px, redesign actions row,
  switch recommended rail to compact 128px-thumb horizontal cards on phone.
- Show dislike count alongside like count for visual symmetry (still
  client-side only; backend dislike API not yet implemented).
This commit is contained in:
nianzhibai
2026-05-21 09:47:36 +08:00
parent c862fc710b
commit 81310c2117
6 changed files with 1324 additions and 599 deletions
+231 -7
View File
@@ -1,20 +1,244 @@
import type { VideoItem } from "@/types";
import { VideoCard } from "./VideoCard";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { Link } from "react-router-dom";
import type { PreviewState, VideoItem } from "@/types";
import { formatCount } from "@/lib/format";
import { previewController } from "@/lib/previewController";
import {
shouldInterceptPreviewTap,
shouldStartInstantPreview,
} from "@/lib/previewIntent";
import { useInViewport } from "@/lib/useInViewport";
import { PreviewVideo } from "./PreviewVideo";
type Props = {
videos: VideoItem[];
};
const HOVER_DELAY_MS = 300;
function useActivePreviewId(): string | null {
return useSyncExternalStore(
previewController.subscribe,
previewController.getActiveId,
() => null
);
}
/**
* 详情页右侧 / 移动端下方的"推荐视频"列表。
*
* 不直接复用 VideoCard:那个组件结构是上下两段(缩略图 + 标题/meta),而这里需要
* 左右横排的紧凑布局,覆盖样式会很乱。本组件复用同一套预览相关基础设施
* previewController / previewIntent / useInViewport / PreviewVideo),
* 行为与 VideoCard 一致:桌面 hover 300ms 后预览,手机首次点击播预览、再点跳详情。
*/
export function RecommendedRail({ videos }: Props) {
if (!videos || videos.length === 0) return null;
return (
<aside className="detail-side" aria-label="推荐视频">
<div className="detail-side__header"></div>
<div className="detail-side__list">
<aside className="vd-rail" aria-label="推荐视频">
<div className="vd-rail__head"></div>
<ul className="vd-rail__list">
{videos.map((v) => (
<VideoCard key={v.id} video={v} />
<RecommendedItem key={v.id} video={v} />
))}
</div>
</ul>
</aside>
);
}
function RecommendedItem({ video }: { video: VideoItem }) {
const [previewState, setPreviewState] = useState<PreviewState>("idle");
const [shouldRenderPreview, setShouldRenderPreview] = useState(false);
const [progress, setProgress] = useState(0);
const rootRef = useRef<HTMLLIElement | null>(null);
const hoverTimerRef = useRef<number | null>(null);
const lastPointerTypeRef = useRef<string>("");
const canHoverRef = useRef(true);
const videoRef = useRef<HTMLVideoElement | null>(null);
const activeId = useActivePreviewId();
const inView = useInViewport(rootRef);
// 全局预览换卡时立即清理
useEffect(() => {
if (activeId !== video.id && shouldRenderPreview) {
cleanup();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeId, video.id]);
// 离开视口立即停
useEffect(() => {
if (!inView && shouldRenderPreview) {
cleanup();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inView]);
// 卸载清理
useEffect(() => {
return () => {
cleanup();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 检测当前设备是否支持 hover(鼠标 vs 触屏)
useEffect(() => {
const media = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => {
canHoverRef.current = media.matches;
};
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
function cleanup() {
if (hoverTimerRef.current) {
window.clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
const el = videoRef.current;
if (el) {
try {
el.pause();
el.removeAttribute("src");
el.load();
} catch {
// noop
}
}
setShouldRenderPreview(false);
setPreviewState("idle");
setProgress(0);
if (previewController.getActiveId() === video.id) {
previewController.setActiveId(null);
}
}
function startPreviewIntent() {
if (!inView) return;
if (hoverTimerRef.current) return;
setPreviewState("intent");
hoverTimerRef.current = window.setTimeout(() => {
hoverTimerRef.current = null;
startPreviewNow({ requireInView: true });
}, HOVER_DELAY_MS);
}
function startPreviewNow(options: { requireInView: boolean }) {
if (options.requireInView && !inView) return;
if (hoverTimerRef.current) {
window.clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
previewController.setActiveId(video.id);
setShouldRenderPreview(true);
setPreviewState("loading");
}
function stopPreview() {
cleanup();
}
function handlePointerEnter(event: React.PointerEvent<HTMLLIElement>) {
lastPointerTypeRef.current = event.pointerType;
if (shouldStartInstantPreview({ pointerType: event.pointerType })) return;
startPreviewIntent();
}
function handlePointerLeave(event: React.PointerEvent<HTMLLIElement>) {
if (shouldStartInstantPreview({ pointerType: event.pointerType })) return;
stopPreview();
}
function handlePointerDown(event: React.PointerEvent<HTMLLIElement>) {
lastPointerTypeRef.current = event.pointerType;
}
function handleClickCapture(event: React.MouseEvent<HTMLAnchorElement>) {
const previewActive = activeId === video.id && shouldRenderPreview;
if (
!shouldInterceptPreviewTap({
pointerType: lastPointerTypeRef.current,
canHover: canHoverRef.current,
previewActive,
})
) {
return;
}
event.preventDefault();
event.stopPropagation();
startPreviewNow({ requireInView: false });
}
return (
<li
ref={rootRef}
className="vd-rail__item"
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onPointerDown={handlePointerDown}
onFocus={startPreviewIntent}
onBlur={stopPreview}
>
<Link
to={video.href}
className="vd-rail__link"
onClickCapture={handleClickCapture}
>
<div className="vd-rail__thumb">
<img src={video.thumbnail} alt={video.title} loading="lazy" />
{shouldRenderPreview && (
<PreviewVideo
ref={videoRef}
src={video.previewSrc}
state={previewState}
onCanPlay={() => setPreviewState("playing")}
onError={() => setPreviewState("error")}
onTimeUpdate={(p) => setProgress(p)}
/>
)}
{previewState === "loading" && (
<span className="preview-loader" />
)}
{previewState === "error" && (
<span className="preview-error"></span>
)}
{previewState === "playing" && (
<div className="preview-progress" aria-hidden="true">
<div
className="preview-progress__bar"
style={{ width: `${Math.min(100, progress * 100)}%` }}
/>
</div>
)}
{video.duration && previewState !== "playing" && (
<span className="vd-rail__duration">{video.duration}</span>
)}
{video.quality === "HD" && previewState !== "playing" && (
<span className="vd-rail__hd">HD</span>
)}
</div>
<div className="vd-rail__body">
<h3 className="vd-rail__title" title={video.title}>
{video.title}
</h3>
<div className="vd-rail__meta">
{video.author && (
<span className="vd-rail__author">{video.author}</span>
)}
<span>{formatCount(video.views)} </span>
{video.publishedAt && <span>{video.publishedAt}</span>}
</div>
</div>
</Link>
</li>
);
}
+45 -23
View File
@@ -9,6 +9,15 @@ type Props = {
hideSaving?: boolean;
};
/**
* 视频操作栏。
* - 点赞 + 点踩合并成一个胶囊(中间用分隔线),两侧都显示计数。
* - "不再显示" 单独成一个独立按钮,靠右放置时由父级处理。
*
* 注意:当前后端只有点赞接口(POST /api/video/:id/like),
* 点踩仅在前端记录,不会持久化。等后端补上 dislike 接口时,把
* handleDislike 里的本地 state 升级成网络请求即可。
*/
export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
const [likes, setLikes] = useState(video.likes ?? 0);
const [dislikes, setDislikes] = useState(video.dislikes ?? 0);
@@ -21,7 +30,7 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
setLiked(true);
setLikes((n) => n + 1);
setBursting(true);
window.setTimeout(() => setBursting(false), 240);
window.setTimeout(() => setBursting(false), 280);
if (disliked) {
setDisliked(false);
@@ -45,10 +54,13 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
}
function handleDislike() {
if (disliked) return;
if (disliked) {
setDisliked(false);
setDislikes((n) => Math.max(0, n - 1));
return;
}
setDisliked(true);
setDislikes((n) => n + 1);
if (liked) {
setLiked(false);
setLikes((n) => Math.max(0, n - 1));
@@ -56,30 +68,40 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
}
return (
<div className="video-actions">
<div className="vd-actions">
<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" : ""}`}
onClick={handleLike}
aria-pressed={liked}
aria-label="点赞"
>
<ThumbsUp size={16} 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" : ""}`}
onClick={handleDislike}
aria-pressed={disliked}
aria-label="点踩"
>
<ThumbsDown size={16} fill={disliked ? "currentColor" : "none"} />
<span className="vd-actions__count">{formatCount(dislikes)}</span>
</button>
</div>
<button
className={`video-actions__btn video-actions__like ${liked ? "is-active" : ""} ${bursting ? "is-bursting" : ""}`}
onClick={handleLike}
aria-label="点赞"
>
<ThumbsUp size={14} fill={liked ? "currentColor" : "none"} />
{liked ? "已赞" : "点赞"} · {formatCount(likes)}
</button>
<button
className={`video-actions__btn is-danger ${disliked ? "is-active" : ""}`}
onClick={handleDislike}
aria-label="点踩"
>
<ThumbsDown size={14} fill={disliked ? "currentColor" : "none"} />
{disliked ? "已踩" : "点踩"} · {formatCount(dislikes)}
</button>
<button
className="video-actions__btn is-danger"
type="button"
className="vd-actions__btn vd-actions__hide"
onClick={onHideVideo}
disabled={hideSaving}
aria-label="不再显示这个视频"
>
<EyeOff size={14} />
{hideSaving ? "隐藏中" : "不再显示"}
<EyeOff size={16} />
<span>{hideSaving ? "处理中" : "不再显示"}</span>
</button>
</div>
);
+133 -106
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { Plus, X } from "lucide-react";
import type { TagItem, VideoDetail } from "@/types";
import { formatCount } from "@/lib/format";
type Props = {
video: VideoDetail;
@@ -9,6 +9,11 @@ type Props = {
onTagsChange?: (tags: string[]) => Promise<void>;
};
/**
* 视频信息板块:
* - 简介:默认折叠 3 行,整块可点击展开/收起。简介为空时不渲染。
* - 标签:横向 chip 列表 + 一个圆形 "+" 按钮调出编辑器;编辑器内仅展示候选标签的 checkbox 网格。
*/
export function VideoInfoPanel({
video,
availableTags = [],
@@ -18,14 +23,33 @@ export function VideoInfoPanel({
const [editingTags, setEditingTags] = useState(false);
const [draftTags, setDraftTags] = useState<string[]>(video.tags ?? []);
const [tagError, setTagError] = useState("");
const [descCollapsed, setDescCollapsed] = useState(true);
const [descExpanded, setDescExpanded] = useState(false);
const tags = video.tags ?? [];
const description = (video.description ?? "").trim();
const showDescription = description.length > 0;
const descriptionLong = description.length > 80 || description.includes("\n");
const sortedAvailable = useMemo(() => {
return [...availableTags].sort((a, b) => {
const ac = a.count ?? 0;
const bc = b.count ?? 0;
if (bc !== ac) return bc - ac;
return a.label.localeCompare(b.label, "zh-Hans-CN");
});
}, [availableTags]);
function openTagEditor() {
setDraftTags(video.tags ?? []);
setDraftTags(tags);
setTagError("");
setEditingTags(true);
}
function closeTagEditor() {
setEditingTags(false);
setTagError("");
}
async function saveTags() {
if (!onTagsChange) return;
setTagError("");
@@ -38,118 +62,121 @@ export function VideoInfoPanel({
}
return (
<section className="info-panel" aria-label="视频信息">
<header className="info-panel__header"></header>
<div className="info-panel__body">
<div className="info-row">
<span className="info-row__label"></span>
<span className="info-row__value">{video.publishedAt}</span>
<section className="vd-info" aria-label="视频信息">
{showDescription && (
<div
className={`vd-info__desc ${descExpanded ? "is-expanded" : ""} ${
descriptionLong ? "is-clickable" : ""
}`}
role={descriptionLong ? "button" : undefined}
tabIndex={descriptionLong ? 0 : undefined}
onClick={() => descriptionLong && setDescExpanded((v) => !v)}
onKeyDown={(e) => {
if (!descriptionLong) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setDescExpanded((v) => !v);
}
}}
>
<p className="vd-info__desc-text">{description}</p>
{descriptionLong && (
<span className="vd-info__desc-toggle">
{descExpanded ? "收起" : "展开"}
</span>
)}
</div>
)}
<div className="info-row">
<span className="info-row__label"></span>
<span className="info-row__value">{video.author || video.category || "影视合集"}</span>
<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>
))}
{onTagsChange && (
<button
type="button"
className="vd-info__tags-edit"
onClick={openTagEditor}
aria-label="编辑标签"
>
<Plus size={14} />
<span></span>
</button>
)}
</div>
</div>
<div className="info-row">
<span className="info-row__label"></span>
<span className="info-row__value">{formatCount(video.views)} </span>
</div>
{editingTags && (
<div className="vd-tag-editor" role="dialog" aria-label="编辑视频标签">
<header className="vd-tag-editor__head">
<span></span>
<button
type="button"
className="vd-tag-editor__close"
onClick={closeTagEditor}
aria-label="关闭"
>
<X size={16} />
</button>
</header>
<div className="info-row">
<span className="info-row__label"></span>
<span className="info-row__value">{video.quality || "HD 1080P"}</span>
</div>
{video.sourceLabel && (
<div className="info-row">
<span className="info-row__label"></span>
<span className="info-row__value">{video.sourceLabel}</span>
</div>
)}
<div className="info-row">
<span className="info-row__label"></span>
<span className="info-row__value">{video.duration || "未知"}</span>
</div>
{/* 标签行 - 满宽 */}
<div className="info-row is-tags-row">
<span className="info-row__label"></span>
<div className="info-row__value">
<div className="detail-tags">
{(video.tags ?? []).map((t) => (
<span key={t} className="tag-chip">
{t}
</span>
))}
{onTagsChange && (
<button className="detail-tags__edit" onClick={openTagEditor}>
</button>
)}
</div>
{editingTags && (
<div className="detail-tag-editor">
<div className="detail-tag-editor__grid">
{availableTags.map((tag) => (
<label key={tag.id} className="detail-tag-editor__item">
<input
type="checkbox"
checked={draftTags.includes(tag.label)}
onChange={() => setDraftTags(toggleTag(draftTags, tag.label))}
/>
<span>{tag.label}</span>
{typeof tag.count === "number" && <em>({tag.count})</em>}
</label>
))}
</div>
{tagError && <div className="detail-tag-editor__error">{tagError}</div>}
<div className="detail-tag-editor__actions">
<button onClick={() => setEditingTags(false)}></button>
<button onClick={saveTags} disabled={tagSaving}>
{tagSaving ? "保存中..." : "保存修改"}
<div className="vd-tag-editor__grid">
{sortedAvailable.length === 0 ? (
<div className="vd-tag-editor__empty"></div>
) : (
sortedAvailable.map((tag) => {
const checked = draftTags.includes(tag.label);
return (
<button
type="button"
key={tag.id}
className={`vd-tag-editor__chip ${checked ? "is-active" : ""}`}
onClick={() =>
setDraftTags((prev) =>
prev.includes(tag.label)
? prev.filter((t) => t !== tag.label)
: [...prev, tag.label]
)
}
aria-pressed={checked}
>
<span>{tag.label}</span>
{typeof tag.count === "number" && (
<em>{tag.count}</em>
)}
</button>
</div>
</div>
);
})
)}
</div>
</div>
{/* 描述行 - Collapsible */}
{video.description && (
<div
className="info-row"
style={{
gridColumn: "1 / -1",
borderTop: "1px dashed rgba(255, 255, 255, 0.06)",
paddingTop: "var(--space-4)"
}}
>
<span className="info-row__label"></span>
<div className="info-row__value" style={{ position: "relative" }}>
<p className={`description ${descCollapsed ? "is-collapsed" : ""}`} style={{ margin: 0 }}>
{video.description}
</p>
{video.description.length > 120 && (
<button
className="description-toggle"
onClick={() => setDescCollapsed(!descCollapsed)}
style={{ border: 0, padding: 0, marginTop: "6px" }}
>
{descCollapsed ? "展开全部介绍 ↓" : "收起介绍 ↑"}
</button>
)}
</div>
{tagError && <div className="vd-tag-editor__error">{tagError}</div>}
<div className="vd-tag-editor__actions">
<button
type="button"
className="vd-tag-editor__btn"
onClick={closeTagEditor}
>
</button>
<button
type="button"
className="vd-tag-editor__btn is-primary"
onClick={saveTags}
disabled={tagSaving}
>
{tagSaving ? "保存中..." : "保存"}
</button>
</div>
)}
</div>
</div>
)}
</section>
);
}
function toggleTag(tags: string[], label: string): string[] {
return tags.includes(label)
? tags.filter((tag) => tag !== label)
: [...tags, label];
}
+86
View File
@@ -0,0 +1,86 @@
import type { VideoDetail } from "@/types";
import { formatCount } from "@/lib/format";
type Props = {
video: VideoDetail;
};
/**
* 详情页标题块:标题 + 一行流式元信息(meta)。
* 元信息按照「主→次」的顺序:作者 / 来源网盘 / 画质 / 时长 / 浏览量 / 发布时间。
* 缺省字段会被自动跳过,不会留下空的分隔点。
*/
export function VideoMetaHeader({ video }: Props) {
const author = (video.author ?? "").trim();
const source = (video.sourceLabel ?? "").trim();
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}</> });
}
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>
)}
</li>
))}
</ul>
</header>
);
}
// 根据 sourceLabel 识别网盘类型,给来源徽标上色。复制自 VideoCard,避免循环依赖。
function sourceKindFromLabel(label: string): string {
const value = label.toLowerCase();
if (value.includes("夸克") || value.includes("quark")) return "quark";
if (value.includes("115") || value.includes("p115")) return "p115";
if (value.includes("pikpak")) return "pikpak";
if (value.includes("沃盘") || value.includes("wopan") || value.includes("联通"))
return "wopan";
if (value.includes("onedrive") || value.includes("one drive")) return "onedrive";
return "";
}
+15 -18
View File
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { AppShell } from "@/components/AppShell";
import { VideoPlayer } from "@/components/VideoPlayer";
import { VideoActions } from "@/components/VideoActions";
import { VideoMetaHeader } from "@/components/VideoMetaHeader";
import { VideoInfoPanel } from "@/components/VideoInfoPanel";
import { RecommendedRail } from "@/components/RecommendedRail";
import {
@@ -77,20 +78,18 @@ export default function VideoDetailPage() {
function handleFirstPlay() {
if (!detail) return;
const id = detail.id;
// 失败静默忽略,不打扰用户播放体验
recordView(id).catch(() => undefined);
recordView(detail.id).catch(() => undefined);
}
if (loading) {
return (
<AppShell>
<div className="container page-section">
<div className="video-grid-loading">
<div className="skeleton-card" />
<div className="skeleton-card" />
<div className="skeleton-card" />
<div className="skeleton-card" />
<div className="vd-skeleton">
<div className="vd-skeleton__player" />
<div className="vd-skeleton__title" />
<div className="vd-skeleton__meta" />
</div>
</div>
</AppShell>
@@ -101,7 +100,7 @@ export default function VideoDetailPage() {
return (
<AppShell>
<div className="container page-section">
<div className="video-grid-empty"></div>
<div className="vd-empty"></div>
</div>
</AppShell>
);
@@ -109,11 +108,10 @@ export default function VideoDetailPage() {
return (
<AppShell>
<div className="container page-section">
<div className="detail-layout">
<div className="detail-main" ref={detailTopRef}>
{/* 顶置影院式悬浮播放器 */}
<div className="detail-player-card">
<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}
@@ -122,8 +120,9 @@ export default function VideoDetailPage() {
/>
</div>
{/* 动作区 */}
<div className="detail-actions-row">
<VideoMetaHeader video={detail} />
<div className="vd-toolbar">
<VideoActions
video={detail}
onHideVideo={handleHideVideo}
@@ -131,7 +130,6 @@ export default function VideoDetailPage() {
/>
</div>
{/* 磨砂元数据展板 */}
<VideoInfoPanel
video={detail}
availableTags={tags}
@@ -139,11 +137,10 @@ export default function VideoDetailPage() {
onTagsChange={handleTagsChange}
/>
</div>
<RecommendedRail videos={detail.relatedVideos} />
</div>
</div>
<div style={{ height: 40 }} />
</AppShell>
);
}
File diff suppressed because it is too large Load Diff