Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-26 17:51:31 +07:00
co-authored by Claude Opus 5.5
commit 61d0a11dfa
371 changed files with 35836 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
import { RELATED_DISPLAY_COUNT } from "@/components/drama-detail/data";
import { pickVideoCardTags } from "@/components/common/card-tags";
import { formatHeat } from "@/components/ranking/data";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { mapVideoListItem } from "@/components/video/data";
import type {
QueryVideoInfoResp,
VideoData,
VideoDetailInfo,
VideoRecommendListResp,
} from "@/lib/api/types";
import { resolvePlayURL } from "@/lib/media/playback";
/** 详情页:直接使用接口 videoInfo 字段 */
export type VideoDetail = VideoDetailInfo;
/** H5 / 侧栏热门推荐条数 */
export const VIDEO_RECOMMEND_DISPLAY_COUNT = 10;
export type VideoPlayView = {
detail: VideoDetail;
src: string;
related: ShortDramaItem[];
};
function mapHotVideoItem(item: VideoData): ShortDramaItem {
return {
kind: "drama",
id: String(item.id),
slug: item.slug?.trim() || undefined,
title: (item.title || "").trim() || "未命名",
subtitle: "",
tags: pickVideoCardTags(item),
heat: formatHeat(item.watchNum),
episodes: "",
cover: (item.coverImg || "").trim(),
};
}
/** 热门推荐:保留广告位;排除当前视频 */
export function mapVideoRecommendList(
data: VideoRecommendListResp | null | undefined,
options?: { excludeId?: number | string | null; limit?: number },
): ShortDramaItem[] {
const excludeId = options?.excludeId != null ? Number(options.excludeId) : NaN;
const limit = options?.limit ?? VIDEO_RECOMMEND_DISPLAY_COUNT;
return (data?.list ?? [])
.filter((item) => {
if (item.type === 2) return true;
if (!item.id) return false;
if (Number.isFinite(excludeId) && Number(item.id) === excludeId) return false;
return true;
})
.map(mapVideoListItem)
.filter((item): item is ShortDramaItem => Boolean(item))
.slice(0, limit);
}
/** 透传接口字段,仅拼播放地址 / 根级点赞收藏态 */
export function mapVideoInfo(data: QueryVideoInfoResp | null | undefined): VideoPlayView | null {
const info = data?.videoInfo;
if (!info?.id) return null;
const media = info.video;
const enableH265 = Boolean(data?.config?.enableH265);
const path = (enableH265 && media?.h265Url?.trim() ? media.h265Url : media?.url) || media?.h265Url || "";
const src = resolvePlayURL(path);
return {
detail: {
...info,
isLike: Boolean(data?.isLike ?? info.isLike),
isFavorite: Boolean(data?.isFavorite ?? info.isFavorite),
},
src,
related: (data?.hotVideoList ?? [])
.filter((item) => Boolean(item.id))
.slice(0, RELATED_DISPLAY_COUNT)
.map(mapHotVideoItem),
};
}
export function formatWatchNum(count?: number | null) {
const value = Number(count) || 0;
if (value >= 10000) {
const wan = value / 10000;
const text = wan >= 100 ? String(Math.round(wan)) : wan.toFixed(1).replace(/\.0$/, "");
return `${text}万次播放`;
}
return `${value}次播放`;
}
export function formatCount(count?: number | null) {
const value = Number(count) || 0;
if (value >= 10000) {
const wan = value / 10000;
const text = wan >= 100 ? String(Math.round(wan)) : wan.toFixed(1).replace(/\.0$/, "");
return `${text}万`;
}
if (value >= 1000) {
const k = value / 1000;
return `${k.toFixed(1).replace(/\.0$/, "")}k`;
}
return String(value);
}
+225
View File
@@ -0,0 +1,225 @@
"use client";
import Link from "next/link";
import { useState, type ReactNode } from "react";
import { BackButton } from "@/components/common/back-button";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { H5CommentsTab, useComments, type CommentTarget } from "@/components/common/comments";
import { XgPlayer } from "@/components/player/xg-player";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { tagPageHref } from "@/components/tag/href";
import { H5VideoCard } from "@/components/video/video-card";
import { formatCount, formatWatchNum, type VideoDetail } from "./data";
type Tab = "intro" | "comments";
type StatsProps = {
liked: boolean;
/** 接口 likeNum:点赞数 */
likeNum?: number;
collected: boolean;
/** 接口 countCollect:收藏数 */
countCollect?: number;
onLike: () => void;
onCollect: () => void;
onShare: () => void;
};
function H5VideoPlayer({
detail,
src,
onPlaySuccess,
}: {
detail: VideoDetail;
src: string;
onPlaySuccess?: () => void;
}) {
// 上下文由 VideoPlayPage 统一设置;此处仅绑定播放器行为
return (
<div className="sticky top-0 z-20 aspect-video w-full overflow-hidden bg-black">
<XgPlayer
src={src}
poster={detail.coverImg || ""}
onPlaySuccess={onPlaySuccess}
analyticsKind="video"
clickToPlay
className="absolute inset-0"
options={{ ignores: ["cssfullscreen"], fullscreen: { rotateFullscreen: true } }}
/>
<BackButton fallback="/video/hot" className="absolute top-3 left-4 z-10" />
</div>
);
}
function IntroTab({
detail,
related,
liked,
likeNum,
collected,
countCollect,
onLike,
onCollect,
onShare,
adGrid,
bannerAds,
}: StatsProps & {
detail: VideoDetail;
related: ShortDramaItem[];
adGrid?: ReactNode;
bannerAds?: ReactNode;
}) {
const tags = (detail.tags ?? []).filter((tag) =>
Boolean(tag.name?.trim() || tag.slug?.trim() || tag.id),
);
const likeCount = likeNum ?? detail.likeNum;
const collectCount = countCollect ?? detail.countCollect;
return (
<div className="flex flex-col gap-[18px] bg-[#010406] px-4 py-3">
<h1 className="line-clamp-2 text-sm leading-5 font-medium text-white/80">{detail.title}</h1>
{detail.desc?.trim() ? (
<p className="text-xs leading-[18px] text-white/80">{detail.desc}</p>
) : null}
{tags.length ? (
<div className="flex flex-wrap items-center gap-1.5">
{tags.map((tag) => {
const name = (tag.tagName || tag.name || "").trim() || tag.slug?.trim() || "";
if (!name) return null;
const canLink = Boolean(tag.slug?.trim() || tag.id != null);
return canLink ? (
<Link
key={tag.slug || tag.id || name}
href={tagPageHref({ slug: tag.slug, id: tag.id }, "video")}
className="rounded-[3px] bg-white/10 px-1.5 py-0.5 text-xs leading-[18px] text-white/90 hover:text-[#F12B9E]"
>
#{name}
</Link>
) : (
<span
key={name}
className="rounded-[3px] bg-white/10 px-1.5 py-0.5 text-xs leading-[18px] text-white/90"
>
#{name}
</span>
);
})}
</div>
) : null}
<div className="flex items-center justify-between text-xs leading-[18px]">
<span className="text-[#BDBDBD]">{formatWatchNum(detail.watchNum)}</span>
<div className="flex items-center gap-3 text-[#BDBDBD]">
<button type="button" aria-pressed={liked} onClick={onLike} className="cursor-pointer flex items-center gap-0.5">
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
<path
d="M8 14c-.2 0-.4-.07-.56-.2C5.3 12 2 9.7 2 6.6 2 4.6 3.4 3 5.2 3c1.1 0 2.1.6 2.8 1.5C8.7 3.6 9.7 3 10.8 3 12.6 3 14 4.6 14 6.6c0 3.1-3.3 5.4-5.44 7.2-.16.13-.36.2-.56.2Z"
fill={liked ? "#F52C56" : "none"}
stroke="#F52C56"
strokeWidth="1.4"
/>
</svg>
{formatCount(likeCount)}
</button>
<button
type="button"
aria-pressed={collected}
onClick={onCollect}
className="cursor-pointer flex items-center gap-0.5"
>
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
<path
d="m8 2 1.8 3.7 4 .6-2.9 2.8.7 4L8 11.2l-3.6 1.9.7-4-2.9-2.8 4-.6L8 2Z"
fill={collected ? "#FFC53D" : "none"}
stroke="#FFC53D"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
{formatCount(collectCount)}
</button>
<button type="button" onClick={onShare} className="cursor-pointer flex items-center gap-0.5">
<img src="/images/share_h5.svg" alt="" className="size-4" />
分享
</button>
</div>
</div>
{/* icon 广告 → 热门推荐 → banner 广告 */}
{adGrid ?? <AdIcons variant="h5" />}
{related.length ? (
<section className="flex flex-col items-center gap-3">
<h2 className="w-full text-lg leading-6 font-semibold text-white">热门推荐</h2>
<div className="grid w-full grid-cols-2 gap-x-[7px] gap-y-3">
{related.map((item) => (
<H5VideoCard key={item.id} item={item} showEpisodes={false} />
))}
</div>
</section>
) : null}
{bannerAds ?? <AdBanners variant="h5" />}
</div>
);
}
export function H5VideoPlay({
detail,
src,
related = [],
commentTarget,
onPlaySuccess,
adGrid,
bannerAds,
...stats
}: StatsProps & {
detail: VideoDetail;
src: string;
related?: ShortDramaItem[];
commentTarget: CommentTarget | null;
onPlaySuccess?: () => void;
adGrid?: ReactNode;
bannerAds?: ReactNode;
}) {
const [tab, setTab] = useState<Tab>("intro");
const comments = useComments({ target: commentTarget, initialSort: "最新" });
const tabs: { key: Tab; label: string }[] = [
{ key: "intro", label: "简介" },
{ key: "comments", label: `评论(${comments.total})` },
];
return (
<div data-component="video-h5-play" className="flex flex-col bg-[#010406] lg:hidden">
<H5VideoPlayer detail={detail} src={src} onPlaySuccess={onPlaySuccess} />
<div role="tablist" className="flex gap-3 px-4 pt-[18px]">
{tabs.map((item) => (
<button
key={item.key}
type="button"
role="tab"
aria-selected={tab === item.key}
onClick={() => setTab(item.key)}
className={`cursor-pointer text-base leading-[22px] ${tab === item.key ? "font-medium text-white" : "text-[#8D8E8F]"}`}
>
{item.label}
</button>
))}
</div>
{tab === "intro" ? (
<IntroTab detail={detail} related={related} adGrid={adGrid} bannerAds={bannerAds} {...stats} />
) : (
<H5CommentsTab
state={comments}
footerExtra={
bannerAds ? <div className="px-4 py-3">{bannerAds}</div> : <AdBanners variant="h5" className="px-4 py-3" />
}
/>
)}
</div>
);
}
@@ -0,0 +1,46 @@
import Link from "next/link";
import { DecryptedImage } from "@/components/common/decrypted-image";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { videoHref } from "@/components/video/video-card";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
export function VideoRelatedAside({ items }: { items: ShortDramaItem[] }) {
const list = items.filter((item) => item.kind === "drama");
if (!list.length) return null;
return (
<aside data-component="video-related-aside" className="flex min-w-0 flex-1 flex-col gap-4 rounded-2xl bg-[rgba(24,23,46,0.88)] p-6 outline outline-1 -outline-offset-1 outline-[#383657]">
<div className="flex h-9 items-center justify-between">
<h2 className="text-[22px] font-bold text-white">热门推荐</h2>
<Link href="/video/hot" className="flex items-center gap-1 text-xs leading-[18px] text-[#B8B2C2] hover:text-white">
更多
<img src="/images/more_arrow.svg" alt="" className="size-3.5" />
</Link>
</div>
{list.map((item) => (
<Link
key={item.id}
href={item.href || videoHref(item.id, item.slug)}
className="group flex h-24 items-center gap-4"
>
<DecryptedImage
src={item.cover}
fallback={IMG_PLACEHOLDER}
alt=""
className="h-24 w-[128px] shrink-0 rounded-lg object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<h3 className="line-clamp-2 text-base leading-[23px] text-[#F5F0FA] group-hover:text-[#F12B9E]">
{item.title}
</h3>
<div className="flex items-center justify-between text-xs leading-[18px]">
<span className="truncate text-[#A89EB5]">{item.tags[0]?.name}</span>
<span className="shrink-0 text-[#ABA1B8]">{item.heat}</span>
</div>
</div>
</Link>
))}
</aside>
);
}
@@ -0,0 +1,46 @@
"use client";
import { useCallback, useRef } from "react";
import { reportVideoHistory } from "@/lib/api/video-actions";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
async function ensureToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
/** 播放成功后上报观看历史;同一视频 id 只报一次 */
export function useReportVideoHistory(videoId: number) {
const reportedIdRef = useRef<number | null>(null);
return useCallback(async () => {
if (!Number.isInteger(videoId) || videoId < 1) return;
if (reportedIdRef.current === videoId) return;
reportedIdRef.current = videoId;
const token = await ensureToken();
if (!token) {
reportedIdRef.current = null;
return;
}
try {
await reportVideoHistory({ id: videoId }, token);
} catch (error) {
reportedIdRef.current = null;
if (process.env.NODE_ENV === "development") {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[video/history] report failed: ${message}`);
}
}
}, [videoId]);
}
@@ -0,0 +1,309 @@
"use client";
import { toast } from "@heroui/react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useVideoContext } from "@/components/analytics/use-playback-analytics";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { CommentPanel, videoCommentTarget } from "@/components/common/comments";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { ActionPill } from "@/components/drama-play/drama-play-page";
import { PcPlayer } from "@/components/drama-play/pc-player";
import { FloatingActions } from "@/components/home/floating-actions";
import { PageBreadcrumb } from "@/components/seo/page-breadcrumb";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { favoriteVideo, getVideoInfo, likeVideo } from "@/lib/api/video-actions";
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import type { VideoDetail } from "./data";
import { H5VideoPlay } from "./h5-video-play";
import { VideoRelatedAside } from "./related-aside";
import { useReportVideoHistory } from "./use-report-video-history";
async function ensureVideoAuthToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
function resolveVideoLikeFavorite(data: Awaited<ReturnType<typeof getVideoInfo>> | null | undefined) {
const info = data?.videoInfo;
return {
isLike: Boolean(data?.isLike ?? info?.isLike),
isFavorite: Boolean(data?.isFavorite ?? info?.isFavorite),
likeNum: Number(info?.likeNum) || 0,
countCollect: Number(info?.countCollect) || 0,
};
}
export function VideoPlayPage({
detail,
src,
related = [],
relatedAside,
promo,
adIcons,
adGrid,
bannerAds,
}: {
detail: VideoDetail;
src: string;
related?: ShortDramaItem[];
relatedAside?: ReactNode;
promo?: ReactNode;
adIcons?: ReactNode;
adGrid?: ReactNode;
bannerAds?: ReactNode;
}) {
const [liked, setLiked] = useState(Boolean(detail.isLike));
const [likeNum, setLikeNum] = useState(Number(detail.likeNum) || 0);
const [liking, setLiking] = useState(false);
const [collected, setCollected] = useState(Boolean(detail.isFavorite));
const [countCollect, setCountCollect] = useState(Number(detail.countCollect) || 0);
const [collecting, setCollecting] = useState(false);
const reportWatchHistory = useReportVideoHistory(Number(detail.id));
const tagName = (detail.tags?.[0]?.tagName || detail.tags?.[0]?.name || "").trim();
const videoTrackContext = useMemo(
() => ({
media_id: String(detail.id || ""),
video_id: String(detail.id || ""),
video_title: detail.title || "未命名视频",
video_type_id: "0",
video_type_name: "未分类",
video_content_type: "video",
recommend_trace_id: "",
video_tag_key: tagName ? "tag" : "default",
video_tag_name: tagName || "默认标签",
video_duration: Math.max(0, Math.round(Number(detail.video?.videoTime) || 0)),
}),
[detail.id, detail.title, detail.video?.videoTime, tagName],
);
useVideoContext(videoTrackContext);
const likeFavoriteKey = `${detail.id}:${detail.isLike}:${detail.likeNum}:${detail.isFavorite}:${detail.countCollect}`;
const [likeFavoriteSync, setLikeFavoriteSync] = useState(likeFavoriteKey);
if (likeFavoriteSync !== likeFavoriteKey) {
setLikeFavoriteSync(likeFavoriteKey);
setLiked(Boolean(detail.isLike));
setLikeNum(Number(detail.likeNum) || 0);
setCollected(Boolean(detail.isFavorite));
setCountCollect(Number(detail.countCollect) || 0);
}
// SSR 详情走 "use cache" 无登录态;客户端带 token 再拉一次,纠正已点赞 / 已收藏
useEffect(() => {
let cancelled = false;
void (async () => {
const token = await ensureVideoAuthToken();
if (!token || cancelled) return;
try {
const data = await getVideoInfo(
detail.slug?.trim() ? { slug: detail.slug.trim() } : { id: detail.id },
token,
);
if (cancelled) return;
const status = resolveVideoLikeFavorite(data);
setLiked(status.isLike);
setCollected(status.isFavorite);
setLikeNum(status.likeNum);
setCountCollect(status.countCollect);
} catch (error) {
console.warn("[video-play/status]", error);
}
})();
return () => {
cancelled = true;
};
}, [detail.id, detail.slug]);
const share = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
toast.success("链接已复制");
} catch {
toast.danger("复制失败,请稍后重试");
}
};
const toggleLike = async () => {
if (liking) return;
const videoId = Number(detail.id);
if (!Number.isInteger(videoId) || videoId < 1) return;
const token = await ensureVideoAuthToken();
if (!token) {
openLoginDialog();
return;
}
const next = !liked;
setLiked(next);
setLikeNum((value) => Math.max(0, value + (next ? 1 : -1)));
setLiking(true);
try {
const result = await likeVideo({ id: videoId, isLike: next }, token);
setLiked(Boolean(result?.isLike ?? next));
if (typeof result?.likeNum === "number") setLikeNum(result.likeNum);
} catch (error) {
setLiked(!next);
setLikeNum((value) => Math.max(0, value + (next ? -1 : 1)));
handleUnauthorized(error);
} finally {
setLiking(false);
}
};
const toggleCollect = async () => {
if (collecting) return;
const videoId = Number(detail.id);
if (!Number.isInteger(videoId) || videoId < 1) return;
const token = await ensureVideoAuthToken();
if (!token) {
openLoginDialog();
return;
}
const next = !collected;
setCollected(next);
setCountCollect((value) => Math.max(0, value + (next ? 1 : -1)));
setCollecting(true);
try {
const result = await favoriteVideo({ id: videoId, isFavorite: next }, token);
setCollected(Boolean(result?.isFavorite ?? next));
if (typeof result?.countCollect === "number") {
setCountCollect(result.countCollect);
} else {
// 响应无 countCollect 时再拉详情同步
try {
const data = await getVideoInfo(
detail.slug?.trim() ? { slug: detail.slug.trim() } : { id: detail.id },
token,
);
const num = Number(data?.videoInfo?.countCollect);
if (Number.isFinite(num) && num >= 0) setCountCollect(num);
} catch {
/* 保留乐观值 */
}
}
} catch (error) {
setCollected(!next);
setCountCollect((value) => Math.max(0, value + (next ? -1 : 1)));
handleUnauthorized(error);
} finally {
setCollecting(false);
}
};
return (
<main data-component="video-play-page" className="relative flex-1 bg-[#0B0709]">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 hidden h-[1200px] overflow-hidden [mask-image:linear-gradient(180deg,#000_70%,transparent_100%)] lg:block"
>
<DecryptedImage
src={detail.coverImg}
fallback={IMG_PLACEHOLDER}
alt=""
className="size-full scale-110 object-cover opacity-70 blur-[12px]"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(9,5,9,0.32)_0%,rgba(9,5,9,0.56)_55%,rgba(6,5,9,0.94)_100%)]" />
</div>
<div
data-pc-scale
className="relative mx-auto hidden w-[1700px] flex-col gap-6 pt-6 pb-8 [zoom:var(--page-scale,1)] lg:flex"
>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "视频详情" },
]}
/>
<PageBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "视频详情" },
]}
/>
<div className="flex flex-col gap-4">
<PcPlayer
src={src}
poster={detail.coverImg || ""}
title={detail.title || ""}
locked={false}
dramaTitle={detail.title || ""}
layout="wide"
onPlaySuccess={reportWatchHistory}
analyticsKind="video"
/>
{adIcons ?? <AdIcons variant="pc" labels={false} />}
<section className="flex h-[140px] items-center justify-between gap-10 rounded-xl bg-[rgba(19,15,22,0.9)] px-6 py-5 outline outline-1 -outline-offset-1 outline-[rgba(199,145,89,0.7)]">
<div className="flex min-w-0 flex-col gap-1.5">
<p className="truncate text-[28px] leading-[34px] font-semibold text-white">{detail.title}</p>
<p className="line-clamp-2 text-xs leading-[18px] text-[#9991AB]">{detail.desc}</p>
</div>
<div className="flex shrink-0 gap-3">
<ActionPill
icon="/images/icon_like.svg"
label={liked ? "已点赞" : "点赞"}
active={liked}
onClick={liking ? undefined : toggleLike}
/>
<ActionPill
icon="/images/icon_collect.svg"
label={collected ? "已收藏" : "收藏"}
active={collected}
onClick={collecting ? undefined : toggleCollect}
/>
<ActionPill icon="/images/icon_share.svg" label="分享" onClick={share} />
<ActionPill icon="/images/icon_feedback.svg" label="反馈" href="/feedback" />
</div>
</section>
<div className="flex items-start gap-6">
<CommentPanel variant="play" target={videoCommentTarget(detail.id)} />
{relatedAside ?? <VideoRelatedAside items={related} />}
</div>
{promo ?? <AdBanners variant="pc" />}
</div>
</div>
<H5VideoPlay
detail={detail}
src={src}
related={related}
liked={liked}
likeNum={likeNum}
collected={collected}
countCollect={countCollect}
onLike={liking ? () => undefined : toggleLike}
onCollect={collecting ? () => undefined : toggleCollect}
onShare={share}
commentTarget={videoCommentTarget(detail.id)}
onPlaySuccess={reportWatchHistory}
adGrid={adGrid}
bannerAds={bannerAds}
/>
<div className="max-lg:hidden">
<FloatingActions />
</div>
</main>
);
}