mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 08:45:41 +08:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+814
-445
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user