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
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { AboutPage } from "@/components/about/about-page";
import { JsonLd } from "@/components/seo/json-ld";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
seoInfo: {
title: "关于我们",
description: "了解海角社区的品牌、产品与服务。",
},
canonical: "/about",
});
}
export default async function Page() {
const siteSeo = await getHomeBaseSEOCached();
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/about",
title: "关于我们",
description: "了解海角社区的品牌、产品与服务。",
type: "AboutPage",
breadcrumbs: [
["首页", "/"],
["关于我们", "/about"],
],
siteName: siteSeo.siteName,
})}
/>
<AboutPage />
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { FavoritesPage } from "@/components/favorites/favorites-page";
export const metadata: Metadata = {
title: "我的收藏 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <FavoritesPage />;
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { FeedbackPage } from "@/components/feedback/feedback-page";
import { JsonLd } from "@/components/seo/json-ld";
import { buildWebPageJSONLD, getHomeBaseSEOCached } from "@/lib/seo";
export const metadata: Metadata = {
title: "问题反馈 - 海角社区",
robots: { index: false, follow: false },
};
export default async function Page() {
const siteSeo = await getHomeBaseSEOCached();
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/feedback",
title: "问题反馈",
description: "向海角社区提交问题反馈与建议。",
breadcrumbs: [
["首页", "/"],
["问题反馈", "/feedback"],
],
siteName: siteSeo.siteName,
})}
/>
<FeedbackPage gmailAddr={siteSeo.config?.gmailAddr} />
</>
);
}
+418
View File
@@ -0,0 +1,418 @@
@import "tailwindcss";
@import "@heroui/styles";
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
/* HeroUI 深色主题对齐站点主色(toast / select 等组件共用) */
.dark,
[data-theme="dark"] {
--accent: #f12b9e;
--accent-foreground: #ffffff;
--focus: #f12b9e;
--background: #07060d;
--surface: #171424;
--surface-foreground: #ffffff;
--overlay: #1a1628;
--overlay-foreground: #ffffff;
--muted: #948ca8;
--success: #3dd68c;
--danger: #ff5c6e;
}
body {
background: #07060d;
color: #ffffff;
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", "Microsoft YaHei", sans-serif;
}
/* 搜索框:不参与 scroll-padding / 滚动锚定,避免输入时页面跳动 */
.site-search-input {
scroll-margin: 0;
overflow-anchor: none;
}
[data-component="site-header"] form[role="search"] {
scroll-margin: 0;
overflow-anchor: none;
}
/* 深色搜索框:取消浏览器 autocomplete / autofill 灰蓝底 */
.site-search-input:-webkit-autofill,
.site-search-input:-webkit-autofill:hover,
.site-search-input:-webkit-autofill:focus,
.site-search-input:-webkit-autofill:active {
-webkit-text-fill-color: #ffffff !important;
caret-color: #ffffff;
border-color: transparent !important;
/* 延后 UA 写入背景,视觉上保持透明 */
transition: background-color 99999s ease-in-out 0s;
box-shadow: 0 0 0px 1000px transparent inset !important;
-webkit-box-shadow: 0 0 0px 1000px transparent inset !important;
background-color: transparent !important;
background-image: none !important;
}
/* Toast 视觉贴近站点深色浮层 */
.toast-region {
z-index: 80;
}
.toast {
border: 1px solid rgba(255, 255, 255, 0.08);
background: #1a1628;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.toast__title {
color: #f2f2fa;
}
.toast__description {
color: #a3a3b8;
}
.toast--success .toast__title,
.toast--success .toast__indicator {
color: #3dd68c;
}
.toast--danger .toast__title,
.toast--danger .toast__indicator {
color: #ff8fa8;
}
.toast--accent .toast__title,
.toast--accent .toast__indicator {
color: #f12b9e;
}
/*
* PC 缩放区文案:.design-frame / [data-pc-scale] 内
* 字号/行高 × --page-unscale,抵消 page-scale zoom,窗口缩小时视觉大小不变。
*/
:is([data-pc-scale], .design-frame) {
--_u: var(--page-unscale, 1);
}
:is([data-pc-scale], .design-frame) .text-\[10px\] {
font-size: calc(10px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-xs {
font-size: calc(12px * var(--_u)) !important;
line-height: calc(16px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-sm {
font-size: calc(14px * var(--_u)) !important;
line-height: calc(20px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-base {
font-size: calc(16px * var(--_u)) !important;
line-height: calc(24px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-lg {
font-size: calc(18px * var(--_u)) !important;
line-height: calc(28px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-xl {
font-size: calc(20px * var(--_u)) !important;
line-height: calc(28px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-2xl {
font-size: calc(24px * var(--_u)) !important;
line-height: calc(32px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[22px\] {
font-size: calc(22px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[28px\] {
font-size: calc(28px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[29px\] {
font-size: calc(29px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[30px\] {
font-size: calc(30px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[32px\] {
font-size: calc(32px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[34px\] {
font-size: calc(34px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[40px\] {
font-size: calc(40px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[44px\] {
font-size: calc(44px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-none {
line-height: 1 !important;
}
:is([data-pc-scale], .design-frame) .leading-4 {
line-height: calc(16px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-5 {
line-height: calc(20px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-6 {
line-height: calc(24px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-7 {
line-height: calc(28px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-8 {
line-height: calc(32px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-9 {
line-height: calc(36px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-10 {
line-height: calc(40px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[18px\] {
line-height: calc(18px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[21px\] {
line-height: calc(21px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[22px\] {
line-height: calc(22px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[23px\] {
line-height: calc(23px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[26px\] {
line-height: calc(26px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[30px\] {
line-height: calc(30px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[33px\] {
line-height: calc(33px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[34px\] {
line-height: calc(34px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[42px\] {
line-height: calc(42px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[44px\] {
line-height: calc(44px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[47px\] {
line-height: calc(47px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[59px\] {
line-height: calc(59px * var(--_u)) !important;
}
/* PC 卡片文案:字号 × --page-unscale,抵消 design-frame zoom,缩小窗口时视觉大小不变 */
.media-card-title {
font-size: calc(24px * var(--page-unscale, 1));
line-height: calc(32px * var(--page-unscale, 1));
/* 必须 overflow:hidden,否则 truncate 省略号在部分浏览器不生效 */
overflow: hidden;
}
.media-card-title-md {
font-size: calc(20px * var(--page-unscale, 1));
line-height: calc(28px * var(--page-unscale, 1));
overflow: hidden;
}
.media-card-title-lg {
font-size: calc(32px * var(--page-unscale, 1));
line-height: calc(41px * var(--page-unscale, 1));
overflow: hidden;
}
.media-card-desc {
font-size: calc(14px * var(--page-unscale, 1));
line-height: calc(20px * var(--page-unscale, 1));
overflow: hidden;
}
.media-card-desc-md {
font-size: calc(16px * var(--page-unscale, 1));
line-height: calc(22px * var(--page-unscale, 1));
}
.media-card-desc-sm {
font-size: 12px;
line-height: 18px;
}
.media-card-meta {
font-size: calc(14px * var(--page-unscale, 1));
line-height: calc(20px * var(--page-unscale, 1));
}
.media-card-episodes {
font-size: calc(14px * var(--page-unscale, 1));
line-height: calc(20px * var(--page-unscale, 1));
}
/* H5 窄卡:标签 / 热度 12px(H5 无 page-scale) */
.media-card-meta-sm {
font-size: 12px;
line-height: 18px;
}
/*
* 样式 H 封面/榜单文案:× --page-unscale,不参与 design-frame zoom 缩小。
* 卡面需 overflow-hidden + 截断,避免反缩放后长文案撑破。
*/
.style-h-board-title {
font-size: calc(32px * var(--page-unscale, 1));
line-height: calc(42px * var(--page-unscale, 1));
}
.style-h-board-sub {
font-size: calc(16px * var(--page-unscale, 1));
line-height: calc(22px * var(--page-unscale, 1));
}
.style-h-board-item-title {
font-size: calc(24px * var(--page-unscale, 1));
line-height: calc(32px * var(--page-unscale, 1));
}
.style-h-board-item-heat {
font-size: calc(14px * var(--page-unscale, 1));
line-height: calc(20px * var(--page-unscale, 1));
}
/* --page-scale is written by the page-scale script in app/layout.tsx (viewport width / 1920, capped at 1, and 1 below the lg breakpoint), along with its reciprocal --page-unscale. */
.design-frame {
width: 1700px;
margin-inline: auto;
zoom: var(--page-scale, 1);
}
/* Cancels the .design-frame zoom so the element keeps its design size. */
.design-unscaled {
zoom: var(--page-unscale, 1);
}
.design-gutter {
padding-inline: max(24px, 50% - 850px * var(--page-scale, 1));
}
.xg-theme.xgplayer {
background: #000;
}
/* QQ / 夸克 / UC:隐藏原生 media controls,强制走 xgplayer 控件 */
html.is-cn-browser .xg-theme.xgplayer video::-webkit-media-controls {
display: none !important;
}
html.is-cn-browser .xg-theme.xgplayer video::-webkit-media-controls-enclosure {
display: none !important;
}
html.is-cn-browser .xg-theme.xgplayer video::-webkit-media-controls-panel {
display: none !important;
}
html.is-cn-browser .xg-theme.xgplayer video::-webkit-media-controls-start-playback-button {
display: none !important;
-webkit-appearance: none;
}
html.is-cn-browser .xg-theme.xgplayer.xgplayer-is-cssfullscreen {
position: fixed !important;
inset: 0 !important;
width: 100vw !important;
height: 100vh !important;
height: var(--app-vh, 100dvh) !important;
max-width: none !important;
max-height: none !important;
z-index: 200 !important;
border-radius: 0 !important;
}
.xg-theme.xgplayer .xgplayer-loading svg path,
.xg-theme.xgplayer .xgplayer-loading svg .path {
stroke: #f12b9e;
}
.xg-theme.xgplayer .xgplayer-start,
.xg-theme.xgplayer.xgplayer-mobile .xgplayer-start {
width: 72px;
height: 72px;
background: transparent;
transition: transform 0.2s ease, opacity 0.2s ease, filter 0.2s ease;
}
.xg-theme.xgplayer .xgplayer-start:hover {
opacity: 1;
transform: translate(-50%, -50%) scale(1.06);
filter: drop-shadow(0 0 12px rgb(241 43 158 / 0.35));
}
.xg-theme.xgplayer .xgplayer-start:active {
transform: translate(-50%, -50%) scale(0.96);
}
.xg-theme.xgplayer .xgplayer-start xg-start-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 9999px;
background: transparent;
overflow: hidden;
}
.xg-theme.xgplayer .xgplayer-start img,
.xg-theme.xgplayer .xgplayer-start svg {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
@media (max-width: 1023px) {
.xg-theme.xgplayer .xgplayer-start,
.xg-theme.xgplayer.xgplayer-mobile .xgplayer-start {
width: 64px;
height: 64px;
}
}
.xg-theme.xgplayer .xgplayer-drag,
.xg-theme.xgplayer .xgplayer-progress-played,
.xg-theme.xgplayer .xgplayer-switch-active {
background: #f12b9e;
}
.xg-theme.xgplayer .xgplayer-playbackrate ul li.selected,
.xg-theme.xgplayer .xgplayer-definition ul li.selected {
color: #f12b9e;
}
@keyframes notice-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.notice-marquee-track {
/* duration 由组件按内容宽度写入,保证线速度恒定 */
animation: notice-marquee 12s linear infinite;
}
.ad-icon-marquee-track {
/* duration 由组件按内容宽度写入,保证线速度恒定;从右向左无缝滚动 */
animation: notice-marquee 20s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.notice-marquee-track,
.ad-icon-marquee-track {
animation: none;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
/** 部署健康检查(对齐 hkdj-web /healthz) */
export async function GET() {
return NextResponse.json({
status: "ok",
environment: process.env.APP_ENV || process.env.NODE_ENV || "unknown",
time: new Date().toISOString(),
});
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { HelpPage } from "@/components/help/help-page";
import { HELP_RICHTEXT_TITLES, loadRichTextFirst } from "@/components/richtext/load-richtext";
import { JsonLd } from "@/components/seo/json-ld";
import { buildWebPageJSONLD, getHomeBaseSEOCached, mergeSEO, resolvePageMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
const data = await loadRichTextFirst([...HELP_RICHTEXT_TITLES]);
return resolvePageMetadata({
seoInfo: mergeSEO(data?.seoInfo, {
title: "帮助中心",
description:
data?.seoInfo?.description?.trim() ||
data?.desc?.trim() ||
"查看海角社区常见问题、播放帮助与客服支持信息。",
}),
canonical: "/help",
});
}
async function HelpRouteContent() {
const data = await loadRichTextFirst([...HELP_RICHTEXT_TITLES]);
const siteSeo = await getHomeBaseSEOCached();
const seo = mergeSEO(data?.seoInfo, siteSeo.seo);
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/help",
title: seo.title || "帮助中心",
description:
seo.description ||
data?.desc ||
data?.title ||
"查看海角社区常见问题、播放帮助与客服支持信息。",
type: "WebPage",
breadcrumbs: [
["首页", "/"],
["帮助中心", "/help"],
],
siteName: siteSeo.siteName,
})}
/>
<HelpPage data={data} />
</>
);
}
export default function Page() {
return (
<Suspense fallback={<PageLoading />}>
<HelpRouteContent />
</Suspense>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { HistoryPage } from "@/components/history/history-page";
export const metadata: Metadata = {
title: "观看历史 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <HistoryPage />;
}
+148
View File
@@ -0,0 +1,148 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script";
import { Suspense } from "react";
import { AdDialogSlot } from "@/components/ad/ad-dialog-slot";
import { AddressDialogSlot } from "@/components/address/address-dialog-slot";
import { AgeGateDialogHost } from "@/components/age-gate/age-gate-dialog";
import { TrackerHost } from "@/components/analytics/tracker-host";
import { RequestHeadersPrime } from "@/components/api/request-headers-prime";
import { AuthUnauthorizedBridge } from "@/components/auth/auth-unauthorized-bridge";
import { GuestAuthBootstrap } from "@/components/auth/guest-auth-bootstrap";
import { LoginDialogHost } from "@/components/auth/login-dialog";
import { ToastHost } from "@/components/common/toast-host";
import { HomeBaseDebugSlot } from "@/components/debug/home-base-debug-slot";
import { DownloadDialogSlot } from "@/components/download/download-dialog-slot";
import { SiteShell } from "@/components/layout/site-shell";
import { browserCompatScript } from "@/lib/browser/compat-inline";
import { getRequestOrigin, OG_DEFAULT_IMAGE } from "@/lib/seo";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const pageScaleScript = `(function () {
var style = document.createElement("style");
document.head.appendChild(style);
function update() {
var width = document.documentElement.clientWidth;
var scale = width < 1024 ? 1 : Math.min(1, width / 1920);
style.textContent = ":root{--page-scale:" + scale + ";--page-unscale:" + 1 / scale + "}";
}
update();
window.addEventListener("resize", update);
})();`;
/** GA4 测量 ID(全站 head 注入) */
const GA4_MEASUREMENT_ID = "G-748CPNEVP5";
/** metadataBase 随请求 Host 变化,保证 rel=canonical 与访问地址一致 */
export async function generateMetadata(): Promise<Metadata> {
const origin = await getRequestOrigin();
return {
metadataBase: new URL(origin),
title: {
default: "海角社区",
template: "%s",
},
description: "发现好故事,开启追剧时光",
applicationName: "海角社区",
verification: {
google: "0Bby47n1AEIB3W8wtLZ-oFQ3l8xRqRT78oA0JVRJ5pM",
yandex: "69b55a6aa9ee9a5f",
other: {
"msvalidate.01": "431F89B229D3A00661DAF708E5C24BC0",
},
},
openGraph: {
type: "website",
locale: "zh_CN",
siteName: "海角社区",
images: [{ url: OG_DEFAULT_IMAGE, alt: "海角社区" }],
},
twitter: {
card: "summary_large_image",
images: [OG_DEFAULT_IMAGE],
},
icons: {
// SVG 优先(现代浏览器),PNG / ICO 兜底
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/favicon.png", type: "image/png", sizes: "512x512" },
{ url: "/favicon.ico", type: "image/x-icon", sizes: "32x32" },
],
shortcut: [{ url: "/favicon.ico", type: "image/x-icon" }],
apple: [{ url: "/favicon.png", type: "image/png", sizes: "180x180" }],
},
};
}
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="zh-CN"
className={`dark ${geistSans.variable} ${geistMono.variable} h-full scroll-pt-14 antialiased lg:scroll-pt-20`}
data-theme="dark"
// beforeInteractive 会给 html 加 is-cn-browser 等 class,需压制告警
suppressHydrationWarning
>
<head>
{/* Google Analytics (GA4) */}
<Script
async
src={`https://www.googletagmanager.com/gtag/js?id=${GA4_MEASUREMENT_ID}`}
strategy="afterInteractive"
/>
<Script id="ga4-gtag" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA4_MEASUREMENT_ID}');
`.trim()}
</Script>
</head>
<body className="flex min-h-full flex-col">
<Suspense fallback={null}>
<RequestHeadersPrime />
</Suspense>
<Suspense fallback={null}>
<TrackerHost />
</Suspense>
<Suspense fallback={null}>
<HomeBaseDebugSlot />
</Suspense>
<SiteShell>{children}</SiteShell>
<GuestAuthBootstrap />
<AuthUnauthorizedBridge />
<LoginDialogHost />
<Suspense fallback={null}>
<DownloadDialogSlot />
</Suspense>
<Suspense fallback={null}>
<AdDialogSlot />
</Suspense>
<Suspense fallback={null}>
<AddressDialogSlot />
</Suspense>
<Suspense fallback={null}>
<AgeGateDialogHost />
</Suspense>
<ToastHost />
<Script id="browser-compat" strategy="beforeInteractive">
{browserCompatScript}
</Script>
<Script id="page-scale" strategy="beforeInteractive">
{pageScaleScript}
</Script>
</body>
</html>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { LikesPage } from "@/components/likes/likes-page";
export const metadata: Metadata = {
title: "我的点赞 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <LikesPage />;
}
@@ -0,0 +1,5 @@
import { PageLoading } from "@/components/common/page-loading";
export default function Loading() {
return <PageLoading className="flex-1 bg-[#07060E]" />;
}
+88
View File
@@ -0,0 +1,88 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { loadMorePage } from "@/components/more/data";
import { MorePage } from "@/components/more/more-page";
import { moreListHref, parseMorePageSegment } from "@/components/more/route";
import { JsonLd } from "@/components/seo/json-ld";
import { dramaHref } from "@/components/short-drama/data";
import { videoHref } from "@/components/video/video-card";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
type Params = Promise<{ id: string; page?: string[] }>;
function decodeId(id: string) {
try {
return decodeURIComponent(id);
} catch {
return id;
}
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { id, page } = await params;
const configId = decodeId(id).trim();
const pageNum = parseMorePageSegment(page) ?? 1;
if (!configId) return resolvePageMetadata({ fallbackTitle: "更多内容" });
try {
const data = await loadMorePage(configId, 1);
const pathKey = data.configId || configId;
return resolvePageMetadata({
seoInfo: {
title: data.title,
description: data.subtitle || `浏览海角社区「${data.title}」更多精选内容。`,
keyworld: ["海角社区", data.title],
},
page: pageNum,
canonical: moreListHref(pathKey, pageNum),
});
} catch {
return resolvePageMetadata({
fallbackTitle: "更多内容",
page: pageNum,
canonical: moreListHref(configId, pageNum),
});
}
}
export default async function Page({ params }: { params: Params }) {
const { id, page: pageSegment } = await params;
const configId = decodeId(id).trim();
if (!configId) notFound();
const page = parseMorePageSegment(pageSegment);
if (page == null) notFound();
if (page === 1 && pageSegment?.length) {
redirect(moreListHref(configId, 1));
}
const [data, siteSeo] = await Promise.all([loadMorePage(configId, page), getHomeBaseSEOCached()]);
// 有 slug 时规范到 /more/{slug}(旧 /more/{id} 301)
if (data.configId && data.configId !== configId) {
redirect(moreListHref(data.configId, page));
}
const pathKey = data.configId || configId;
const itemListUrls = data.items
.filter((item) => item.kind === "drama")
.slice(0, 24)
.map((item) => (data.tab === "视频" ? videoHref(item.id, item.slug) : dramaHref(item.id, item.slug)));
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: moreListHref(pathKey, page),
title: data.title,
description: data.subtitle || `浏览海角社区「${data.title}」更多精选内容。`,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
[data.title, moreListHref(pathKey, 1)],
],
itemListUrls,
siteName: siteSeo.siteName,
})}
/>
<MorePage data={data} />
</>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Suspense, type ReactNode } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { emptyMorePage, parseMoreTab } from "@/components/more/data";
import { MorePage } from "@/components/more/more-page";
import { moreListHref } from "@/components/more/route";
/** 无 id 的空壳页;正式内容在 /more/{id},本页不索引 */
export const metadata: Metadata = {
title: "更多内容 - 海角社区",
robots: { index: false, follow: false },
};
type SearchParams = Promise<{ id?: string | string[]; type?: string | string[]; page?: string | string[] }>;
function first(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
/** 兼容旧链接 /more?id=xx&page=n → /more/xx[/n] */
async function MoreQueryBody({ searchParams }: { searchParams: SearchParams }): Promise<ReactNode> {
const params = await searchParams;
const id = first(params.id)?.trim();
if (id) {
const page = Math.max(1, Number(first(params.page)) || 1);
redirect(moreListHref(id, page));
}
return <MorePage data={emptyMorePage(parseMoreTab(first(params.type)))} />;
}
export default function Page({ searchParams }: { searchParams: SearchParams }) {
return (
<Suspense fallback={<PageLoading />}>
<MoreQueryBody searchParams={searchParams} />
</Suspense>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata } from "next";
import { NavPage } from "@/components/nav/nav-page";
import { JsonLd } from "@/components/seo/json-ld";
import { getHomeBaseCached } from "@/lib/api/home";
import type { AppTranslationInfo, ConfigInfo } from "@/lib/api/types";
import { buildWebPageJSONLD, mergeSEO, resolvePageMetadata } from "@/lib/seo";
async function loadNavBase(): Promise<{
config?: ConfigInfo;
bottonTranslations?: AppTranslationInfo;
}> {
try {
const base = await getHomeBaseCached();
return {
config: base.config,
bottonTranslations: base.bottonTranslations,
};
} catch (error) {
console.warn("[nav] home/base failed", error);
return {};
}
}
export async function generateMetadata(): Promise<Metadata> {
const { config } = await loadNavBase();
// 导航页优先 dh*,缺字段再回退 home/base 的 web*
return resolvePageMetadata({
seoInfo: {
title: config?.dhTitle,
description: config?.dhDescription,
keyworld: config?.dhKeyworld,
},
siteName: config?.dhSiteName?.trim() || undefined,
fallbackTitle: `${config?.dhSiteName?.trim() || "海角社区"} · 官方导航`,
fallbackDescription: `${config?.dhSiteName?.trim() || "海角社区"}官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。`,
fallbackKeywords: [config?.dhSiteName || "海角社区", "官方导航", "最新地址"].filter(Boolean) as string[],
canonical: "/nav",
});
}
export default async function Page() {
const { config, bottonTranslations } = await loadNavBase();
const siteName = config?.dhSiteName?.trim() || config?.webSiteName?.trim() || "海角社区";
const seo = mergeSEO(
{ title: config?.dhTitle, description: config?.dhDescription, keyworld: config?.dhKeyworld },
{ title: config?.webTitle, description: config?.webDescription, keyworld: config?.webKeyworld },
);
const title = seo.title || `${siteName} · 官方导航`;
const description =
seo.description || `${siteName}官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。`;
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/nav",
title,
description,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
["官方导航", "/nav"],
],
siteName,
})}
/>
<NavPage config={config} bottonTranslations={bottonTranslations} />
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import Link from "next/link";
import { GoBackButton } from "@/components/not-found/go-back-button";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
export const metadata: Metadata = {
title: "页面不存在 - 海角社区",
robots: { index: false, follow: false },
};
const actionClass =
"flex h-11 w-[120px] items-center justify-center rounded-[22px] text-sm leading-5 font-semibold transition-opacity hover:opacity-90";
export default function NotFound() {
return (
<main data-component="not-found" className="flex flex-1 flex-col items-center justify-center gap-3.5 bg-[radial-gradient(ellipse_50%_50%_at_50%_50%,#210F2E_0%,#07060E_100%)] px-4 py-16 text-center lg:min-h-[603px] lg:gap-[18px] lg:px-[110px] lg:pt-[92px] lg:pb-[72px]">
<SeoBreadcrumb items={[{ name: "首页", href: "/" }, { name: "页面不存在" }]} />
<p className="text-[88px] leading-[92px] font-semibold text-[#F12B9E] lg:text-[136px] lg:leading-[142px]">404</p>
<span className="rounded-full bg-[#F12B9E]/14 px-3.5 py-2 text-xs leading-[18px] font-semibold text-[#F12B9E]">
页面走丢了
</span>
<h1 className="text-xl leading-7 font-semibold text-white lg:text-[28px] lg:leading-[34px]">抱歉,没有找到这个页面</h1>
<p className="text-xs leading-[18px] text-[#948CA8] lg:text-sm lg:leading-5">页面可能已被删除、链接已失效,或暂时无法访问</p>
<form
action="/search"
role="search"
className="flex w-full max-w-[480px] items-center gap-2 rounded-[23px] bg-[#110F1F] py-0.5 pr-0.5 pl-3 outline outline-1 -outline-offset-1 outline-[#383657] focus-within:outline-[#F12B9E]/60"
>
<input
type="search"
name="q"
placeholder="搜索想看的短剧"
aria-label="搜索想看的短剧"
className="min-w-0 flex-1 bg-transparent text-xs leading-[18px] text-white outline-none placeholder:text-[#7A738F]"
/>
<button
type="submit"
className="cursor-pointer h-10 w-[72px] shrink-0 rounded-[22px] bg-[#F12B9E] text-sm leading-5 font-semibold text-white transition-opacity hover:opacity-90 lg:h-11 lg:w-[84px]"
>
搜索
</button>
</form>
<div className="flex gap-3">
<Link href="/" className={`${actionClass} bg-[#F12B9E] text-white`}>
返回首页
</Link>
<GoBackButton
className={`${actionClass} bg-[#211F38] text-[#D1CCE0] outline outline-1 -outline-offset-1 outline-[#4D476B]`}
/>
</div>
</main>
);
}
+93
View File
@@ -0,0 +1,93 @@
import type { Metadata } from "next";
import { cache } from "react";
import { HomeDataDebug } from "@/components/home/home-data-debug";
import { HomePage } from "@/components/home/home-page";
import { JsonLd } from "@/components/seo/json-ld";
import { dramaHref } from "@/components/short-drama/data";
import { getHomeBaseCached } from "@/lib/api/home";
import { getPlayletHomeCached } from "@/lib/api/playlet";
import type { AdvitiseData, ConfigInfo, HomePlayletResponse, PlayletData } from "@/lib/api/types";
import {
buildWebPageJSONLD,
getHomeBaseSEOCached,
mergeSEO,
resolvePageMetadata,
} from "@/lib/seo";
function summarizeFetchError(error: unknown) {
if (!(error instanceof Error)) return String(error);
const cause = (error as Error & { cause?: { code?: string; message?: string } }).cause;
const code = cause?.code || (error as Error & { code?: string }).code;
if (code) return `${error.message} (${code})`;
return error.message;
}
/** 同请求内 metadata + page 共用一次结果 */
const loadHome = cache(async (): Promise<HomePlayletResponse | null> => {
try {
return await getPlayletHomeCached();
} catch (error) {
console.warn(`[playlet/home] ${summarizeFetchError(error)}; fallback to local mock`);
return null;
}
});
const loadHomeBase = cache(async (): Promise<{ advitise?: AdvitiseData; config?: ConfigInfo }> => {
try {
const base = await getHomeBaseCached();
return { advitise: base.advitise, config: base.config };
} catch (error) {
console.warn(`[home/base] ${summarizeFetchError(error)}`);
return {};
}
});
export async function generateMetadata(): Promise<Metadata> {
const home = await loadHome();
// seoInfo → home/base 公共 web* → 代码兜底
return resolvePageMetadata({
seoInfo: home?.seoInfo,
fallbackTitle: "海角社区 - 热门短剧在线看",
fallbackDescription: "海角社区汇集热门短剧、最近上新、排行榜与精选专题推荐,支持高清在线播放。",
fallbackKeywords: ["海角社区", "短剧", "在线短剧", "热门短剧"],
canonical: "/",
});
}
export default async function Home() {
const [home, base, siteSeo] = await Promise.all([loadHome(), loadHomeBase(), getHomeBaseSEOCached()]);
const merged = home
? { ...home, advitise: base.advitise ?? home.advitise }
: base.advitise
? { advitise: base.advitise }
: null;
const seo = mergeSEO(home?.seoInfo, siteSeo.seo);
const homeTitle = seo.title || "海角社区 - 热门短剧在线看";
const homeDescription =
seo.description || "海角社区汇集热门短剧、最近上新、排行榜与精选专题推荐,支持高清在线播放。";
const itemListUrls =
home?.homeBannerData
?.filter((item): item is PlayletData & { id: number } => Boolean(item?.id))
.slice(0, 12)
.map((item) => dramaHref(item.id, item.slug)) ?? [];
return (
<>
<HomeDataDebug home={merged} base={base} />
<JsonLd
data={buildWebPageJSONLD({
path: "/",
title: homeTitle,
description: homeDescription,
type: "CollectionPage",
breadcrumbs: [["首页", "/"]],
itemListUrls,
siteName: siteSeo.siteName,
})}
/>
<HomePage home={merged} heading={homeTitle} />
</>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { JsonLd } from "@/components/seo/json-ld";
import { PrivacyPage } from "@/components/privacy/privacy-page";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
seoInfo: {
title: "隐私政策",
description: "查看海角社区隐私政策与个人信息保护说明。",
},
canonical: "/privacy",
});
}
export default async function Page() {
const siteSeo = await getHomeBaseSEOCached();
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/privacy",
title: "隐私政策",
description: "查看海角社区隐私政策与个人信息保护说明。",
type: "WebPage",
breadcrumbs: [
["首页", "/"],
["隐私政策", "/privacy"],
],
siteName: siteSeo.siteName,
})}
/>
<PrivacyPage />
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { ProfilePage } from "@/components/profile/profile-page";
export const metadata: Metadata = {
title: "个人中心 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <ProfilePage />;
}
+124
View File
@@ -0,0 +1,124 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import {
buildRankingList,
rankingDataType,
rankingTabLabels,
rankingTabs,
RANKING_PAGE_SIZE,
type RankingListResult,
type RankingTab,
} from "@/components/ranking/data";
import { RankingPage } from "@/components/ranking/ranking-page";
import {
parseRankingPageSegment,
parseRankingTab,
rankingHref,
resolveRankingTab,
} from "@/components/ranking/route";
import { JsonLd } from "@/components/seo/json-ld";
import { dramaHref } from "@/components/short-drama/data";
import { videoHref } from "@/components/video/video-card";
import { getPlayletContentListCached } from "@/lib/api/playlet";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
type Params = Promise<{ type: string; page?: string[] }>;
export function generateStaticParams() {
return rankingTabs.map((type) => ({ type }));
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { type, page } = await params;
const tab = parseRankingTab(decodeType(type));
const tabText = rankingTabLabels[tab];
const pageNum = parseRankingPageSegment(page) ?? 1;
return resolvePageMetadata({
seoInfo: {
title: `${tabText}排行榜`,
description: `查看海角社区实时更新的${tabText}排行榜,浏览热播内容与精选推荐。`,
keyworld: ["海角社区", "排行榜", tabText],
},
page: pageNum,
canonical: rankingHref(tab, pageNum),
});
}
function decodeType(type: string) {
try {
return decodeURIComponent(type);
} catch {
return type;
}
}
async function loadRanking(tab: RankingTab, page: number): Promise<RankingListResult> {
const dataType = rankingDataType(tab);
try {
const data = await getPlayletContentListCached({
dataType,
pageNum: page,
pageSize: RANKING_PAGE_SIZE,
});
return buildRankingList(data, dataType, page, RANKING_PAGE_SIZE);
} catch (error) {
console.error("[ranking]", error);
return buildRankingList(null, dataType, page, RANKING_PAGE_SIZE);
}
}
async function RankingContent({ params }: { params: Params }) {
const { type, page: pageSegment } = await params;
const rawType = decodeType(type);
const tab = resolveRankingTab(rawType);
if (!tab) notFound();
const page = parseRankingPageSegment(pageSegment);
if (page == null) notFound();
// 旧中文 tab → 英文规范 URL
if (rawType !== tab) {
redirect(rankingHref(tab, page));
}
if (page === 1 && pageSegment?.length) {
redirect(rankingHref(tab, 1));
}
const tabText = rankingTabLabels[tab];
const [list, siteSeo] = await Promise.all([loadRanking(tab, page), getHomeBaseSEOCached()]);
const itemListUrls = list.items
.filter((item) => item.kind === "drama")
.slice(0, 24)
.map((item) => (tab === "video" ? videoHref(item.id, item.slug) : dramaHref(item.id, item.slug)));
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: rankingHref(tab, page),
title: `${tabText}排行榜`,
description: `查看海角社区实时更新的${tabText}排行榜,浏览热播内容与精选推荐。`,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
["排行榜", rankingHref(tab, 1)],
],
itemListUrls,
siteName: siteSeo.siteName,
})}
/>
<RankingPage tab={tab} list={list} />
</>
);
}
export default function Page({ params }: { params: Params }) {
return (
<Suspense fallback={<PageLoading />}>
<RankingContent params={params} />
</Suspense>
);
}
+63
View File
@@ -0,0 +1,63 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { toRichTextPath } from "@/components/layout/nav-href";
import { loadRichText, normalizeRichTextTitle } from "@/components/richtext/load-richtext";
import { RichTextPage } from "@/components/richtext/richtext-page";
import { JsonLd } from "@/components/seo/json-ld";
import { buildWebPageJSONLD, getHomeBaseSEOCached, mergeSEO, resolvePageMetadata } from "@/lib/seo";
type Params = Promise<{ title: string }>;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const title = normalizeRichTextTitle((await params).title);
const data = await loadRichText(title);
if (!data) return resolvePageMetadata({ fallbackTitle: "详情" });
const pathTitle = data.title?.trim() || title;
return resolvePageMetadata({
seoInfo: mergeSEO(data.seoInfo, {
title: data.desc?.trim() || pathTitle || "详情",
description: data.desc || data.title,
}),
ogType: "article",
canonical: toRichTextPath(pathTitle),
});
}
async function RichTextRouteContent({ params }: { params: Params }) {
const title = normalizeRichTextTitle((await params).title);
const data = await loadRichText(title);
if (!data) notFound();
const pathTitle = data.title?.trim() || title;
const siteSeo = await getHomeBaseSEOCached();
const seo = mergeSEO(data.seoInfo, siteSeo.seo);
const pageTitle = seo.title || data.desc?.trim() || pathTitle || "详情";
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: toRichTextPath(pathTitle),
title: pageTitle,
description: seo.description || data.desc || data.title || pageTitle,
type: "WebPage",
breadcrumbs: [
["首页", "/"],
[pageTitle, toRichTextPath(pathTitle)],
],
siteName: siteSeo.siteName,
})}
/>
<RichTextPage data={data} />
</>
);
}
export default function Page({ params }: { params: Params }) {
return (
<Suspense fallback={<PageLoading />}>
<RichTextRouteContent params={params} />
</Suspense>
);
}
+51
View File
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { Suspense, type ReactNode } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { toRichTextPath } from "@/components/layout/nav-href";
import { loadRichText, normalizeRichTextTitle } from "@/components/richtext/load-richtext";
import { mergeSEO, resolvePageMetadata } from "@/lib/seo";
type SearchParams = Promise<{ title?: string | string[] }>;
function first(value?: string | string[]) {
return Array.isArray(value) ? value[0] : value;
}
/** 查询参数模式统一跳转到路径模式 /richtext/{title};本页仅跳转,不收录 */
export async function generateMetadata({ searchParams }: { searchParams: SearchParams }): Promise<Metadata> {
const title = normalizeRichTextTitle(first((await searchParams).title));
if (!title) {
return resolvePageMetadata({ fallbackTitle: "详情", robots: { index: false, follow: false } });
}
const data = await loadRichText(title);
if (!data) {
return resolvePageMetadata({ fallbackTitle: "详情", robots: { index: false, follow: false } });
}
return resolvePageMetadata({
seoInfo: mergeSEO(data.seoInfo, {
title: data.desc?.trim() || data.title?.trim() || "详情",
description: data.desc || data.title,
}),
robots: { index: false, follow: false },
});
}
async function RichTextQueryRedirect({
searchParams,
}: {
searchParams: SearchParams;
}): Promise<ReactNode> {
const title = normalizeRichTextTitle(first((await searchParams).title));
if (!title) notFound();
redirect(toRichTextPath(title));
}
export default function Page({ searchParams }: { searchParams: SearchParams }) {
return (
<Suspense fallback={<PageLoading />}>
<RichTextQueryRedirect searchParams={searchParams} />
</Suspense>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { MetadataRoute } from "next";
import { getHomeBaseCached } from "@/lib/api/home";
import { getRequestOrigin, getSiteOrigin } from "@/lib/seo/site";
/** 从地址字符串解析 origin(支持无协议域名) */
function originFromAddr(value?: string | null) {
const text = value?.trim();
if (!text) return undefined;
try {
const withProto = /^https?:\/\//i.test(text) ? text : `https://${text}`;
return new URL(withProto).origin;
} catch {
return undefined;
}
}
/** 基础 robots:私有页 / 搜索 disallow,并声明 sitemap(永久域名取 config.foreverUrlAddr) */
export default async function robots(): Promise<MetadataRoute.Robots> {
const forever = originFromAddr((await getHomeBaseCached()).config?.foreverUrlAddr);
const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim();
const origin = forever || (configured ? getSiteOrigin() : await getRequestOrigin());
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/profile",
"/history",
"/favorites",
"/likes",
"/settings",
"/feedback",
// 前缀匹配:/search、/search/短剧/... 均不抓
"/search",
],
},
],
sitemap: `${origin.replace(/\/$/, "")}/sitemap.xml`,
};
}
@@ -0,0 +1,72 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { connection } from "next/server";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { loadSearchPage } from "@/components/search/data";
import { parseSearchListPath, searchListHref } from "@/components/search/route";
import { SearchPage } from "@/components/search/search-page";
import { buildServerForwardHeaders } from "@/lib/api/client-headers";
import { primeRequestForwardHeaders } from "@/lib/api/request-context";
import { resolvePageMetadata } from "@/lib/seo";
type Params = Promise<{ type: string; slug?: string[] }>;
const SEARCH_ROBOTS = { index: false, follow: false } as const;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { type, slug } = await params;
const parsed = parseSearchListPath(type, slug);
if (!parsed) {
return resolvePageMetadata({
fallbackTitle: "搜索",
robots: SEARCH_ROBOTS,
});
}
return resolvePageMetadata({
seoInfo: {
title: `搜索「${parsed.query}」`,
description: `在海角社区搜索「${parsed.query}」的${parsed.type}结果。`,
keyworld: ["海角社区", "搜索", parsed.query, parsed.type],
},
page: parsed.page,
canonical: searchListHref(parsed.query, parsed.type, parsed.page),
// 搜索结果无限 URL / 薄内容,不参与收录
robots: SEARCH_ROBOTS,
});
}
async function SearchRouteContent({ params }: { params: Params }) {
await connection();
try {
primeRequestForwardHeaders(await buildServerForwardHeaders());
} catch {
/* ignore */
}
const { type, slug } = await params;
const parsed = parseSearchListPath(type, slug);
if (!parsed) notFound();
// /search/短剧/foo/1 → /search/短剧/foo
if (parsed.page === 1 && (slug?.length ?? 0) > 1) {
redirect(searchListHref(parsed.query, parsed.type, 1));
}
const list = await loadSearchPage(parsed.query, parsed.type, parsed.page);
// 页码超出时回落到末页(有结果时)
if (list.totalCount > 0 && parsed.page > list.totalPages) {
redirect(searchListHref(parsed.query, parsed.type, list.totalPages));
}
return <SearchPage list={list} />;
}
export default function Page({ params }: { params: Params }) {
return (
<Suspense fallback={<PageLoading />}>
<SearchRouteContent params={params} />
</Suspense>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { buildSearchList, parseSearchType } from "@/components/search/data";
import { searchListHref } from "@/components/search/route";
import { SearchPage } from "@/components/search/search-page";
import { resolvePageMetadata } from "@/lib/seo";
type SearchParams = Promise<{ q?: string | string[]; type?: string | string[]; page?: string | string[] }>;
function first(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
fallbackTitle: "搜索",
fallbackDescription: "搜索海角社区短剧与视频。",
fallbackKeywords: ["海角社区", "搜索"],
canonical: "/search",
// 搜索入口不收录;robots.txt 同步 Disallow /search
robots: { index: false, follow: false },
});
}
/** 兼容旧链接 /search?q=&type=&page= → /search/{type}/{q}[/{page}] */
async function SearchIndexContent({ searchParams }: { searchParams: SearchParams }) {
const params = await searchParams;
const query = first(params.q)?.trim() ?? "";
const type = parseSearchType(first(params.type));
const page = Number(first(params.page)) || 1;
if (query) {
redirect(searchListHref(query, type, page > 1 ? page : 1));
}
return <SearchPage list={buildSearchList(null, "", type, 1)} />;
}
export default function Page({ searchParams }: { searchParams: SearchParams }) {
return (
<Suspense fallback={<PageLoading />}>
<SearchIndexContent searchParams={searchParams} />
</Suspense>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { SettingsPage } from "@/components/settings/settings-page";
export const metadata: Metadata = {
title: "账号设置 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <SettingsPage />;
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { EditProfilePage } from "@/components/settings/edit-profile-page";
export const metadata: Metadata = {
title: "编辑个人资料 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <EditProfilePage />;
}
@@ -0,0 +1,427 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { connection } from "next/server";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import {
dramaDetailHref,
mapPlayletChapterPlay,
mapPlayletInfo,
mapPlayletRecommendList,
parseEpisode,
playHref,
RECOMMEND_DISPLAY_COUNT,
resolvePlayURL,
type DramaDetail,
type DramaPlayView,
type RelatedDrama,
} from "@/components/drama-detail/data";
import { DramaDetailPage } from "@/components/drama-detail/drama-detail-page";
import { RelatedAside } from "@/components/drama-detail/related-aside";
import { DramaPlayPage } from "@/components/drama-play/drama-play-page";
import { JsonLd } from "@/components/seo/json-ld";
import {
buildShortDramaList,
dramaHref,
PAGE_SIZE,
shortDramaSortType,
sortLabels,
sorts,
type ShortDramaListResult,
type Sort,
} from "@/components/short-drama/data";
import {
parseShortDramaListPath,
parseShortDramaSortSegment,
shortDramaListHref,
} from "@/components/short-drama/route";
import { ShortDramaPage } from "@/components/short-drama/short-drama-page";
import {
getPlayletChapterInfoCached,
getPlayletInfoCached,
getPlayletListCached,
getPlayletRecommendListCached,
getPlayletThemeListCached,
} from "@/lib/api/playlet";
import type { PlayletThemeItem } from "@/lib/api/types";
import { buildServerForwardHeaders } from "@/lib/api/client-headers";
import { primeRequestForwardHeaders } from "@/lib/api/request-context";
import { AdBannersSlot, AdIconsSlot } from "@/components/common/ads/slots";
import {
buildPlayletDetailJSONLD,
buildPlayletPlayJSONLD,
buildWebPageJSONLD,
getHomeBaseSEOCached,
mergeSEO,
resolveContentMetadata,
resolvePageMetadata,
} from "@/lib/seo";
type Params = Promise<{ sort: string; page?: string[] }>;
/** 详情 / 播放「更多好剧」:多取几条,排除当前剧后仍够展示 */
const RECOMMEND_PAGE_SIZE = RECOMMEND_DISPLAY_COUNT + 4;
export function generateStaticParams() {
return sorts.map((sort) => ({ sort }));
}
function decodeSegment(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function playletLookup(idOrSlug: string): { id?: string; slug?: string } {
if (/^\d+$/.test(idOrSlug)) return { id: idOrSlug };
return { slug: idOrSlug };
}
/** 更多好剧:GET /api/app/playlet/recommend/list(sortList=1 推荐) */
async function loadPlayletRecommend(excludeId?: number | string | null): Promise<RelatedDrama[]> {
try {
const data = await getPlayletRecommendListCached({
pageNum: 1,
pageSize: RECOMMEND_PAGE_SIZE,
sortList: 1,
});
return mapPlayletRecommendList(data, { excludeId, limit: RECOMMEND_DISPLAY_COUNT });
} catch (error) {
console.error("[short-drama/recommend]", error);
return [];
}
}
async function loadPlayletDetail(idOrSlug: string): Promise<DramaDetail | null> {
try {
const data = await getPlayletInfoCached(playletLookup(idOrSlug));
return mapPlayletInfo(data);
} catch (error) {
console.error("[short-drama/info]", error);
return null;
}
}
async function loadPlayletDetailWithRecommend(idOrSlug: string): Promise<{
detail: DramaDetail;
related: RelatedDrama[];
} | null> {
const lookup = playletLookup(idOrSlug);
try {
const detail = mapPlayletInfo(await getPlayletInfoCached(lookup));
if (!detail) return null;
// 更多好剧失败不影响详情主数据
const related = await loadPlayletRecommend(detail.id);
return { detail, related };
} catch (error) {
console.error("[short-drama/detail]", error);
return null;
}
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { sort, page } = await params;
const sortKey = parseShortDramaSortSegment(sort);
if (sortKey) {
const sortText = sortLabels[sortKey];
const listPath = parseShortDramaListPath(page);
const pageNum = listPath?.page ?? 1;
const themeSlug = listPath?.themeSlug;
// 列表 TDK:有题材时用 theme/list 的 seoInfo,缺字段再回退 home/base
const themes = await loadPlayletThemes();
const theme = themeSlug
? themes.find((item) => item.slug === themeSlug)
: undefined;
const themeName = theme?.name?.trim() || "";
return resolvePageMetadata({
seoInfo: theme?.seoInfo,
fallbackTitle: themeName ? `短剧·${themeName}·${sortText}` : `短剧·${sortText}`,
fallbackDescription: themeName
? `浏览海角社区短剧「${themeName}」题材,按${sortText}发现精彩作品。`
: `浏览海角社区短剧列表,按${sortText}发现精彩作品。`,
fallbackKeywords: ["海角社区", "短剧", themeName, sortText].filter(Boolean),
page: pageNum,
canonical: shortDramaListHref(sortKey, pageNum, themeSlug),
});
}
// 播放页 title:短剧标题 + 第几集 + 网页标题(站点名由 withSiteSuffix 追加)
if (page?.length === 1) {
const play = await loadPlayletPlay(decodeSegment(sort), page[0]);
if (!play) return resolveContentMetadata({ fallbackTitle: "海角社区" });
const dramaTitle = play.detail.title?.trim() || "短剧";
const playTitle = `${dramaTitle} 第${play.episode}集`;
const { title: _seoTitle, ...playSeoRest } = play.detail.seoInfo ?? {};
void _seoTitle;
return resolveContentMetadata({
seoInfo: play.detail.seoInfo ? playSeoRest : null,
fallbackTitle: playTitle,
fallbackDescription:
play.detail.desc || `在线观看《${dramaTitle}》第${play.episode}集`,
fallbackKeywords: [
"海角社区",
"短剧",
"在线播放",
dramaTitle,
playTitle,
...(play.detail.tagNames ?? []),
].filter(Boolean),
ogType: "video.episode",
canonical: playHref(play.detail.id, play.episode, play.detail.slug),
});
}
if (page?.length) return resolvePageMetadata({ fallbackTitle: "海角社区" });
// 详情页:TDK 用短剧详情(playlet/info)
const detail = await loadPlayletDetail(decodeSegment(sort));
if (!detail) return resolveContentMetadata({ fallbackTitle: "海角社区" });
return resolveContentMetadata({
seoInfo: detail.seoInfo,
fallbackTitle: detail.title,
fallbackDescription: detail.desc || `在线观看《${detail.title}》,查看选集、剧情信息与相关推荐。`,
fallbackKeywords: ["海角社区", "短剧", detail.title || "", ...(detail.tagNames ?? [])].filter(
Boolean,
),
canonical: dramaDetailHref(detail),
});
}
async function loadPlayletPlay(idOrSlug: string, episodeRaw: string): Promise<DramaPlayView | null> {
const detail = await loadPlayletDetail(idOrSlug);
if (!detail) return null;
const episode = parseEpisode(episodeRaw, detail);
if (!episode) return null;
const chapter = detail.chapterList?.find((item) => Number(item.currentEpisode) === episode);
if (!chapter?.id) return null;
const recommendPromise = loadPlayletRecommend(detail.id);
try {
const data = await getPlayletChapterInfoCached(chapter.id);
const mapped = mapPlayletChapterPlay(data, detail, episode);
const related = await recommendPromise;
if (mapped) return { ...mapped, related: related.length ? related : mapped.related };
// 接口异常时回退剧集列表里的相对地址(无 CDN 时可能无法播)
return {
detail,
episode,
chapterTitle: chapter.title?.trim() || undefined,
src: resolvePlayURL(chapter.videoUrl),
related,
};
} catch (error) {
console.error("[short-drama/chapter]", error);
return {
detail,
episode,
chapterTitle: chapter?.title?.trim() || undefined,
src: resolvePlayURL(chapter?.videoUrl),
related: await recommendPromise,
};
}
}
async function loadPlayletThemes(): Promise<PlayletThemeItem[]> {
try {
const data = await getPlayletThemeListCached();
return data.list ?? [];
} catch (error) {
console.error("[short-drama/theme]", error);
return [];
}
}
async function loadShortDramaList(
sort: Sort,
page: number,
themeSlug?: string,
): Promise<ShortDramaListResult> {
const themes = await loadPlayletThemes();
const activeTheme = themeSlug
? themes.find((theme) => theme.slug === themeSlug)
: undefined;
// 非法题材路径段时不当作筛选条件,仍展示题材列表
const resolvedSlug = activeTheme?.slug;
try {
const data = await getPlayletListCached({
pageNum: page,
pageSize: PAGE_SIZE,
sortType: shortDramaSortType(sort),
themeSlug: resolvedSlug,
});
return buildShortDramaList(data, sort, page, PAGE_SIZE, {
themes,
themeSlug: resolvedSlug,
});
} catch (error) {
console.error("[short-drama]", error);
return buildShortDramaList(null, sort, page, PAGE_SIZE, {
themes,
themeSlug: resolvedSlug,
});
}
}
async function ShortDramaRouteContent({ params }: { params: Params }) {
// 请求期渲染:fetch 超时等会触达当前时间,需排除在 prerender shell 外(已有 Suspense)
await connection();
try {
primeRequestForwardHeaders(await buildServerForwardHeaders());
} catch {
/* ignore */
}
const { sort, page: pageSegment } = await params;
const sortKey = parseShortDramaSortSegment(sort);
if (sortKey) {
const parsed = parseShortDramaListPath(pageSegment);
if (parsed == null) notFound();
// 旧中文排序段 → 英文规范 URL
if (decodeSegment(sort) !== sortKey) {
redirect(shortDramaListHref(sortKey, parsed.page, parsed.themeSlug));
}
// 页码为 1 时去掉尾段 /1:/sort/1 → /sort,/sort/theme/1 → /sort/theme
if (parsed.page === 1) {
if (!parsed.themeSlug && pageSegment?.length === 1) {
redirect(shortDramaListHref(sortKey, 1));
}
if (parsed.themeSlug && pageSegment?.length === 2) {
redirect(shortDramaListHref(sortKey, 1, parsed.themeSlug));
}
}
const sortText = sortLabels[sortKey];
const list = await loadShortDramaList(sortKey, parsed.page, parsed.themeSlug);
const itemListUrls = list.items
.filter((item) => item.kind === "drama")
.slice(0, 24)
.map((item) => dramaHref(item.id, item.slug));
const siteSeo = await getHomeBaseSEOCached();
const activeTheme = list.themeSlug
? list.themes.find((item) => item.slug === list.themeSlug)
: undefined;
const themeName = activeTheme?.name?.trim() || "";
const listSeo = mergeSEO(activeTheme?.seoInfo, siteSeo.seo);
const listTitle =
listSeo.title ||
(themeName ? `短剧·${themeName}·${sortText}` : `短剧·${sortText}`);
const listDescription =
listSeo.description ||
(themeName
? `浏览海角社区短剧「${themeName}」题材,按${sortText}发现精彩作品。`
: `浏览海角社区短剧列表,按${sortText}发现精彩作品。`);
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: shortDramaListHref(sortKey, parsed.page, parsed.themeSlug),
title: listTitle,
description: listDescription,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
["短剧", shortDramaListHref(sortKey, 1)],
],
itemListUrls,
siteName: siteSeo.siteName,
})}
/>
<ShortDramaPage list={list} />
</>
);
}
if (!pageSegment?.length) {
const mapped = await loadPlayletDetailWithRecommend(decodeSegment(sort));
if (!mapped) notFound();
const detailPath = dramaDetailHref(mapped.detail);
const siteSeo = await getHomeBaseSEOCached();
return (
<>
<JsonLd
data={buildPlayletDetailJSONLD({
path: detailPath,
title: mapped.detail.title,
description: mapped.detail.desc,
cover: mapped.detail.cover,
totalEpisode: mapped.detail.totalEpisode,
onlineAt: mapped.detail.onlineAt,
tagNames: mapped.detail.tagNames,
themeNames: mapped.detail.themeNames,
anchor: mapped.detail.anchor,
seoInfo: mapped.detail.seoInfo,
siteName: siteSeo.siteName,
})}
/>
<DramaDetailPage
detail={mapped.detail}
related={mapped.related}
relatedAside={<RelatedAside items={mapped.related} />}
adIcons={<AdIconsSlot variant="pc" />}
promo={<AdBannersSlot variant="pc" />}
adGrid={<AdIconsSlot variant="h5" />}
bannerAds={<AdBannersSlot variant="h5" />}
/>
</>
);
}
if (pageSegment.length === 1) {
const play = await loadPlayletPlay(decodeSegment(sort), pageSegment[0]);
if (!play) notFound();
const detailPath = dramaDetailHref(play.detail);
const episodePath = playHref(play.detail.id, play.episode, play.detail.slug);
const siteSeo = await getHomeBaseSEOCached();
const dramaTitle = play.detail.title?.trim() || "短剧";
return (
<>
<JsonLd
data={buildPlayletPlayJSONLD({
path: detailPath,
playPath: episodePath,
episode: play.episode,
title: dramaTitle,
description: play.detail.desc,
cover: play.detail.cover,
totalEpisode: play.detail.totalEpisode,
onlineAt: play.detail.onlineAt,
tagNames: play.detail.tagNames,
themeNames: play.detail.themeNames,
anchor: play.detail.anchor,
seoInfo: play.detail.seoInfo,
siteName: siteSeo.siteName,
videoUrl: play.src,
})}
/>
<DramaPlayPage
detail={play.detail}
episode={play.episode}
src={play.src}
related={play.related}
relatedAside={<RelatedAside items={play.related} />}
adIcons={<AdIconsSlot variant="pc" />}
promo={<AdBannersSlot variant="pc" />}
/>
</>
);
}
notFound();
}
export default function Page({ params }: { params: Params }) {
return (
<Suspense fallback={<PageLoading />}>
<ShortDramaRouteContent params={params} />
</Suspense>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { PageLoading } from "@/components/common/page-loading";
export default function Loading() {
return <PageLoading className="flex-1 bg-[#07060E]" />;
}
+179
View File
@@ -0,0 +1,179 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { JsonLd } from "@/components/seo/json-ld";
import { dramaHref } from "@/components/short-drama/data";
import { loadTagPage, tagCanonicalKey } from "@/components/tag/data";
import { parseTagKind, type TagContentKind } from "@/components/tag/href";
import { tagListHref } from "@/components/tag/route";
import { TagPage } from "@/components/tag/tag-page";
import { videoHref } from "@/components/video/video-card";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
type Params = Promise<{ slug?: string[] }>;
type SearchParams = Promise<{ page?: string | string[]; kind?: string | string[] }>;
function decodeSeg(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function first(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
function parsePageNum(raw: string | undefined): number | null {
if (raw == null) return 1;
const num = Number(raw);
if (!Number.isInteger(num) || num < 1) return null;
return num;
}
/**
* 路径约定:
* - /tag/{playlet|video}/{slug|id}
* - /tag/{playlet|video}/{slug|id}/{page}
* 兼容旧链(会 301 到 slug 优先路径):
* - /tag/{key}、/tag/{key}/{page}
* - /tag/{key}?kind=video&page=n
*/
function parseTagSlug(
slug: string[] | undefined,
queryKind?: string | string[],
queryPage?: string | string[],
): {
kind: TagContentKind | undefined;
key: string;
page: number;
legacy: boolean;
} | null {
const segs = (slug ?? []).map(decodeSeg).filter(Boolean);
const qKind = parseTagKind(queryKind);
const qPage = Number(first(queryPage));
if (!segs.length) return null;
const headKind = parseTagKind(segs[0]);
if (headKind) {
if (segs.length < 2 || segs.length > 3) return null;
const key = segs[1]?.trim();
if (!key) return null;
const page = parsePageNum(segs[2]);
if (page == null) return null;
return { kind: headKind, key, page, legacy: false };
}
// 旧路径 /tag/{key}[/page]
if (segs.length > 2) return null;
const key = segs[0]?.trim();
if (!key) return null;
const pathPage = parsePageNum(segs[1]);
if (pathPage == null) return null;
const page =
Number.isInteger(qPage) && qPage > 1 && segs.length === 1 ? Math.floor(qPage) : pathPage;
return { kind: qKind, key, page, legacy: true };
}
export async function generateMetadata({
params,
searchParams,
}: {
params: Params;
searchParams: SearchParams;
}): Promise<Metadata> {
const query = await searchParams;
const parsed = parseTagSlug((await params).slug, query.kind, query.page);
if (!parsed) {
return resolvePageMetadata({ seoInfo: { title: "标签", keyworld: ["海角社区", "标签"] } });
}
const { key, page, kind: preferredKind } = parsed;
try {
const data = await loadTagPage(key, 1, preferredKind);
const pathKey = tagCanonicalKey(data);
const unit = data.kind === "video" ? "视频" : "短剧";
return resolvePageMetadata({
seoInfo: {
title: `${data.name}标签`,
description: `浏览海角社区「${data.name}」标签相关${unit}。`,
keyworld: ["海角社区", "标签", data.name, unit],
},
page,
canonical: tagListHref(pathKey, page, data.kind),
});
} catch {
return resolvePageMetadata({
seoInfo: { title: key, keyworld: ["海角社区", "标签", key] },
page,
canonical: tagListHref(key, page, preferredKind ?? "playlet"),
});
}
}
export default async function Page({
params,
searchParams,
}: {
params: Params;
searchParams: SearchParams;
}) {
const query = await searchParams;
const parsed = parseTagSlug((await params).slug, query.kind, query.page);
if (!parsed) notFound();
const { key, page, kind: preferredKind, legacy } = parsed;
// 旧链接统一 301 到 /tag/{kind}/{slug|id}[/{page}]
if (legacy) {
const data = await loadTagPage(key, 1, preferredKind).catch(() => null);
const kind = data?.kind ?? preferredKind ?? "playlet";
const pathKey = data ? tagCanonicalKey(data) : key;
redirect(tagListHref(pathKey, page, kind));
}
const [data, siteSeo] = await Promise.all([
loadTagPage(key, page, preferredKind),
getHomeBaseSEOCached(),
]);
const pathKey = tagCanonicalKey(data);
// 规范到优先 slug:纯 id / name 访问且已有 slug 时跳转
if (pathKey && key !== pathKey) {
redirect(tagListHref(pathKey, page, data.kind));
}
// /tag/{kind}/{key}/1 → /tag/{kind}/{key}
if (page === 1 && (await params).slug?.length === 3 && preferredKind) {
redirect(tagListHref(pathKey, 1, preferredKind));
}
const unit = data.kind === "video" ? "视频" : "短剧";
const itemListUrls = data.items
.filter((item) => item.kind === "drama")
.slice(0, 24)
.map((item) => (data.kind === "video" ? videoHref(item.id, item.slug) : dramaHref(item.id, item.slug)));
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: tagListHref(pathKey, page, data.kind),
title: `${data.name}标签`,
description: `浏览海角社区「${data.name}」标签相关${unit}。`,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
[data.name, tagListHref(pathKey, 1, data.kind)],
],
itemListUrls,
siteName: siteSeo.siteName,
})}
/>
<TagPage data={data} />
</>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { JsonLd } from "@/components/seo/json-ld";
import { TermsPage } from "@/components/terms/terms-page";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
seoInfo: {
title: "用户协议",
description: "查看海角社区用户服务条款及平台使用规则。",
},
canonical: "/terms",
});
}
export default async function Page() {
const siteSeo = await getHomeBaseSEOCached();
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/terms",
title: "用户协议",
description: "查看海角社区用户服务条款及平台使用规则。",
type: "WebPage",
breadcrumbs: [
["首页", "/"],
["用户协议", "/terms"],
],
siteName: siteSeo.siteName,
})}
/>
<TermsPage />
</>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { PageLoading } from "@/components/common/page-loading";
export default function Loading() {
return <PageLoading className="flex-1 bg-[#07060E]" />;
}
+245
View File
@@ -0,0 +1,245 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { JsonLd } from "@/components/seo/json-ld";
import { dramaHref } from "@/components/short-drama/data";
import {
isTopicVideo,
loadTopicPage,
unwrapTopicListItem,
} from "@/components/topic/data";
import { TopicPage } from "@/components/topic/topic-page";
import {
dataTypeFromTopicKind,
parseTopicSortSegment,
parseTopicSortType,
topicKindFromDataType,
topicListHref,
topicSortLabel,
topicSortPath,
type TopicSortType,
} from "@/components/topic/route";
import { videoHref } from "@/components/video/video-card";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
type Params = Promise<{ slug?: string[] }>;
type SearchParams = Promise<{ dataType?: string | string[]; sortType?: string | string[] }>;
function decodeSegment(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function parsePageNumber(value: string): number | null {
const num = Number(value);
if (!Number.isInteger(num) || num < 1) return null;
return num;
}
type TopicPath =
| { dataType: 1 | 2; key: string; page: number; sortType: TopicSortType }
| { redirectTo: string };
/**
* 解析专题路径:
* - /topic/playlet|{video}/{key}/{sort}[/{page}]
* - 兼容 /topic/{kind}/{key}[/{page}]、旧 /topic/{key}?dataType=、?sortType=
*/
function parseTopicSlug(
slug: string[] | undefined,
searchDataType?: string | string[] | null,
searchSortType?: string | string[] | null,
): TopicPath | null {
const parts = (slug ?? []).map(decodeSegment).filter(Boolean);
const rawQuery = Array.isArray(searchDataType) ? searchDataType[0] : searchDataType;
const queryType = rawQuery === "2" || rawQuery === "video" ? 2 : rawQuery === "1" || rawQuery === "playlet" ? 1 : null;
const querySort = parseTopicSortType(searchSortType);
if (!parts.length) return null;
const kindType = dataTypeFromTopicKind(parts[0]);
if (kindType != null) {
const key = (parts[1] || "").trim();
if (!key) return null;
if (parts.length > 4) return null;
let sortType: TopicSortType = 1;
let page = 1;
let needsCanonical = false;
if (parts.length === 2) {
// /topic/kind/key → 默认编辑排序
needsCanonical = true;
} else if (parts.length === 3) {
const third = parts[2];
const asPage = parsePageNumber(third);
// 路径排序段用英文(兼容旧中文);纯数字优先当页码,避免 /topic/.../2 被误判为 sortType
if (asPage != null) {
page = asPage;
needsCanonical = true;
} else {
const asSort = parseTopicSortSegment(third);
if (asSort == null) return null;
sortType = asSort;
// 旧中文 / 数字段 → 英文规范 URL
if (third !== topicSortPath(asSort)) needsCanonical = true;
}
} else if (parts.length === 4) {
const asSort = parseTopicSortSegment(parts[2]);
const asPage = parsePageNumber(parts[3]);
if (asSort == null || asPage == null) return null;
sortType = asSort;
page = asPage;
if (parts[2] !== topicSortPath(asSort)) needsCanonical = true;
}
if (querySort != null && querySort !== sortType) {
return { redirectTo: topicListHref(key, { dataType: kindType, page, sortType: querySort }) };
}
if (querySort != null) {
// 去掉 ?sortType=
return { redirectTo: topicListHref(key, { dataType: kindType, page, sortType }) };
}
// /topic/1/xxx → /topic/playlet/xxx/...
if (parts[0] !== topicKindFromDataType(kindType)) {
return { redirectTo: topicListHref(key, { dataType: kindType, page, sortType }) };
}
if (page === 1 && parts.length === 4) {
return { redirectTo: topicListHref(key, { dataType: kindType, page: 1, sortType }) };
}
if (needsCanonical) {
return { redirectTo: topicListHref(key, { dataType: kindType, page, sortType }) };
}
return { dataType: kindType, key, page, sortType };
}
// 旧路径 /topic/{key}[/{page}|{sort}][/{page}]
const key = parts[0].trim();
if (!key) return null;
if (parts.length > 3) return null;
let sortType: TopicSortType = querySort ?? 1;
let page = 1;
if (parts.length === 2) {
const asSort = parseTopicSortSegment(parts[1]);
const asPage = parsePageNumber(parts[1]);
if (asSort != null) sortType = asSort;
else if (asPage != null) page = asPage;
else return null;
} else if (parts.length === 3) {
const asSort = parseTopicSortSegment(parts[1]);
const asPage = parsePageNumber(parts[2]);
if (asSort == null || asPage == null) return null;
sortType = asSort;
page = asPage;
}
const dataType = queryType ?? 1;
return { redirectTo: topicListHref(key, { dataType, page, sortType }) };
}
export async function generateMetadata({
params,
searchParams,
}: {
params: Params;
searchParams: SearchParams;
}): Promise<Metadata> {
const { slug } = await params;
const query = await searchParams;
const parsed = parseTopicSlug(slug, query.dataType, query.sortType);
if (!parsed || "redirectTo" in parsed) {
return resolvePageMetadata({ fallbackTitle: "专题" });
}
const canonical = topicListHref(parsed.key, {
dataType: parsed.dataType,
page: parsed.page,
sortType: parsed.sortType,
});
try {
const data = await loadTopicPage(parsed.key, parsed.dataType, 1, parsed.sortType);
const name = (data.name || "").trim() || "专题";
return resolvePageMetadata({
seoInfo: data.seoInfo ?? {
title: name,
description: (data.desc || "").trim() || `浏览海角社区专题「${name}」。`,
keyworld: ["海角社区", name],
},
page: parsed.page,
canonical,
});
} catch {
return resolvePageMetadata({ fallbackTitle: "专题", page: parsed.page, canonical });
}
}
export default async function Page({
params,
searchParams,
}: {
params: Params;
searchParams: SearchParams;
}) {
const { slug } = await params;
const query = await searchParams;
const parsed = parseTopicSlug(slug, query.dataType, query.sortType);
if (!parsed) notFound();
if ("redirectTo" in parsed) redirect(parsed.redirectTo);
const { dataType, key: topicKey, page, sortType } = parsed;
const [data, siteSeo] = await Promise.all([
loadTopicPage(topicKey, dataType, page, sortType),
getHomeBaseSEOCached(),
]);
const resolvedDataType: 1 | 2 = isTopicVideo(data.dataType) ? 2 : dataType;
const pathKey = (data.slug || "").trim() || (data.id != null ? String(data.id) : topicKey);
const name = (data.name || "").trim() || "专题";
const desc = (data.desc || "").trim();
if (resolvedDataType !== dataType) {
redirect(topicListHref(pathKey, { dataType: resolvedDataType, page, sortType }));
}
// 规范到 slug 路径(含排序段)
const canonical = topicListHref(pathKey, { dataType: resolvedDataType, page, sortType });
const currentPath = topicListHref(topicKey, { dataType: resolvedDataType, page, sortType });
if (canonical !== currentPath) {
redirect(canonical);
}
const itemListUrls = (data.list ?? [])
.map((item) => unwrapTopicListItem(item, resolvedDataType))
.filter((item): item is NonNullable<typeof item> => Boolean(item?.id))
.slice(0, 24)
.map((item) =>
resolvedDataType === 2
? videoHref(String(item.id), "slug" in item ? item.slug : undefined)
: dramaHref(item.id, "slug" in item ? item.slug : undefined),
);
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: topicListHref(pathKey, { dataType: resolvedDataType, page, sortType }),
title: `${name} · ${topicSortLabel(sortType)}`,
description: desc || `浏览海角社区专题「${name}」。`,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
[name, topicListHref(pathKey, { dataType: resolvedDataType, page: 1, sortType: 1 })],
],
itemListUrls,
siteName: siteSeo.siteName,
})}
/>
<TopicPage data={data} />
</>
);
}
+300
View File
@@ -0,0 +1,300 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { connection } from "next/server";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { sortLabels } from "@/components/short-drama/data";
import {
buildVideoList,
VIDEO_PAGE_SIZE,
videoSorts,
videoSortType,
type VideoListResult,
type VideoSort,
} from "@/components/video/data";
import {
parseVideoListPath,
parseVideoSortSegment,
videoListHref,
} from "@/components/video/route";
import { VideoPage } from "@/components/video/video-page";
import {
mapVideoInfo,
mapVideoRecommendList,
VIDEO_RECOMMEND_DISPLAY_COUNT,
type VideoPlayView,
} from "@/components/video-play/data";
import { VideoRelatedAside } from "@/components/video-play/related-aside";
import { VideoPlayPage } from "@/components/video-play/video-play-page";
import { JsonLd } from "@/components/seo/json-ld";
import {
getVideoCategoryListCached,
getVideoInfoCached,
getVideoListCached,
getVideoRecommendListCached,
} from "@/lib/api/video";
import type { ShortDramaItem } from "@/components/short-drama/data";
import type { VideoCategoryListItem } from "@/lib/api/types";
import { buildServerForwardHeaders } from "@/lib/api/client-headers";
import { primeRequestForwardHeaders } from "@/lib/api/request-context";
import {
buildVideoPlayJSONLD,
buildWebPageJSONLD,
getHomeBaseSEOCached,
mergeSEO,
resolveContentMetadata,
resolvePageMetadata,
} from "@/lib/seo";
import { videoHref } from "@/components/video/video-card";
import { AdBannersSlot, AdIconsSlot } from "@/components/common/ads/slots";
type Params = Promise<{ sort: string; page?: string[] }>;
export function generateStaticParams() {
return videoSorts.map((sort) => ({ sort }));
}
function decodeSegment(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function videoLookup(idOrSlug: string): { id?: string; slug?: string } {
if (/^\d+$/.test(idOrSlug)) return { id: idOrSlug };
return { slug: idOrSlug };
}
/** 热门推荐:GET /api/app/video/recommend/list(sortList=1 推荐) */
async function loadVideoRecommend(excludeId?: number | string | null): Promise<ShortDramaItem[]> {
try {
const data = await getVideoRecommendListCached({
pageNum: 1,
// 多取几条,排除当前视频后仍能凑满展示条数
pageSize: VIDEO_RECOMMEND_DISPLAY_COUNT + 4,
sortList: 1,
});
return mapVideoRecommendList(data, { excludeId, limit: VIDEO_RECOMMEND_DISPLAY_COUNT });
} catch (error) {
console.error("[video/recommend]", error);
return [];
}
}
async function loadVideoDetail(idOrSlug: string): Promise<VideoPlayView | null> {
try {
const data = await getVideoInfoCached(videoLookup(idOrSlug));
return mapVideoInfo(data);
} catch (error) {
console.error("[video/info]", error);
return null;
}
}
async function loadVideoDetailWithRecommend(idOrSlug: string): Promise<VideoPlayView | null> {
const mapped = await loadVideoDetail(idOrSlug);
if (!mapped) return null;
const related = await loadVideoRecommend(mapped.detail.id);
return { ...mapped, related: related.length ? related : mapped.related };
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { sort, page } = await params;
const sortKey = parseVideoSortSegment(sort);
if (sortKey) {
const sortText = sortLabels[sortKey];
const listPath = parseVideoListPath(page);
const pageNum = listPath?.page ?? 1;
const categorySlug = listPath?.categorySlug;
// 列表 TDK:有分类时用 category/list 的 seoInfo,缺字段再回退 home/base
const categories = await loadVideoCategories();
const category = categorySlug
? categories.find((item) => item.slug === categorySlug)
: undefined;
const categoryName = category?.name?.trim() || category?.title?.trim() || "";
return resolvePageMetadata({
seoInfo: category?.seoInfo,
fallbackTitle: categoryName ? `视频·${categoryName}·${sortText}` : `视频·${sortText}`,
fallbackDescription: categoryName
? `浏览海角社区视频「${categoryName}」分类,按${sortText}发现精彩内容。`
: `浏览海角社区视频列表,按${sortText}发现精彩内容。`,
fallbackKeywords: ["海角社区", "视频", categoryName, sortText].filter(Boolean),
page: pageNum,
canonical: videoListHref(sortKey, pageNum, categorySlug),
});
}
if (page?.length) return resolvePageMetadata({ fallbackTitle: "海角社区" });
// 视频播放页:TDK 用视频详情(video/info)
const mapped = await loadVideoDetail(decodeSegment(sort));
const detail = mapped?.detail;
if (!detail) return resolveContentMetadata({ fallbackTitle: "海角社区" });
const tagNames = (detail.tags ?? []).map((tag) => tag.name?.trim() || "").filter(Boolean);
return resolveContentMetadata({
fallbackTitle: detail.title,
fallbackDescription: detail.desc || (detail.title ? `在线观看《${detail.title}》` : undefined),
fallbackKeywords: ["海角社区", "视频", detail.title || "", ...tagNames].filter(Boolean),
ogType: "video.episode",
canonical: videoHref(String(detail.id), detail.slug),
});
}
async function loadVideoCategories(): Promise<VideoCategoryListItem[]> {
try {
const data = await getVideoCategoryListCached();
return data.list ?? [];
} catch (error) {
console.error("[video/category]", error);
return [];
}
}
async function loadVideoList(
sort: VideoSort,
page: number,
categorySlug?: string,
): Promise<VideoListResult> {
const categories = await loadVideoCategories();
const active = categorySlug
? categories.find((item) => item.slug === categorySlug)
: undefined;
const resolvedSlug = active?.slug;
const categoryId = active?.id;
try {
const data = await getVideoListCached({
pageNum: page,
pageSize: VIDEO_PAGE_SIZE,
sortType: videoSortType(sort),
categoryId,
});
return buildVideoList(data, sort, page, VIDEO_PAGE_SIZE, {
categories,
categorySlug: resolvedSlug,
});
} catch (error) {
console.error("[video]", error);
return buildVideoList(null, sort, page, VIDEO_PAGE_SIZE, {
categories,
categorySlug: resolvedSlug,
});
}
}
async function VideoRouteContent({ params }: { params: Params }) {
// 请求期渲染:fetch 超时等会触达当前时间,需排除在 prerender shell 外(已有 Suspense)
await connection();
try {
primeRequestForwardHeaders(await buildServerForwardHeaders());
} catch {
/* ignore */
}
const { sort, page: pageSegment } = await params;
const sortKey = parseVideoSortSegment(sort);
if (sortKey) {
const parsed = parseVideoListPath(pageSegment);
if (parsed == null) notFound();
// 旧中文排序段 → 英文规范 URL
if (decodeSegment(sort) !== sortKey) {
redirect(videoListHref(sortKey, parsed.page, parsed.categorySlug));
}
// 页码为 1 时去掉尾段 /1:/sort/1 → /sort,/sort/category/1 → /sort/category
if (parsed.page === 1) {
if (!parsed.categorySlug && pageSegment?.length === 1) {
redirect(videoListHref(sortKey, 1));
}
if (parsed.categorySlug && pageSegment?.length === 2) {
redirect(videoListHref(sortKey, 1, parsed.categorySlug));
}
}
const sortText = sortLabels[sortKey];
const list = await loadVideoList(sortKey, parsed.page, parsed.categorySlug);
const itemListUrls = list.items
.filter((item) => item.kind === "drama")
.slice(0, 24)
.map((item) => videoHref(item.id, item.slug));
const siteSeo = await getHomeBaseSEOCached();
const activeCategory = list.categorySlug
? list.categories.find((item) => item.slug === list.categorySlug)
: undefined;
const categoryName = activeCategory?.name?.trim() || activeCategory?.title?.trim() || "";
const listSeo = mergeSEO(activeCategory?.seoInfo, siteSeo.seo);
const listTitle =
listSeo.title ||
(categoryName ? `视频·${categoryName}·${sortText}` : `视频·${sortText}`);
const listDescription =
listSeo.description ||
(categoryName
? `浏览海角社区视频「${categoryName}」分类,按${sortText}发现精彩内容。`
: `浏览海角社区视频列表,按${sortText}发现精彩内容。`);
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: videoListHref(sortKey, parsed.page, parsed.categorySlug),
title: listTitle,
description: listDescription,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
["视频", videoListHref(sortKey, 1)],
],
itemListUrls,
siteName: siteSeo.siteName,
})}
/>
<VideoPage list={list} />
</>
);
}
if (pageSegment?.length) notFound();
const mapped = await loadVideoDetailWithRecommend(decodeSegment(sort));
if (!mapped) notFound();
const playPath = videoHref(String(mapped.detail.id), mapped.detail.slug);
const siteSeo = await getHomeBaseSEOCached();
return (
<>
<JsonLd
data={buildVideoPlayJSONLD({
path: playPath,
title: mapped.detail.title,
description: mapped.detail.desc,
cover: mapped.detail.coverImg || mapped.detail.video?.cover,
addedTime: mapped.detail.addedTime,
videoUrl: mapped.src,
durationSec: mapped.detail.video?.videoTime,
width: mapped.detail.video?.width,
height: mapped.detail.video?.height,
watchNum: mapped.detail.watchNum,
siteName: siteSeo.siteName,
})}
/>
<VideoPlayPage
detail={mapped.detail}
src={mapped.src}
related={mapped.related}
relatedAside={<VideoRelatedAside items={mapped.related} />}
adIcons={<AdIconsSlot variant="pc" labels={false} />}
promo={<AdBannersSlot variant="pc" />}
adGrid={<AdIconsSlot variant="h5" />}
bannerAds={<AdBannersSlot variant="h5" />}
/>
</>
);
}
export default function Page({ params }: { params: Params }) {
return (
<Suspense fallback={<PageLoading />}>
<VideoRouteContent params={params} />
</Suspense>
);
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { cache } from "react";
import { JsonLd } from "@/components/seo/json-ld";
import { WayHomePage } from "@/components/way-home/way-home-page";
import { getHomeBaseCached } from "@/lib/api/home";
import type { AnnouncementInfo, ConfigInfo } from "@/lib/api/types";
import { buildWebPageJSONLD, resolvePageMetadata } from "@/lib/seo";
const loadWayHomeBase = cache(async (): Promise<{
announcement?: AnnouncementInfo;
config?: ConfigInfo;
}> => {
try {
const base = await getHomeBaseCached();
return { announcement: base.announcement, config: base.config };
} catch (error) {
console.warn("[way-home] home/base failed", error);
return {};
}
});
export async function generateMetadata(): Promise<Metadata> {
const { config } = await loadWayHomeBase();
const siteName = config?.webSiteName?.trim() || config?.dhSiteName?.trim() || "海角社区";
return resolvePageMetadata({
siteName,
seoInfo: {
title: `回家的路 - ${siteName}官方最新地址发布页`,
description: `${siteName}官方最新地址发布页,实时同步国内可用线路、永久入口与防失联联系方式。`,
keyworld: [siteName, "回家的路", "最新地址", "永久入口", "备用地址"],
},
canonical: "/way-home",
});
}
export default async function Page() {
const { announcement, config } = await loadWayHomeBase();
const siteName = config?.webSiteName?.trim() || config?.dhSiteName?.trim() || "海角社区";
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/way-home",
title: `回家的路 - ${siteName}官方最新地址发布页`,
description: `${siteName}官方最新地址发布页,实时同步国内可用线路、永久入口与防失联联系方式。`,
type: "WebPage",
breadcrumbs: [
["首页", "/"],
["回家的路", "/way-home"],
],
siteName,
})}
/>
<WayHomePage announcement={announcement} config={config} />
</>
);
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { JsonLd } from "@/components/seo/json-ld";
import { WelfarePage } from "@/components/welfare/welfare-page";
import { buildWelfareCategories, type WelfareCategory } from "@/components/welfare/data";
import { getAppListCached } from "@/lib/api/applist";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
seoInfo: {
title: "福利应用",
description: "海角社区福利应用与精选推荐。",
keyworld: ["海角社区", "福利应用"],
},
canonical: "/welfare",
});
}
async function WelfareContent() {
let categories: WelfareCategory[] = [];
try {
const data = await getAppListCached(0);
categories = buildWelfareCategories(data);
} catch (error) {
console.error("[welfare]", error);
}
const siteSeo = await getHomeBaseSEOCached();
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/welfare",
title: "福利应用",
description: "海角社区福利应用与精选推荐。",
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
["福利应用", "/welfare"],
],
siteName: siteSeo.siteName,
})}
/>
<WelfarePage categories={categories} />
</>
);
}
export default function Page() {
return (
<Suspense fallback={<PageLoading />}>
<WelfareContent />
</Suspense>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { InfoPageLayout } from "@/components/info/info-page-layout";
const slogan = "每一刻,皆入戏。";
const sections: { title: string; paragraphs: string[]; slogan?: boolean }[] = [
{
title: "品牌介绍",
paragraphs: [
"AI爽剧网是面向短剧爱好者打造的在线内容平台,聚合丰富题材与优质作品,为用户提供清晰、便捷且沉浸的观看体验。",
"我们关注每一次真实的情绪共鸣,也重视创作者与观众之间的连接,让好故事更快被看见。",
],
},
{
title: "产品愿景",
paragraphs: ["以简洁可靠的产品体验连接内容、创作者与观众,持续优化搜索、追剧、收藏、观看记录和多端同步等核心能力。"],
slogan: true,
},
{
title: "联系我们",
paragraphs: [
"商务合作:business@qimengshorts.com",
"用户反馈:support@qimengshorts.com",
"服务时间:工作日 10:00–18:00",
],
},
];
export function AboutPage() {
return (
<InfoPageLayout data-component="about-page"
title="关于我们"
subtitle="了解AI爽剧网的品牌、产品与服务"
updatedAt="最后更新:2026年7月23日"
sections={sections.map((section) => ({
title: section.title,
content:
section.title === "联系我们" ? (
<p className="whitespace-pre-line">{section.paragraphs.join("\n")}</p>
) : (
<>
{section.paragraphs.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
{section.slogan ? <p>品牌主张:{slogan}</p> : null}
</>
),
}))}
mobile={sections.map((section) => (
<section key={section.title} className="flex flex-col gap-2 rounded-2xl bg-[#13111C] px-4 py-3.5">
<h2 className="text-base leading-[22px] font-bold text-[#FAF7FF]">{section.title}</h2>
{section.paragraphs.map((paragraph) => (
<p key={paragraph} className="text-xs leading-[18px] text-[#9E94AD]">
{paragraph}
</p>
))}
{section.slogan ? <p className="text-xs leading-[18px] font-bold text-[#F12B9E]">{slogan}</p> : null}
</section>
))}
/>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { connection } from "next/server";
import { getAdvitiseCached } from "@/lib/api/home";
import { AdDialogHost } from "./ad-dialog";
/** 服务端插槽:从 /api/app/home/base 拉取 advitise.dialogList(Suspense + connection) */
export async function AdDialogSlot() {
await connection();
const advitise = await getAdvitiseCached();
return <AdDialogHost advitise={advitise} />;
}
+305
View File
@@ -0,0 +1,305 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { getAgeGateOpen, subscribeAgeGate } from "@/components/age-gate/age-gate-dialog";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { adTrackProps } from "@/components/common/ads/pick";
import { useChromeless } from "@/components/layout/route-visibility";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
/** 对齐 jha-1043 dialogAd.js:签名队列 + session 进度 / 关闭态 */
const QUEUE_VERSION = "2";
const DISMISSED_KEY = "dialogAdDismissed";
const PROGRESS_KEY = "dialogAdProgress";
let queueIdle = true;
const listeners = new Set<() => void>();
function emit() {
listeners.forEach((listener) => listener());
}
function setQueueIdle(idle: boolean) {
if (queueIdle === idle) return;
queueIdle = idle;
emit();
}
export function subscribeAd(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** 当前是否正在展示广告弹窗 */
export const getAdOpen = () => !queueIdle;
/**
* 广告队列是否已结束(已全部关闭 / 无广告 / 已 dismiss)
* 供最新地址弹窗排序:年龄确认 → 广告队列 → 地址弹窗
*/
export function isAdQueueDone(signature: string) {
if (!signature) return true;
try {
return window.sessionStorage.getItem(DISMISSED_KEY) === signature;
} catch {
return true;
}
}
/** @deprecated 兼容旧调用:等价于队列空闲 */
export const hasAdShown = () => queueIdle;
const getAgeGateServerSnapshot = () => true;
function readSession(key: string) {
try {
return window.sessionStorage.getItem(key) || "";
} catch {
return "";
}
}
function writeSession(key: string, value: string) {
try {
window.sessionStorage.setItem(key, value);
} catch {
// sessionStorage 不可用时仍允许本页依次展示
}
}
function removeSession(key: string) {
try {
window.sessionStorage.removeItem(key);
} catch {}
}
function buildSignature(ads: AdvertiseInfo[]) {
return (
QUEUE_VERSION +
"|" +
ads
.map((item, index) => item.id || item.images || String(index))
.join("|")
);
}
function readProgress(signature: string) {
try {
const raw = readSession(PROGRESS_KEY);
if (!raw) return 0;
const progress = JSON.parse(raw) as { signature?: string; index?: number };
if (!progress || progress.signature !== signature) return 0;
const value = Number(progress.index);
if (!Number.isFinite(value) || value < 0) return 0;
return Math.floor(value);
} catch {
return 0;
}
}
function rememberProgress(signature: string, index: number) {
writeSession(PROGRESS_KEY, JSON.stringify({ signature, index }));
}
function pickDialogAds(items?: AdvertiseInfo[] | null, advitise?: AdvitiseData | null) {
const source = items ?? advitise?.dialogList ?? [];
return source.filter((item): item is AdvertiseInfo & { images: string } => Boolean(item.images?.trim()));
}
/** 弹窗广告:使用 advitise.dialogList,全站队列展示(对齐 jha-1043) */
export function AdDialogHost({
items,
advitise,
}: {
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
}) {
const chromeless = useChromeless();
const ageGateOpen = useSyncExternalStore(subscribeAgeGate, getAgeGateOpen, getAgeGateServerSnapshot);
const ads = useMemo(() => pickDialogAds(items, advitise), [items, advitise]);
const signature = useMemo(() => buildSignature(ads), [ads]);
const [index, setIndex] = useState(-1);
const hasDisplayedRef = useRef(false);
const enabled = !chromeless && !ageGateOpen && ads.length > 0;
useEffect(() => {
let cancelled = false;
void (async () => {
await Promise.resolve();
if (cancelled) return;
if (!enabled) {
if (!ads.length) setQueueIdle(true);
return;
}
if (isAdQueueDone(signature)) {
setIndex(-1);
setQueueIdle(true);
return;
}
const start = readProgress(signature);
if (start >= ads.length) {
if (hasDisplayedRef.current) writeSession(DISMISSED_KEY, signature);
removeSession(PROGRESS_KEY);
setIndex(-1);
setQueueIdle(true);
return;
}
setIndex(start);
setQueueIdle(false);
})();
return () => {
cancelled = true;
};
}, [enabled, signature, ads.length]);
const finishQueue = useCallback(() => {
if (hasDisplayedRef.current) writeSession(DISMISSED_KEY, signature);
else removeSession(DISMISSED_KEY);
removeSession(PROGRESS_KEY);
setIndex(-1);
setQueueIdle(true);
}, [signature]);
const showNext = useCallback(() => {
setIndex((current) => {
const next = current + 1;
if (next >= ads.length) {
finishQueue();
return -1;
}
rememberProgress(signature, next);
return next;
});
}, [ads.length, finishQueue, signature]);
const skipBroken = useCallback(() => {
showNext();
}, [showNext]);
useEffect(() => {
if (index < 0 || index >= ads.length) return;
hasDisplayedRef.current = true;
rememberProgress(signature, index);
setQueueIdle(false);
}, [index, ads.length, signature]);
useEffect(() => {
if (index < 0) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") showNext();
};
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = overflow;
window.removeEventListener("keydown", onKeyDown);
};
}, [index, showNext]);
const ad = index >= 0 && index < ads.length ? ads[index] : null;
const adId = ad ? String(ad.advertiseCode || ad.id || "") : "";
const trackAttrs = ad
? adTrackProps(ad, {
slotKey: "dialog_ad",
slotName: "弹窗广告位",
adType: "interstitial",
})
: {};
useEffect(() => {
if (!ad?.images || !adId) return;
document.dispatchEvent(
new CustomEvent("dialog-ad:track", {
detail: {
eventType: "show",
element: Object.assign(document.createElement("div"), {
dataset: {
advertisingKey: "dialog_ad",
advertisingName: "弹窗广告",
advertisingId: adId,
dialogAdId: adId,
},
}),
},
}),
);
}, [ad, adId]);
if (!ad?.images) return null;
const picture = (
<DecryptedImage
src={ad.images}
alt={ad.title || "广告"}
priority
className="h-[468px] w-[300px] rounded-3xl object-cover lg:h-[602px] lg:w-[400px] lg:rounded-[40px]"
onError={skipBroken}
/>
);
return (
<div
data-component="ad-dialog"
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto bg-black/70 px-4 py-10"
onClick={(event) => event.target === event.currentTarget && showNext()}
>
<div
role="dialog"
aria-modal="true"
aria-label={ad.title || "广告"}
className="my-auto flex flex-col items-center gap-9 lg:gap-6"
onClick={(event) => event.target === event.currentTarget && showNext()}
data-advertising-key="dialog_ad"
data-advertising-name="弹窗广告"
data-advertising-id={adId}
data-dialog-ad-id={adId}
>
{ad.link ? (
<a
href={ad.link}
target="_blank"
rel="noopener noreferrer sponsored"
className="block"
{...trackAttrs}
onClick={() => {
document.dispatchEvent(
new CustomEvent("dialog-ad:track", {
detail: {
eventType: "click",
element: Object.assign(document.createElement("div"), {
dataset: {
advertisingKey: "dialog_ad",
advertisingName: "弹窗广告",
advertisingId: adId,
dialogAdId: adId,
},
}),
},
}),
);
}}
>
{picture}
</a>
) : (
picture
)}
<button
type="button"
aria-label="关闭广告"
data-dialog-ad-close=""
onClick={showNext}
className="size-9 cursor-pointer"
>
<svg viewBox="0 0 36 36" className="size-full" aria-hidden="true">
<circle cx="18" cy="18" r="14" fill="none" stroke="white" strokeWidth="2" />
<path d="M13 13l10 10M23 13L13 23" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
);
}
@@ -0,0 +1,22 @@
import { connection } from "next/server";
import { getHomeBaseCached } from "@/lib/api/home";
import { AddressDialogHost } from "./address-dialog";
/** 服务端插槽:从 /api/app/home/base 拉取最新地址弹窗数据(Suspense + connection) */
export async function AddressDialogSlot() {
await connection();
let announcement: Awaited<ReturnType<typeof getHomeBaseCached>>["announcement"] | undefined;
let config: Awaited<ReturnType<typeof getHomeBaseCached>>["config"] | undefined;
try {
const base = await getHomeBaseCached();
announcement = base.announcement;
config = base.config;
} catch {
// 接口失败时仍渲染默认地址弹窗
}
return <AddressDialogHost announcement={announcement} config={config} />;
}
+374
View File
@@ -0,0 +1,374 @@
"use client";
import { usePathname } from "next/navigation";
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { getAdOpen, hasAdShown, subscribeAd } from "@/components/ad/ad-dialog";
import { getAgeGateOpen, subscribeAgeGate } from "@/components/age-gate/age-gate-dialog";
import { useChromeless } from "@/components/layout/route-visibility";
import type { AnnouncementInfo, ConfigInfo } from "@/lib/api/types";
/** 对齐 FOYA latestAddrPop(1041/1038):倒计时结束后才允许关闭,默认 3 秒 */
const CLOSE_DELAY_SECONDS = 3;
const SESSION_KEY = "hjsq-address-shown";
const ACCENT = "#FF2D55";
export type AddressDialogInfo = {
permanent: string;
latest: string;
telegram: string;
email: string;
mirrors: string[];
publishPage: string;
};
/** 从 home/base 的 announcement / config 组装地址弹窗文案(不改字段名映射,只做展示拼装) */
export function buildAddressDialogInfo(
announcement?: AnnouncementInfo | null,
config?: ConfigInfo | null,
): AddressDialogInfo | null {
const permanent = (announcement?.foreverUrlAddr || config?.foreverUrlAddr || "").trim();
const latest = (announcement?.newUrlAddr || config?.newUrlAddr || "").trim();
const telegram = (config?.tgGroup || config?.telegramAdd || config?.telegram || "").trim();
const email = (config?.gmailAddr || "").trim();
const mirrors = [
announcement?.newUrlAddr1,
announcement?.newUrlAddr2,
config?.newUrlAddr1,
config?.newUrlAddr2,
...(config?.useIngAddress ?? []),
]
.map((item) => item?.trim() || "")
.filter(Boolean)
.filter((item, index, list) => list.indexOf(item) === index);
const publishPage = (
announcement?.translationAddr ||
config?.translationAddr ||
config?.webAddr ||
"/way-home"
).trim();
if (!permanent && !latest && !mirrors.length && !telegram && !email) return null;
return {
permanent: permanent || latest,
latest: latest || permanent,
telegram,
email,
mirrors,
publishPage,
};
}
let dialogOpen = false;
const listeners = new Set<() => void>();
function setDialogOpen(open: boolean) {
dialogOpen = open;
listeners.forEach((listener) => listener());
}
export function openAddressDialog() {
setDialogOpen(true);
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
const getSnapshot = () => dialogOpen;
const getServerSnapshot = () => false;
const getBlockedServerSnapshot = () => true;
function markShown() {
try {
if (window.sessionStorage.getItem(SESSION_KEY)) return false;
window.sessionStorage.setItem(SESSION_KEY, "1");
} catch {}
return true;
}
const toUrl = (domain: string) => (domain.startsWith("http") ? domain : `https://${domain}`);
export function AddressDialogHost({
announcement,
config,
}: {
announcement?: AnnouncementInfo | null;
config?: ConfigInfo | null;
}) {
const info = useMemo(() => buildAddressDialogInfo(announcement, config), [announcement, config]);
const open = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const ageGateOpen = useSyncExternalStore(subscribeAgeGate, getAgeGateOpen, getBlockedServerSnapshot);
const adOpen = useSyncExternalStore(subscribeAd, getAdOpen, getBlockedServerSnapshot);
const chromeless = useChromeless();
const pathname = usePathname();
const close = useCallback(() => setDialogOpen(false), []);
useEffect(() => {
// 排序:年龄确认 → 广告队列结束 → 最新地址(仅首页,chromeless 除外)
if (pathname !== "/" || chromeless || ageGateOpen || adOpen || !hasAdShown() || !info) return;
if (markShown()) openAddressDialog();
}, [pathname, chromeless, ageGateOpen, adOpen, info]);
useEffect(() => {
if (!open) return;
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = overflow;
};
}, [open]);
if (!open || !info) return null;
return <AddressCard info={info} onClose={close} />;
}
function AddressCard({
info,
onClose,
}: {
info: AddressDialogInfo;
onClose: () => void;
}) {
// 对齐 latestAddrPop:倒计时期间锁定关闭,结束后才可关(不自动关掉弹窗)
const [seconds, setSeconds] = useState(CLOSE_DELAY_SECONDS);
const canClose = seconds <= 0;
useEffect(() => {
if (canClose) return;
const timer = window.setInterval(() => {
setSeconds((value) => (value <= 1 ? 0 : value - 1));
}, 1000);
return () => window.clearInterval(timer);
}, [canClose]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && canClose) onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [canClose, onClose]);
const tryClose = () => {
if (!canClose) return;
onClose();
};
const publishHref = info.publishPage.startsWith("/") ? info.publishPage : toUrl(info.publishPage);
return (
<div
data-component="address-dialog"
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto bg-black/70 px-4 py-10"
onClick={(event) => event.target === event.currentTarget && tryClose()}
data-close-locked={canClose ? undefined : "true"}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="address-dialog-title"
className="my-auto flex w-[300px] flex-col gap-3 rounded-[20px] bg-[#0B0914] px-5 py-6 text-sm/5 text-white/90 outline outline-1 outline-white/20 lg:w-[400px] lg:gap-[18px] lg:rounded-[40px] lg:p-9 lg:text-base/[22px]"
>
<div className="flex items-center justify-between">
<h2 id="address-dialog-title" className="text-base/[22px] font-medium lg:text-xl/7">
{canClose ? "现在可以关闭" : `${seconds}秒后会自动关闭`}
</h2>
<button
type="button"
aria-label="关闭"
disabled={!canClose}
onClick={tryClose}
className={`size-6 text-white ${canClose ? "cursor-pointer" : "cursor-not-allowed opacity-40"}`}
>
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
<button
type="button"
onClick={() => saveAddressImage(info)}
className="cursor-pointer flex items-center gap-1.5 self-end text-sm/5 transition hover:text-white lg:gap-3.5"
>
<svg viewBox="0 0 20 20" className="size-5" fill="none" aria-hidden="true">
<rect x="3.33" y="3.33" width="13.33" height="13.33" rx="2" stroke="white" strokeWidth="1.5" />
<circle cx="12.5" cy="7.5" r="1" fill="white" />
<path d="M3.75 14.5l4-4.25 3 3 1.75-1.75 3.75 3.75" stroke="white" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
保存地址为图片
</button>
<div className="flex flex-col gap-3">
<p>请花几秒钟记住以下信息!</p>
{info.permanent ? <p>永久地址: {info.permanent}</p> : null}
{info.latest ? <p>最新地址: {info.latest}</p> : null}
{info.telegram ? (
<p>
TG交流群:{" "}
<a
href={info.telegram}
target="_blank"
rel="noopener noreferrer"
className="text-[#FF2D55] hover:underline lg:font-medium"
>
点击加入官方TG群!
</a>
</p>
) : null}
{info.email ? (
<>
<p>
官方邮箱:{" "}
<a href={`mailto:${info.email}`} className="hover:underline">
{info.email}
</a>
</p>
<p className="text-sm/5">(发送任意邮件,自动回复最新地址)</p>
</>
) : null}
{info.mirrors.length > 0 ? (
<>
<p>
<span className="text-[#FF2D55]">由于封控原因,域名经常被和谐,</span>请右上角
<span className="text-[#FF2D55]">保存图片</span>以下为最新地址发布页
</p>
<div className="flex flex-wrap gap-1.5 lg:gap-[11px]">
{info.mirrors.map((domain, index) => (
<a
key={`${domain}-${index}`}
href={toUrl(domain)}
target="_blank"
rel="noopener noreferrer"
className="rounded bg-[#EF2385] px-3 py-2 text-xs/[18px] font-medium text-white transition hover:bg-[#F23D94]"
>
{domain}
</a>
))}
</div>
</>
) : null}
</div>
<div className="flex gap-4">
<a
href={publishHref}
target={info.publishPage.startsWith("/") ? undefined : "_blank"}
rel={info.publishPage.startsWith("/") ? undefined : "noopener noreferrer"}
aria-disabled={!canClose}
tabIndex={canClose ? 0 : -1}
onClick={(event) => {
if (!canClose) {
event.preventDefault();
return;
}
tryClose();
}}
className={`flex-1 rounded px-[18px] py-3 text-center text-sm/5 outline outline-1 -outline-offset-1 outline-white/30 transition ${
canClose ? "hover:bg-white/5" : "pointer-events-none opacity-40"
}`}
>
地址发布页
</a>
<button
type="button"
disabled={!canClose}
onClick={tryClose}
className={`flex-1 rounded bg-[#EF2385] px-[18px] py-3 text-sm/5 text-white transition ${
canClose ? "cursor-pointer hover:bg-[#F23D94]" : "cursor-not-allowed opacity-40"
}`}
>
立即关闭
</button>
</div>
</section>
</div>
);
}
type Segment = { text: string; color?: string };
function saveAddressImage(info: AddressDialogInfo) {
const width = 400;
const padding = 36;
const contentWidth = width - padding * 2;
const scale = 2;
const font = '16px "PingFang SC", "Microsoft YaHei", sans-serif';
const lines: Segment[][] = [
[{ text: "请花几秒钟记住以下信息!" }],
...(info.permanent ? [[{ text: `永久地址: ${info.permanent}` }]] : []),
...(info.latest ? [[{ text: `最新地址: ${info.latest}` }]] : []),
...(info.telegram
? [[{ text: "TG交流群: " }, { text: info.telegram, color: ACCENT }]]
: []),
...(info.email ? [[{ text: `官方邮箱: ${info.email}` }]] : []),
...(info.email ? [[{ text: "(发送任意邮件,自动回复最新地址)" }]] : []),
...(info.publishPage
? [[{ text: "地址发布页: " }, { text: info.publishPage, color: ACCENT }]]
: []),
...info.mirrors.map((domain, index) => [{ text: `备用地址${index + 1}: ${domain}` }]),
];
const measure = document.createElement("canvas").getContext("2d");
if (!measure) return;
measure.font = font;
const rows: Segment[][] = [];
for (const line of lines) {
let row: Segment[] = [];
let rowWidth = 0;
for (const segment of line) {
for (const char of segment.text) {
const charWidth = measure.measureText(char).width;
if (rowWidth + charWidth > contentWidth) {
rows.push(row);
row = [];
rowWidth = 0;
}
const last = row[row.length - 1];
if (last && last.color === segment.color) last.text += char;
else row.push({ text: char, color: segment.color });
rowWidth += charWidth;
}
}
rows.push(row);
}
const lineHeight = 22;
const rowGap = 12;
const titleHeight = 28;
const height = padding * 2 + titleHeight + 18 + rows.length * (lineHeight + rowGap) - rowGap;
const canvas = document.createElement("canvas");
canvas.width = width * scale;
canvas.height = height * scale;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.scale(scale, scale);
ctx.fillStyle = "#0B0914";
ctx.fillRect(0, 0, width, height);
ctx.textBaseline = "middle";
ctx.fillStyle = "#FFFFFF";
ctx.font = '500 20px "PingFang SC", "Microsoft YaHei", sans-serif';
ctx.fillText("海角社区 · 最新地址", padding, padding + titleHeight / 2);
ctx.font = font;
let y = padding + titleHeight + 18 + lineHeight / 2;
for (const row of rows) {
let x = padding;
for (const segment of row) {
ctx.fillStyle = segment.color ?? "rgba(255,255,255,0.9)";
ctx.fillText(segment.text, x, y);
x += ctx.measureText(segment.text).width;
}
y += lineHeight + rowGap;
}
const link = document.createElement("a");
link.download = "海角社区地址.png";
link.href = canvas.toDataURL("image/png");
link.click();
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { useEffect, useSyncExternalStore } from "react";
import { useChromeless } from "@/components/layout/route-visibility";
/** 对齐 jha-1043 eightPop.js:sessionStorage + 关闭/退出均离开站点 */
const SESSION_KEY = "ageConfirmed";
const EXIT_URL = "about:blank";
let confirmed: boolean | null = null;
const listeners = new Set<() => void>();
function emit() {
listeners.forEach((listener) => listener());
}
function readConfirmed() {
if (confirmed === null) {
try {
confirmed = window.sessionStorage.getItem(SESSION_KEY) === "1";
} catch {
confirmed = false;
}
}
return confirmed;
}
function confirmAge() {
confirmed = true;
try {
window.sessionStorage.setItem(SESSION_KEY, "1");
} catch {
// sessionStorage 不可用时仍允许本页确认并关闭
}
emit();
}
export function openAgeGateDialog() {
confirmed = false;
try {
window.sessionStorage.removeItem(SESSION_KEY);
} catch {}
emit();
}
export function subscribeAgeGate(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export const getAgeGateOpen = () => !readConfirmed();
const getServerSnapshot = () => false;
function exitSite() {
try {
window.location.replace(EXIT_URL);
} catch {
window.location.href = EXIT_URL;
}
}
export function AgeGateDialogHost() {
const chromeless = useChromeless();
const open = useSyncExternalStore(subscribeAgeGate, getAgeGateOpen, getServerSnapshot) && !chromeless;
useEffect(() => {
if (!open) return;
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = overflow;
};
}, [open]);
if (!open) return null;
return (
<div data-component="age-gate-dialog" className="fixed inset-0 z-[60] flex flex-col items-center overflow-y-auto bg-black/70 px-4 py-10">
<div className="my-auto flex flex-col items-center gap-9 lg:gap-6">
<section
role="dialog"
aria-modal="true"
aria-labelledby="age-gate-title"
aria-describedby="age-gate-desc"
className="flex w-[300px] flex-col items-center gap-[18px] rounded-[20px] bg-[#0B0914] px-5 py-6 outline outline-1 outline-white/20 lg:w-[440px] lg:gap-9 lg:rounded-[40px] lg:p-9"
>
<AdultIcon />
<div className="flex flex-col gap-[18px] text-white/90 lg:gap-6">
<h2
id="age-gate-title"
className="text-center text-lg/6 font-semibold lg:text-2xl/8 lg:font-medium"
>
这是个成人网站
</h2>
<p id="age-gate-desc" className="text-justify text-sm/5 lg:text-lg/6">
本网站包含有年龄限制的内容,包括裸体和露骨色情素材的内容。
点击继续即表示您确认您已年满 18 岁,或在您访问本网站时所在的司法管辖区已是成年人
<span className="hidden lg:inline">。</span>
</p>
</div>
<div className="flex w-full flex-col gap-3">
<button
type="button"
onClick={confirmAge}
className="cursor-pointer rounded-xl bg-[#E9019C] py-[13px] text-base/[22px] font-semibold text-white transition hover:bg-[#F21AAA]"
>
我已年满18岁-进入
</button>
<button
type="button"
onClick={exitSite}
className="cursor-pointer rounded-xl bg-white/10 py-[13px] text-base/[22px] text-white/90 transition hover:bg-white/15"
>
我未满18岁-退出
</button>
</div>
</section>
<button type="button" aria-label="退出" onClick={exitSite} className="cursor-pointer size-9">
<svg viewBox="0 0 44 44" className="size-full" aria-hidden="true">
<circle cx="22" cy="22" r="21" fill="none" stroke="white" strokeWidth="2" />
<path d="M16 16l12 12M28 16L16 28" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
);
}
function AdultIcon() {
return (
<svg viewBox="0 0 78 78" className="size-[78px] shrink-0 text-[#E9019C]" aria-hidden="true">
<circle cx="39" cy="39" r="35" fill="none" stroke="currentColor" strokeWidth="5" />
<text
x="39"
y="40"
fill="currentColor"
fontSize="26"
fontWeight="800"
textAnchor="middle"
dominantBaseline="central"
fontFamily="Arial, Helvetica, sans-serif"
>
18+
</text>
</svg>
);
}
@@ -0,0 +1,223 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef, type ReactNode } from "react";
import { resolveChannel } from "@/lib/analytics/channel";
import {
externalAd,
inferPage,
initializeTracker,
navigation,
pageClick,
pageView,
setPage,
track,
tracker,
type TrackerBoot,
} from "@/components/analytics/tracker";
const SDK_SRC = "/js/web-sdk-v1.2.0.js?v=1";
function loadScript(src: string): Promise<void> {
return new Promise((resolve) => {
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
if (existing) {
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => resolve();
document.head.append(script);
});
}
function bootThirdParty() {
const google = (process.env.NEXT_PUBLIC_GTAG_ID || "").trim();
const yandexRaw = (process.env.NEXT_PUBLIC_YANDEX_METRIKA_ID || "").trim();
const yandex = yandexRaw ? Number(yandexRaw) : 0;
// layout 已注入 GA4 时勿重复加载
if (
google &&
!document.querySelector(`script[src*="googletagmanager.com/gtag/js?id=${google}"]`)
) {
window.dataLayer ||= [];
window.gtag ||= function gtag(...args: unknown[]) {
window.dataLayer!.push(args);
};
window.gtag("js", new Date());
window.gtag("config", google);
void loadScript(`https://www.googletagmanager.com/gtag/js?id=${google}`);
}
if (yandex) {
window.ym ||= Object.assign(
function ym(...args: unknown[]) {
(window.ym!.a ||= []).push(args);
},
{ a: [] as unknown[][], l: Date.now() },
);
window.ym(yandex, "init", {
ssr: true,
webvisor: true,
clickmap: true,
ecommerce: "dataLayer",
referrer: document.referrer,
url: location.href,
accurateTrackBounce: true,
trackLinks: true,
});
void loadScript(`https://mc.yandex.ru/metrika/tag.js?id=${yandex}`);
}
}
export function TrackerProvider({
boot: explicitBoot,
children,
}: {
boot?: TrackerBoot;
children?: ReactNode;
}) {
const pathname = usePathname();
const readyRef = useRef(false);
const lastPathRef = useRef("");
useEffect(() => {
let disposed = false;
const channel = resolveChannel();
const inferred = inferPage(pathname || location.pathname);
const boot: TrackerBoot = {
appId: explicitBoot?.appId || process.env.NEXT_PUBLIC_TRACKER_APP_ID || "JHA-1046",
encryptedConfig:
explicitBoot?.encryptedConfig ||
process.env.NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG ||
"",
appVersion: explicitBoot?.appVersion || "1.0.0",
pageKey: explicitBoot?.pageKey || inferred.key,
pageName: explicitBoot?.pageName || inferred.name,
userType: explicitBoot?.userType || "normal",
uid: explicitBoot?.uid || "",
channel: explicitBoot?.channel || channel,
};
void loadScript(SDK_SRC)
.then(() => initializeTracker(boot))
.then(() => {
if (disposed) return;
readyRef.current = true;
lastPathRef.current = pathname || location.pathname;
pageView();
bootThirdParty();
});
const click = (event: MouseEvent) => {
if (!(event.target instanceof Element)) return;
const target = event.target;
if (
!target.closest(
"[data-ad-close],[data-dialog-ad-close],[data-download-close],[data-login-close]",
)
) {
pageClick(event);
}
const anchor = target.closest<HTMLAnchorElement>(
"a[data-navigation-key],a[data-nav-key]",
);
if (anchor) navigation(anchor);
const ad = target.closest<HTMLElement>('[data-track-ad="external"][data-ad-id]');
if (ad) externalAd(ad, "click");
};
document.addEventListener("click", click, true);
const observer =
typeof IntersectionObserver === "undefined"
? null
: new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
externalAd(entry.target as HTMLElement, "show");
observer?.unobserve(entry.target);
}
}
},
{ threshold: 0.1 },
);
const observed = new WeakSet<Element>();
const observe = () =>
document
.querySelectorAll('[data-track-ad="external"][data-ad-id]:not([data-ad-clone])')
.forEach((node) => {
if (!observed.has(node)) {
observed.add(node);
if (observer) observer.observe(node);
else externalAd(node as HTMLElement, "show");
}
});
observe();
const mutations = new MutationObserver(observe);
mutations.observe(document.body, { childList: true, subtree: true });
const external = (event: Event) => {
const detail = (event as CustomEvent<{ element: HTMLElement; eventType: string }>).detail;
if (detail?.element) externalAd(detail.element, detail.eventType);
};
const advertising = (event: Event) => {
const detail = (event as CustomEvent<{ element: HTMLElement; eventType: string }>).detail;
const node = detail?.element;
if (!node) return;
void track("advertising", {
event_type: detail.eventType,
advertising_key: node.dataset.advertisingKey || "dialog_ad",
advertising_name: node.dataset.advertisingName || "弹窗广告",
advertising_id: node.dataset.advertisingId || node.dataset.dialogAdId || "",
});
};
document.addEventListener("external-ad:track", external);
document.addEventListener("dialog-ad:track", advertising);
const flush = () => {
void tracker.flush();
};
window.addEventListener("pagehide", flush);
return () => {
disposed = true;
observer?.disconnect();
mutations.disconnect();
document.removeEventListener("click", click, true);
document.removeEventListener("external-ad:track", external);
document.removeEventListener("dialog-ad:track", advertising);
window.removeEventListener("pagehide", flush);
};
// 仅随 boot 关键字段变化重建;pathname 由下方 effect 处理
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
explicitBoot?.appId,
explicitBoot?.encryptedConfig,
explicitBoot?.uid,
explicitBoot?.channel,
explicitBoot?.userType,
]);
useEffect(() => {
if (!readyRef.current) return;
const path = pathname || "";
if (!path || path === lastPathRef.current) return;
lastPathRef.current = path;
const next = inferPage(path);
setPage(next, true);
}, [pathname]);
return children;
}
export function useTracker() {
return tracker;
}
export default TrackerProvider;
+36
View File
@@ -0,0 +1,36 @@
import { connection } from "next/server";
import { getHomeBaseCached } from "@/lib/api/home";
import { TrackerProvider } from "./TrackerProvider";
/**
* 服务端拉取 reportSdkConfig,客户端初始化 Web SDK。
* 须放在 <Suspense> 内,并用 connection() 排除在 prerender shell 外。
*/
export async function TrackerHost() {
await connection();
let encryptedConfig =
process.env.NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG?.trim() ||
process.env.TRACKER_ENCRYPTED_CONFIG?.trim() ||
"";
try {
const base = await getHomeBaseCached();
// 接口字段在 config.reportSdkConfig
const fromApi = base.config?.reportSdkConfig?.trim();
if (fromApi) encryptedConfig = fromApi;
} catch {
// 接口失败时用环境变量兜底
}
return (
<TrackerProvider
boot={{
appId: process.env.NEXT_PUBLIC_TRACKER_APP_ID || "JHA-1046",
encryptedConfig,
appVersion: "1.0.0",
}}
/>
);
}
+570
View File
@@ -0,0 +1,570 @@
/** 数据上报字段(对齐 Web SDK / jha-1043 tracker) */
export type Fields = Record<string, unknown>;
export interface TrackerBoot {
appId?: string;
encryptedConfig?: unknown;
appVersion?: string;
uid?: string;
channel?: string;
pageKey?: string;
pageName?: string;
userType?: string;
}
interface WebSDK {
init(options: Fields): unknown;
track: Record<string, (payload: Fields) => unknown>;
getCommonFields?(keys: string[]): Promise<Fields>;
flush?(): unknown;
setUid?(uid: string): void;
setChannel?(channel: string): void;
setContext?(fields: Fields): void;
}
declare global {
interface Window {
WebSDK?: WebSDK;
__TRACKER_BOOT__?: TrackerBoot;
__TRACKER__?: typeof tracker;
tracker?: typeof tracker;
__trackerCommonFields?: Record<string, string>;
__CHANNEL_CODE__?: string;
dataLayer?: unknown[];
gtag?: (...args: unknown[]) => void;
ym?: ((...args: unknown[]) => void) & { a?: unknown[][]; l?: number };
}
}
const str = (value: unknown, fallback = "") =>
String(value ?? "").trim() || fallback;
const number = (value: unknown, fallback = 0) =>
Number.isFinite(Number(value)) ? Number(value) : fallback;
const commonKeys = [
"sid",
"device_id",
"device",
"device_fingerprint",
"fp_version",
];
const serverKeys = [
"app_id",
"uid",
"channel",
"sid",
"client_ts",
"device",
"device_id",
"device_brand",
"device_model",
"device_fingerprint",
"fp_version",
"user_agent",
"sdk_version",
"app_version",
];
let activeBoot: TrackerBoot = {};
let finishReady: () => void;
const ready = new Promise<void>((resolve) => {
finishReady = resolve;
});
let initializing = false;
let common: Record<string, string> = Object.fromEntries(
commonKeys.map((key) => [key, ""]),
);
let commonReady: Promise<void> = Promise.resolve();
let page = { key: "internal_other", name: "站内其他页面" };
let userType = "normal";
let drama: Fields | null = null;
const impressions = new Set<string>();
export function normalizeUID(value: unknown): string {
const uid = str(value);
return uid.length >= 4 &&
uid.length <= 32 &&
/^[A-Za-z0-9_]+$/.test(uid) &&
!["u_1001", "stress_user", "u_lab"].includes(uid.toLowerCase())
? uid
: "";
}
export function normalizeChannel(value: unknown): string {
const channel = str(value);
return channel.length <= 50 &&
/^[A-Za-z0-9_.-]+$/.test(channel) &&
!["0", "self", "natural", "official", "channel", "unknown", "-"].includes(
channel.toLowerCase(),
)
? channel
: "organic";
}
/** 按路径推断 page_key / page_name(对齐 jha-1043 tracker_page.go,适配本站路由) */
export function inferPage(path: string) {
const raw = (path || "/").split("?")[0] || "/";
const p = raw.length > 1 && raw.endsWith("/") ? raw.slice(0, -1) : raw;
if (p === "/" || p === "") return { key: "home", name: "首页" };
// 短剧:/short-drama/{sort} 列表;/short-drama/{slug} 详情;/short-drama/{slug}/{ep} 播放
if (/^\/short-drama(?:\/|$)/.test(p)) {
const parts = p.split("/").filter(Boolean); // short-drama, ...
if (parts.length >= 3 && /^\d+$/.test(parts[2] || "")) {
return { key: "play", name: "短剧播放页" };
}
// 排序段(hot/latest 及旧中文别名)视为列表,其余视为详情
const sortKeys = new Set(["hot", "latest", "热度优先", "最近更新"]);
if (parts.length === 2 && sortKeys.has(decodeURIComponent(parts[1] || ""))) {
return { key: "list", name: "短剧列表" };
}
if (parts.length === 1) return { key: "list", name: "短剧列表" };
return { key: "detail", name: "短剧详情" };
}
// 视频:列表 / 播放
if (/^\/video(?:\/|$)/.test(p)) {
const parts = p.split("/").filter(Boolean);
const sortKeys = new Set(["hot", "latest", "热度优先", "最近更新"]);
if (parts.length === 2 && sortKeys.has(decodeURIComponent(parts[1] || ""))) {
return { key: "list", name: "视频列表" };
}
if (parts.length === 1) return { key: "list", name: "视频列表" };
return { key: "play", name: "视频播放页" };
}
const rules: Array<[RegExp, string, string]> = [
[/^\/ranking(?:\/|$)/, "rank", "排行榜"],
[/^\/topic(?:\/|$)/, "topic", "专题详情"],
[/^\/more(?:\/|$)/, "topic", "专题详情"],
[/^\/tag(?:\/|$)/, "tag_list", "标签详情"],
[/^\/search(?:\/|$)/, "search", "搜索结果页"],
[/^\/history(?:\/|$)/, "history", "观看历史"],
[/^\/favorites(?:\/|$)/, "collect", "我的收藏"],
[/^\/likes(?:\/|$)/, "likes", "我的点赞"],
[/^\/profile(?:\/|$)/, "user_center", "个人中心"],
[/^\/settings\/profile(?:\/|$)/, "edit_profile", "编辑资料"],
[/^\/settings(?:\/|$)/, "setting", "设置"],
[/^\/way-home(?:\/|$)/, "home_road", "回家的路"],
[/^\/welfare(?:\/|$)/, "application", "应用推荐"],
[/^\/nav(?:\/|$)/, "nav", "官方导航"],
[/^\/about(?:\/|$)/, "about_us", "关于我们"],
[/^\/terms(?:\/|$)/, "user_agreement", "用户协议"],
[/^\/privacy(?:\/|$)/, "privacy_policy", "隐私政策"],
[/^\/help(?:\/|$)/, "help_center", "帮助中心"],
[/^\/feedback(?:\/|$)/, "feedback", "问题反馈"],
[/^\/richtext(?:\/|$)/, "rich_text", "富文本页"],
[/^\/collection(?:\/|$)/, "collect", "合集"],
];
const hit = rules.find(([re]) => re.test(p));
return hit
? { key: hit[1], name: hit[2] }
: { key: "internal_other", name: "站内其他页面" };
}
/** SPA 路由切换时更新当前页并上报 page_view */
export function setPage(next: { key: string; name: string }, reportView = true) {
page = { key: str(next.key, page.key), name: str(next.name, page.name) };
if (reportView) pageView();
}
export function initializeTracker(boot: TrackerBoot): Promise<void> {
const inferred = inferPage(location.pathname);
page = {
key: str(boot.pageKey, inferred.key),
name: str(boot.pageName, inferred.name),
};
userType = boot.userType === "vip" ? "vip" : "normal";
activeBoot = boot;
window.__TRACKER_BOOT__ = boot;
window.__TRACKER__ = tracker;
window.tracker = tracker;
if (!initializing) {
initializing = true;
const resolve = () => finishReady();
let attempts = 0;
const timer = setInterval(() => {
if (!window.WebSDK?.track && ++attempts < 100) return;
clearInterval(timer);
if (!window.WebSDK) {
resolve();
return;
}
Promise.resolve()
.then(() =>
window.WebSDK?.init({
appId: str(boot.appId),
uid: normalizeUID(boot.uid),
channel: normalizeChannel(boot.channel || window.__CHANNEL_CODE__),
encryptedConfig: str(boot.encryptedConfig),
appVersion: str(boot.appVersion, "1.0.0"),
disableAutoEvents: ["app_page_view", "page_click"],
}),
)
.catch(() => undefined)
.then(() => {
// Core events need only SDK initialization; device fingerprint loading must not stall them.
commonReady = (async () => {
try {
const fields = await window.WebSDK?.getCommonFields?.(
commonKeys.slice(),
);
common = Object.fromEntries(
commonKeys.map((key) => [key, str(fields?.[key])]),
);
} catch {
/* Empty common fields preserve the original degraded SDK contract. */
}
window.__trackerCommonFields = common;
window.dispatchEvent(
new CustomEvent("tracker:common-fields", { detail: common }),
);
})();
window.dispatchEvent(
new CustomEvent("tracker:ready", { detail: page }),
);
resolve();
});
}, 50);
}
return ready;
}
export async function track(method: string, payload: Fields): Promise<unknown> {
await ready;
try {
return await window.WebSDK?.track[method]?.(payload);
} catch {
return { sent: false, ok: false, reason: "exception" };
}
}
export function pageView(extra: Fields = {}) {
let referrer = { key: "direct", name: "直接访问" };
try {
const saved = sessionStorage.getItem("__tracker_last_page");
if (saved) referrer = JSON.parse(saved);
else if (document.referrer) {
const url = new URL(document.referrer);
referrer =
url.host === location.host
? inferPage(url.pathname)
: { key: "external", name: url.host };
}
} catch {
/* Storage is optional. */
}
const navigation = performance.getEntriesByType("navigation")[0] as
| PerformanceNavigationTiming
| undefined;
void track("appPageView", {
user_type: userType,
page_key: page.key,
page_name: page.name,
referrer_page_key: referrer.key,
referrer_page_name: referrer.name,
current_page_key: page.key,
current_page_name: page.name,
page_load_time: Math.max(
0,
Math.round(
navigation?.loadEventEnd ||
navigation?.domContentLoadedEventEnd ||
performance.now(),
),
),
recommend_trace_id: "",
...extra,
});
try {
sessionStorage.setItem("__tracker_last_page", JSON.stringify(page));
} catch {
/* Storage is optional. */
}
}
export function navigation(anchor: HTMLAnchorElement) {
const href = anchor.getAttribute("href");
if (!href || /^(#|javascript:)/i.test(href)) return;
let key = str(anchor.dataset.navigationKey || anchor.dataset.navKey);
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key)) {
const url = new URL(href, location.href);
const target = inferPage(url.pathname);
key =
url.origin !== location.origin
? "nav_external"
: target.key !== "internal_other"
? "nav_" + target.key
: "nav_" +
(url.pathname.split("/").filter(Boolean)[0] || "home")
.replace(/[^a-z0-9]+/gi, "_")
.slice(0, 48);
}
void track("navigation", {
navigation_key: key.toLowerCase(),
navigation_name: str(
anchor.dataset.navigationName || anchor.dataset.navName,
anchor.getAttribute("aria-label") ||
anchor.textContent?.replace(/\s+/g, " ") ||
key,
).slice(0, 255),
});
}
export function pageClick(event: MouseEvent) {
void track("pageClick", {
page_key: page.key,
page_name: page.name,
click_page_x: Math.trunc(event.clientX),
click_page_y: Math.trunc(event.clientY),
click_x_percent: innerWidth
? Math.round(
Math.max(0, Math.min(100, (event.clientX / innerWidth) * 100)),
)
: 0,
click_y_percent: innerHeight
? Math.round(
Math.max(0, Math.min(100, (event.clientY / innerHeight) * 100)),
)
: 0,
screen_width: screen.width,
screen_height: screen.height,
recommend_trace_id: "",
});
}
export function externalAd(node: HTMLElement, type: string) {
if (!["show", "click"].includes(type) || !node.dataset.adId) return;
const slots = Array.from(
document.querySelectorAll(
'[data-track-ad="external"][data-ad-id]:not([data-ad-clone])',
),
);
const slot = node.hasAttribute("data-ad-clone")
? slots.find(
(slot) => slot.getAttribute("data-ad-id") === node.dataset.adId,
)
: node;
const position = Math.max(1, slots.indexOf(slot as Element) + 1);
const interstitial = node.dataset.adType === "interstitial";
const payload = {
page_key: page.key,
page_name: page.name,
ad_slot_key: str(
node.dataset.adSlotKey,
`${page.key}_${interstitial ? "popup" : "content_banner"}_${position}`,
),
ad_slot_name: str(
node.dataset.adSlotName,
page.name + (interstitial ? "弹窗广告位" : `内容横幅第${position}位`),
),
ad_id: node.dataset.adId,
creative_id: str(node.dataset.creativeId),
ad_type: str(node.dataset.adType, "banner"),
};
const key = payload.ad_slot_key + "|" + payload.ad_id;
if (!impressions.has(key)) {
impressions.add(key);
void track("adImpression", payload);
}
if (type === "click" && node.dataset.adClickable === "1")
void track("adClick", payload);
}
export function setDramaContext(value: Fields | null) {
drama = value;
}
const behaviorNames: Record<string, string> = {
ep_view: "展示",
ep_play: "播放",
ep_pause: "暂停",
ep_forward: "快进",
ep_rewind: "快退",
ep_complete: "播放完成",
ep_share: "分享",
ep_fail: "播放失败",
};
const videoBehaviorNames: Record<string, string> = {
video_view: "展示",
video_play: "播放",
video_pause: "暂停",
video_forward: "快进",
video_rewind: "快退",
video_complete: "播放完成",
video_fail: "播放失败",
};
let videoCtx: Fields | null = null;
export function setVideoContext(value: Fields | null) {
videoCtx = value;
}
export function videoEvent(
behavior: string,
media?: HTMLVideoElement | null,
options: { played?: number; forceProgress?: number } = {},
) {
if (!videoCtx) return Promise.resolve();
const duration =
media && Number.isFinite(media.duration)
? Math.round(media.duration)
: number(videoCtx.video_duration);
const current = Math.round(media?.currentTime || 0);
return track("videoEvent", {
...videoCtx,
media_id: str(videoCtx.media_id, str(videoCtx.video_id)),
video_duration: Math.max(0, duration),
play_duration: Math.max(0, Math.round(options.played || 0)),
play_progress:
options.forceProgress ??
(behavior === "video_complete"
? 100
: duration > 0
? Math.round(Math.min(100, (current / duration) * 100))
: 0),
play_speed: Math.max(1, Math.round((media?.playbackRate || 1) * 100)),
video_behavior_key: behavior,
video_behavior_name: videoBehaviorNames[behavior] || behavior,
});
}
export function dramaEvent(
behavior: string,
video?: HTMLVideoElement | null,
options: { played?: number; forceProgress?: number } = {},
) {
if (!drama) return Promise.resolve();
const duration =
video && Number.isFinite(video.duration)
? Math.round(video.duration)
: number(drama.drama_duration);
const current = Math.round(video?.currentTime || 0);
return track("dramaEvent", {
...drama,
drama_duration: Math.max(0, duration),
play_duration: Math.max(0, Math.round(options.played || 0)),
play_progress:
options.forceProgress ??
(behavior === "ep_complete"
? 100
: duration > 0
? Math.round(Math.min(100, (current / duration) * 100))
: 0),
play_speed: Math.max(1, Math.round((video?.playbackRate || 1) * 100)),
play_behavior_key: behavior,
play_behavior_name: behaviorNames[behavior] || behavior,
});
}
export function dramaStatusChange(
episode: number,
videoID: string | number,
override?: string,
) {
if (!drama) return Promise.resolve();
const previous = number(drama.episode, 1);
const behavior =
override ||
(episode === previous + 1
? "ep_next"
: episode === previous - 1
? "ep_previous"
: "ep_switch");
if (!override && episode === previous && str(videoID) === str(drama.video_id))
return Promise.resolve();
const names: Record<string, string> = {
ep_auto_play: "自动播放",
ep_previous: "上一集",
ep_next: "下一集",
ep_switch: "切换剧集",
ep_unlock: "解锁",
};
return track("dramaStatusChange", {
media_id: drama.media_id,
drama_id: drama.drama_id,
drama_title: drama.drama_title,
episode: Math.max(1, episode),
video_id: str(videoID, str(drama.video_id)),
pre_episode: previous,
drama_behavior_key: behavior,
drama_behavior_name: names[behavior] || behavior,
});
}
/** Full per-request SDK snapshot for server-side drama events; header compatibility remains five fields. */
export async function serverContext(keys = serverKeys): Promise<Fields> {
await ready;
let fields: Fields = {};
try {
fields = (await window.WebSDK?.getCommonFields?.(serverKeys.slice())) || {};
} catch {
/* Cached device identifiers remain usable. */
}
const defaults: Fields = {
...common,
app_id: str(activeBoot.appId),
uid: normalizeUID(activeBoot.uid),
channel: normalizeChannel(activeBoot.channel || window.__CHANNEL_CODE__),
client_ts: Math.floor(Date.now() / 1000),
device_brand: "",
device_model: "",
user_agent: navigator.userAgent,
sdk_version: "1.2.0",
app_version: str(activeBoot.appVersion, "1.0.0"),
};
return Object.fromEntries(
keys.map((key) => [
key,
key === "client_ts"
? Math.floor(number(fields[key], number(defaults[key])))
: str(fields[key] ?? defaults[key]),
]),
);
}
export const tracker = {
init: initializeTracker,
pageView,
setPage,
inferPage,
dramaEvent,
dramaStatusChange,
dramaUnlock: () =>
dramaStatusChange(
number(drama?.episode, 1),
str(drama?.video_id),
"ep_unlock",
),
setDramaContext,
videoEvent,
setVideoContext,
getVideoContext: () => (videoCtx ? { ...videoCtx } : null),
externalAd,
advertising: (payload: Fields) => track("advertising", payload),
keywordSearch: (payload: Fields) => track("keywordSearch", payload),
keywordClick: (payload: Fields) => track("keywordClick", payload),
getDramaContext: () => ({ ...drama }),
getCommonFieldsSync: () => common,
getCommonFields: serverContext,
serverContext,
getCommonField: async (key: string) => {
await ready;
await commonReady;
return common[key] || "";
},
encodeSDKCommonHeader: (fields = common) =>
btoa(
unescape(
encodeURIComponent(
JSON.stringify(
Object.fromEntries(
commonKeys.map((key) => [key, str(fields[key])]),
),
),
),
),
),
getSDKCommonHeader: async () => {
await ready;
await commonReady;
return tracker.encodeSDKCommonHeader();
},
flush: async () => {
try {
await window.WebSDK?.flush?.();
} catch {
/* Analytics must never interrupt playback. */
}
},
setUid: (uid: unknown) => {
activeBoot.uid = normalizeUID(uid);
window.WebSDK?.setUid?.(activeBoot.uid);
},
setChannel: (channel: unknown) => {
activeBoot.channel = normalizeChannel(channel);
window.WebSDK?.setChannel?.(activeBoot.channel);
},
setContext: (context: Fields) => window.WebSDK?.setContext?.(context),
};
@@ -0,0 +1,168 @@
"use client";
import { useEffect, useRef } from "react";
import {
dramaEvent,
setDramaContext,
setVideoContext,
tracker,
videoEvent,
type Fields,
} from "@/components/analytics/tracker";
export function useDramaContext(context: Fields | null) {
const viewed = useRef("");
useEffect(() => {
setDramaContext(context);
const key = context ? `${String(context.drama_id)}:${String(context.video_id)}` : "";
if (context && viewed.current !== key) {
viewed.current = key;
void dramaEvent("ep_view");
}
return () => setDramaContext(null);
}, [context]);
}
export function useVideoContext(context: Fields | null) {
const viewed = useRef("");
useEffect(() => {
setVideoContext(context);
const key = context ? String(context.video_id) : "";
if (context && viewed.current !== key) {
viewed.current = key;
void videoEvent("video_view");
}
return () => setVideoContext(null);
}, [context]);
}
const reporters = new WeakMap<HTMLVideoElement, (behavior: string) => void>();
export function reportPlaybackAction(behavior: string, video?: HTMLVideoElement | null) {
const report = video && reporters.get(video);
if (report) report(behavior);
else void dramaEvent(behavior, video);
}
/** 墙钟播放时长(排除缓冲/seek),对齐原 SDK */
export function bindPlaybackAnalytics(
video: HTMLVideoElement,
enabled: boolean,
kind: "drama" | "video" = "drama",
): () => void {
if (!enabled) return () => undefined;
let started = 0;
let elapsed = 0;
let position = 0;
let seekFrom: number | null = null;
const settle = () => {
if (started) elapsed += Math.max(0, Date.now() - started);
started = 0;
};
const begin = () => {
if (!started) started = Date.now();
};
const mapBehavior = (behavior: string) => {
if (kind === "drama") return behavior;
const map: Record<string, string> = {
ep_play: "video_play",
ep_pause: "video_pause",
ep_forward: "video_forward",
ep_rewind: "video_rewind",
ep_complete: "video_complete",
ep_fail: "video_fail",
};
return map[behavior] || behavior;
};
const emit = (behavior: string, keep = false, forceProgress?: number) => {
const played = (elapsed + (started ? Math.max(0, Date.now() - started) : 0)) / 1000;
const key = mapBehavior(behavior);
if (kind === "video") void videoEvent(key, video, { played, forceProgress });
else void dramaEvent(key, video, { played, forceProgress });
if (!keep) {
elapsed = 0;
if (started) started = Date.now();
}
};
reporters.set(video, emit);
const events: Record<string, () => void> = {
play: () => emit("ep_play"),
playing: begin,
waiting: settle,
pause: () => {
const pending = started || elapsed > 0;
settle();
if (!video.ended && !video.seeking && pending) emit("ep_pause");
},
timeupdate: () => {
if (!video.seeking) position = video.currentTime;
},
seeking: () => {
if (seekFrom == null) {
settle();
seekFrom = position;
}
},
seeked: () => {
if (seekFrom == null) return;
const from = seekFrom;
seekFrom = null;
position = video.currentTime;
if (Math.abs(position - from) >= 0.5) emit(position > from ? "ep_forward" : "ep_rewind");
if (!video.paused && !video.ended) begin();
},
ended: () => {
settle();
emit("ep_complete", true);
emit("ep_play", false, 100);
},
error: () => {
settle();
emit("ep_fail");
},
};
Object.entries(events).forEach(([name, handler]) => video.addEventListener(name, handler));
const report = () => {
if (elapsed + (started ? Date.now() - started : 0) >= 500) {
settle();
emit("ep_play");
void tracker.flush();
setTimeout(() => void tracker.flush(), 50);
setTimeout(() => void tracker.flush(), 200);
}
};
const visibility = () => {
if (document.visibilityState === "hidden") report();
if (!video.paused && !video.ended) begin();
};
document.addEventListener("visibilitychange", visibility);
window.addEventListener("pagehide", report);
window.addEventListener("beforeunload", report);
return () => {
reporters.delete(video);
const pending = started || elapsed;
settle();
if (pending && !video.ended) emit("ep_pause");
Object.entries(events).forEach(([name, handler]) => video.removeEventListener(name, handler));
document.removeEventListener("visibilitychange", visibility);
window.removeEventListener("pagehide", report);
window.removeEventListener("beforeunload", report);
};
}
/** 从 xgplayer 实例拿到内部 video 并绑定埋点 */
export function bindPlayerAnalytics(
player: { media?: HTMLVideoElement | null; video?: HTMLVideoElement | null } | null | undefined,
kind: "drama" | "video" = "drama",
): () => void {
const video = player?.media || player?.video || null;
if (!video) return () => undefined;
return bindPlaybackAnalytics(video, true, kind);
}
@@ -0,0 +1,18 @@
import { connection } from "next/server";
import { buildServerForwardHeaders } from "@/lib/api/client-headers";
import { primeRequestForwardHeaders } from "@/lib/api/request-context";
/**
* 请求期注入上游透传头(UA / IP)。
* 必须放在 <Suspense> 内,禁止在 RootLayout 顶层直接 headers(),否则会 blocking-prerender-runtime。
*/
export async function RequestHeadersPrime() {
await connection();
try {
primeRequestForwardHeaders(await buildServerForwardHeaders());
} catch {
// 无请求上下文时跳过
}
return null;
}
@@ -0,0 +1,11 @@
"use client";
import { useEffect } from "react";
import { bindUnauthorizedHandler } from "@/lib/auth/handle-unauthorized";
/** 将 api 401 / 无权限接到清会话 + 重新登录 */
export function AuthUnauthorizedBridge() {
useEffect(() => bindUnauthorizedHandler(), []);
return null;
}
@@ -0,0 +1,52 @@
"use client";
import { useEffect, useState } from "react";
import { ensureGuestSession } from "@/lib/auth/session";
import { tracker } from "@/lib/tracker";
/**
* 仅在挂载后异步拉游客会话,不参与 SSR / 首屏阻塞。
* ensureGuestSession 本地有缓存时会同步返回并后台 refresh。
*/
export function GuestAuthBootstrap() {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
// 不 await:失败/超时也不拖住其它渲染
void ensureGuestSession()
.then((user) => {
if (cancelled || !user) return;
// 埋点 uid:SDK 要求非空时长度 ≥ 4
tracker.setUid(String(user.userId || ""));
if (process.env.NODE_ENV === "development") {
console.info("[guest-auth]", {
userId: user.userId,
nickName: user.nickName,
isRegister: user.isRegister,
loginType: user.loginType,
});
}
})
.catch((err: unknown) => {
if (cancelled) return;
const message = err instanceof Error ? err.message : "游客登录失败";
setError(message);
console.error("[guest-auth]", err);
});
return () => {
cancelled = true;
};
}, []);
if (process.env.NODE_ENV !== "development" || !error) return null;
return (
<div className="pointer-events-none fixed right-3 bottom-3 z-[60] max-w-xs rounded-lg bg-black/80 px-3 py-2 text-xs text-[#FF8FA8]">
游客登录失败:{error}
</div>
);
}
+385
View File
@@ -0,0 +1,385 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useId, useState, useSyncExternalStore, type FormEvent, type ReactNode } from "react";
import { useMockUser } from "@/components/profile/use-mock-user";
import {
ACCOUNT_RULE_HINT,
CREDENTIAL_MAX_LEN,
isValidCredential,
PASSWORD_RULE_HINT,
} from "@/lib/auth/credentials";
type Mode = "login" | "register";
let currentMode: Mode | null = null;
const listeners = new Set<() => void>();
function setDialogMode(mode: Mode | null) {
currentMode = mode;
listeners.forEach((listener) => listener());
}
export function openLoginDialog(mode: Mode = "login") {
setDialogMode(mode);
}
export function openRegisterDialog() {
setDialogMode("register");
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
const getSnapshot = () => currentMode;
const getServerSnapshot = () => null;
const desktopQuery = "(min-width: 1024px)";
function subscribeDesktop(callback: () => void) {
const media = window.matchMedia(desktopQuery);
media.addEventListener("change", callback);
return () => media.removeEventListener("change", callback);
}
const getDesktopSnapshot = () => window.matchMedia(desktopQuery).matches;
const getDesktopServerSnapshot = () => false;
const emptyValues = { account: "", password: "", confirmPassword: "" };
type Field = keyof typeof emptyValues;
type Errors = Partial<Record<Field, string>>;
function validate(mode: Mode, values: typeof emptyValues): Errors {
const errors: Errors = {};
if (!values.account) errors.account = "请输入账号";
else if (!isValidCredential(values.account)) errors.account = ACCOUNT_RULE_HINT;
if (!values.password) errors.password = "请输入密码";
else if (!isValidCredential(values.password))
errors.password = mode === "login" ? "密码错误,请重新输入" : PASSWORD_RULE_HINT;
if (mode === "register") {
if (!values.confirmPassword) errors.confirmPassword = "请再次输入密码";
else if (values.confirmPassword !== values.password) errors.confirmPassword = "两次输入的密码不一致";
}
return errors;
}
function mapAuthError(message: string, mode: Mode): Errors {
if (/账户不存在|账号不存在|用户不存在/.test(message)) return { account: message };
if (/已存在|重复|already exists/i.test(message)) return { account: "该账号已被注册" };
if (/数据库|database/i.test(message)) return { account: "注册服务暂时异常,请稍后重试" };
if (/密码|password/i.test(message)) return { password: message };
if (/参数/.test(message)) return { account: message };
return mode === "login" ? { password: message } : { account: message };
}
export function LoginDialogHost() {
const mode = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const isDesktop = useSyncExternalStore(subscribeDesktop, getDesktopSnapshot, getDesktopServerSnapshot);
const { login, register } = useMockUser();
const [values, setValues] = useState(emptyValues);
const [errors, setErrors] = useState<Errors>({});
const [submitting, setSubmitting] = useState(false);
const close = useCallback(() => {
if (submitting) return;
setValues(emptyValues);
setErrors({});
setDialogMode(null);
}, [submitting]);
useEffect(() => {
if (!mode) return;
const onKeyDown = (event: KeyboardEvent) => event.key === "Escape" && close();
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = overflow;
window.removeEventListener("keydown", onKeyDown);
};
}, [mode, close]);
if (!mode) return null;
const isLogin = mode === "login";
const placeholderClass = isLogin ? "" : "lg:placeholder:text-[#746E85]";
const switchMode = (next: Mode) => {
if (submitting) return;
setErrors({});
setValues((current) => ({ ...current, confirmPassword: "" }));
setDialogMode(next);
};
const update = (key: Field) => (value: string) => {
setValues((current) => ({ ...current, [key]: value }));
setErrors((current) => ({ ...current, [key]: undefined }));
};
const submit = async (event: FormEvent) => {
event.preventDefault();
const nextErrors = validate(mode, values);
setErrors(nextErrors);
if (Object.keys(nextErrors).length || submitting) return;
setSubmitting(true);
try {
if (isLogin) await login(values.account, values.password);
else await register(values.account, values.password);
if (process.env.NODE_ENV === "development") {
console.info(isLogin ? "[auth-login]" : "[auth-register]", { account: values.account });
}
setValues(emptyValues);
setErrors({});
setDialogMode(null);
} catch (error) {
const message = error instanceof Error ? error.message : isLogin ? "登录失败,请稍后重试" : "注册失败,请稍后重试";
setErrors(mapAuthError(message, mode));
} finally {
setSubmitting(false);
}
};
return (
<div
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto bg-black/70 px-4 py-10"
onClick={(event) => event.target === event.currentTarget && close()}
>
<div className={`my-auto flex flex-col items-center lg:gap-6 ${isLogin ? "gap-[11px]" : "gap-[7px]"}`}>
<form
role="dialog"
aria-modal="true"
aria-labelledby="login-dialog-title"
onSubmit={submit}
className="flex w-[311px] flex-col items-center gap-4 rounded-3xl bg-[#0E0C17] px-[18px] py-6 lg:w-[456px] lg:gap-3.5 lg:bg-[#0A0915]/90 lg:px-8 lg:py-7 lg:shadow-[0_20px_50px_rgba(0,0,0,0.35)] lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-white/13"
>
<img src="/logo.svg" alt="海角社区" className="h-[33px] w-auto lg:h-12" />
{isLogin ? (
<div className="flex gap-4 self-stretch lg:hidden" role="tablist">
<TabButton active onClick={() => switchMode("login")}>
登录
</TabButton>
<TabButton active={false} onClick={() => switchMode("register")}>
注册
</TabButton>
</div>
) : null}
<div className="flex flex-col gap-4 self-stretch lg:gap-2">
<h2
id="login-dialog-title"
className={`text-xl leading-[30px] font-bold text-white lg:not-sr-only lg:text-[30px] lg:leading-9 lg:font-semibold ${isLogin ? "sr-only" : ""}`}
>
{isLogin ? "登录" : "创建账号"}
</h2>
{isLogin ? (
<p className="text-xs leading-[18px] text-[#A8A1B8] lg:text-sm lg:leading-5 lg:text-[#9D97AE]">
登录后同步收藏、历史与会员权益
</p>
) : (
<p className="text-xs leading-[18px] text-[#9E94AD] lg:text-sm lg:leading-5 lg:text-[#A8A1B6]">
<span className="lg:hidden">注册后同步收藏、历史与会员权益</span>
<span className="max-lg:hidden">注册成功即可同步收藏与追剧记录</span>
</p>
)}
</div>
<AuthField
label="账号"
icon={<UserIcon />}
placeholder="请输入账号"
value={values.account}
onChange={update("account")}
autoComplete="username"
placeholderClass={placeholderClass}
error={errors.account}
/>
<AuthField
label="密码"
icon={<LockIcon />}
placeholder="请输入密码"
value={values.password}
onChange={update("password")}
autoComplete={isLogin ? "current-password" : "new-password"}
secret
placeholderClass={placeholderClass}
error={errors.password}
/>
{isLogin ? null : (
<AuthField
label="确认密码"
icon={<LockIcon />}
placeholder={isDesktop ? "请再次输入密码" : "确认密码"}
value={values.confirmPassword}
onChange={update("confirmPassword")}
autoComplete="new-password"
secret
placeholderClass={placeholderClass}
error={errors.confirmPassword}
/>
)}
<button
type="submit"
disabled={submitting}
className="cursor-pointer h-11 self-stretch rounded-xl bg-[#F12B9E] text-base leading-[22px] font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-wait disabled:opacity-70 lg:h-12 lg:text-sm lg:leading-5 lg:font-semibold"
>
{submitting ? (
isLogin ? "登录中…" : "注册中…"
) : isLogin ? (
<>
<span className="lg:hidden">登录</span>
<span className="max-lg:hidden">立即登录</span>
</>
) : (
"立即注册"
)}
</button>
<p className="flex h-[22px] items-center justify-center gap-2.5 self-stretch text-xs leading-[18px] lg:h-6 lg:text-sm lg:leading-5">
<span className="text-[#9790A6] max-lg:hidden">{isLogin ? "还没有账号?" : "已有账号?"}</span>
<button
type="button"
onClick={() => switchMode(isLogin ? "register" : "login")}
className="cursor-pointer font-bold text-[#F12B9E] hover:opacity-80 lg:font-semibold"
>
<span className="lg:hidden">{isLogin ? "没有账号?注册账号" : "已有账号? 去登录"}</span>
<span className="max-lg:hidden">{isLogin ? "立即注册" : "去登录"}</span>
</button>
</p>
<p
className={`self-stretch text-center text-xs leading-[18px] lg:hidden ${isLogin ? "text-[#7A738A]" : "text-[#9E94AD]"}`}
>
{isLogin ? "登录" : "注册"}即代表同意
<Link href="/terms" onClick={close} className="text-[#A8A1B8]">
《用户协议》
</Link>
和
<Link href="/privacy" onClick={close} className="text-[#A8A1B8]">
《隐私政策》
</Link>
</p>
</form>
<button type="button" aria-label="关闭" onClick={close} className="cursor-pointer size-11 lg:size-9">
<svg viewBox="0 0 44 44" className="size-full" aria-hidden="true">
<circle cx="22" cy="22" r="21" fill="none" stroke="white" strokeWidth="2" />
<path d="M16 16l12 12M28 16L16 28" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
);
}
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={`cursor-pointer text-xl leading-[30px] ${active ? "font-bold text-white" : "text-[#656565]"}`}
>
{children}
</button>
);
}
function AuthField({
label,
icon,
placeholder,
value,
onChange,
autoComplete,
secret = false,
placeholderClass = "",
error,
}: {
label: string;
icon: ReactNode;
placeholder: string;
value: string;
onChange: (value: string) => void;
autoComplete: string;
secret?: boolean;
placeholderClass?: string;
error?: string;
}) {
const [visible, setVisible] = useState(false);
const errorId = useId();
const outlineClass = error
? "outline outline-1 -outline-offset-1 outline-[#FF4A5C]"
: "focus-within:outline focus-within:outline-1 focus-within:-outline-offset-1 focus-within:outline-[#F12B9E]/60 lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-white/10 lg:focus-within:outline-[#F12B9E]/60";
return (
<div data-component="login-dialog" className="flex flex-col gap-1.5 self-stretch">
<label className="flex flex-col gap-2">
<span className="sr-only text-sm leading-5 font-semibold text-[#E0DDE8] lg:not-sr-only">{label}</span>
<span className={`flex h-[50px] items-center gap-2 rounded-xl bg-white/7 px-3.5 lg:h-12 ${outlineClass}`}>
{icon}
<input
type={secret && !visible ? "password" : "text"}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
maxLength={CREDENTIAL_MAX_LEN}
autoComplete={autoComplete}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
className={`min-w-0 flex-1 bg-transparent text-sm leading-5 text-white outline-none placeholder:text-[#7A738A] ${placeholderClass}`}
/>
{secret ? (
<button
type="button"
aria-label={visible ? "隐藏密码" : "显示密码"}
onClick={() => setVisible((current) => !current)}
className="cursor-pointer flex size-5 shrink-0 items-center justify-center"
>
<svg viewBox="0 0 20 16" className="h-[13.33px] w-[17.5px]" aria-hidden="true">
<path
d="M1 8s3.3-6 9-6 9 6 9 6-3.3 6-9 6-9-6-9-6z"
fill="none"
stroke="#A8A1B8"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<circle cx="10" cy="8" r="2.6" fill="#A8A1B8" />
{visible ? null : <path d="M3 15L17 1" stroke="#A8A1B8" strokeWidth="1.6" strokeLinecap="round" />}
</svg>
</button>
) : null}
</span>
</label>
{error ? (
<p id={errorId} role="alert" className="text-xs leading-[18px] text-[#FF5C6E]">
{error}
</p>
) : null}
</div>
);
}
function UserIcon() {
return (
<svg viewBox="0 0 20 20" className="size-5 shrink-0" aria-hidden="true">
<circle cx="10" cy="7" r="3.25" fill="none" stroke="#A8A1B8" strokeWidth="1.5" />
<path d="M3.75 16.5c.9-2.9 3.3-4.5 6.25-4.5s5.35 1.6 6.25 4.5" fill="none" stroke="#A8A1B8" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function LockIcon() {
return (
<svg viewBox="0 0 20 20" className="size-5 shrink-0" aria-hidden="true">
<rect x="4" y="8.5" width="12" height="8.5" rx="2" fill="none" stroke="#A8A1B8" strokeWidth="1.5" />
<path d="M7 8.5V6.5a3 3 0 0 1 6 0v2" fill="none" stroke="#A8A1B8" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
@@ -0,0 +1,212 @@
"use client";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import { EmptyState } from "@/components/common/empty-state";
import { Pagination } from "@/components/common/pagination";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { H5ShortDramaCard, ShortDramaCard } from "@/components/short-drama/short-drama-card";
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
type CollectionPageProps<T extends string, S extends string> = {
title: string;
subtitle: string;
breadcrumb: string;
types: readonly T[];
mobileTypes?: readonly T[];
scopes: readonly S[];
defaultScope: S;
totalPages: number;
getItems: (type: T, scope: S, page: number) => ShortDramaItem[];
clearable?: boolean;
showMobileScopes?: boolean;
showPcScopes?: boolean;
showVideoScopes?: boolean;
showMobileEpisodes?: boolean;
emptyText: string;
};
type FilterPillsProps<T extends string> = {
options: readonly T[];
value: T;
onChange: (value: T) => void;
className?: string;
itemClassName: string;
};
function FilterPills<T extends string>({ options, value, onChange, className = "", itemClassName }: FilterPillsProps<T>) {
return (
<div className={`items-center ${className}`}>
{options.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === value}
onClick={() => onChange(option)}
className={`cursor-pointer flex items-center justify-center rounded-2xl transition-colors ${itemClassName} ${
option === value ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
}`}
>
{option}
</button>
))}
</div>
);
}
export function CollectionPage<T extends string, S extends string>({
title,
subtitle,
breadcrumb,
types,
mobileTypes = types,
scopes,
defaultScope,
totalPages,
getItems,
clearable = false,
showMobileScopes = true,
showPcScopes = true,
showVideoScopes = true,
showMobileEpisodes = true,
emptyText,
}: CollectionPageProps<T, S>) {
const router = useRouter();
const [type, setType] = useState<T>(types[0]);
const [scope, setScope] = useState<S>(defaultScope);
const [page, setPage] = useState(1);
const [cleared, setCleared] = useState(false);
const topRef = useRef<HTMLDivElement>(null);
const items = cleared ? [] : getItems(type, scope, page);
const isVideo = type === "视频";
const PcCard = isVideo ? VideoCard : ShortDramaCard;
const MobileCard = isVideo ? H5VideoCard : H5ShortDramaCard;
const changeType = (next: T) => {
setType(next);
setPage(1);
};
const changeScope = (next: S) => {
setScope(next);
setPage(1);
};
const changePage = (next: number) => {
setPage(next);
topRef.current?.scrollIntoView({ behavior: "smooth" });
};
const goBack = () => {
if (window.history.length > 1) router.back();
else router.push("/profile");
};
return (
<main data-component="collection-page" className="flex-1 bg-[#07060D] lg:bg-[#070611]">
<div className="relative flex h-12 items-center justify-center px-4 lg:hidden">
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<h1 className="text-base leading-[22px] font-bold text-white">{title}</h1>
{clearable ? (
<button
type="button"
onClick={() => setCleared(true)}
disabled={cleared}
className="cursor-pointer absolute right-4 text-xs leading-[18px] font-bold text-[#F12B9E] disabled:opacity-40"
>
清空
</button>
) : null}
</div>
<div
ref={topRef}
data-pc-scale
className="mx-auto flex w-full scroll-mt-24 flex-col gap-3 px-4 py-3 lg:w-[1700px] lg:gap-5 lg:px-0 lg:py-[30px] lg:[zoom:var(--page-scale,1)]"
>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: "个人中心", href: "/profile" },
{ name: breadcrumb },
]}
/>
<div className="hidden items-center lg:flex">
<div className="flex flex-1 flex-col gap-px">
<h2 className="text-[30px] leading-9 font-semibold text-white">{title}</h2>
<p className="text-sm leading-5 text-[#9790A6]">{subtitle}</p>
</div>
{clearable ? (
<button
type="button"
onClick={() => setCleared(true)}
disabled={cleared}
className="cursor-pointer h-9 w-24 rounded-[18px] bg-white/6 text-xs leading-[18px] font-semibold text-[#B5AEC1] transition-colors hover:text-white disabled:opacity-40"
>
清空记录
</button>
) : null}
</div>
<div className="flex flex-col gap-3 lg:gap-2.5">
<FilterPills
options={mobileTypes}
value={type}
onChange={changeType}
className="flex gap-3 lg:hidden"
itemClassName="h-8 px-4 text-sm leading-5"
/>
<FilterPills
options={types}
value={type}
onChange={changeType}
className="hidden gap-[9px] lg:flex"
itemClassName="h-8 w-[88px] px-4 text-xs leading-[18px]"
/>
{isVideo && !showVideoScopes ? null : (
<FilterPills
options={scopes}
value={scope}
onChange={changeScope}
className={`flex gap-2 lg:gap-[9px] ${showMobileScopes ? "" : "max-lg:hidden"} ${showPcScopes ? "" : "lg:hidden"}`}
itemClassName="h-6 px-3 text-xs leading-[18px] lg:h-8 lg:px-4"
/>
)}
</div>
{items.length === 0 ? (
<EmptyState
title={emptyText}
description="去首页发现更多好剧吧"
onReload={null}
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
/>
) : (
<>
<div
className={`hidden justify-between gap-x-5 gap-y-6 lg:grid ${
isVideo ? "grid-cols-[repeat(auto-fill,324px)]" : "grid-cols-[repeat(auto-fill,266px)]"
}`}
>
{items.map((item) => (
<PcCard key={item.id} item={item} />
))}
</div>
<div className="grid grid-cols-2 gap-x-[7px] gap-y-3 lg:hidden">
{items.map((item) => (
<MobileCard key={item.id} item={item} showEpisodes={showMobileEpisodes} />
))}
</div>
<Pagination page={page} total={totalPages} onChange={changePage} showJump={false} />
</>
)}
</div>
</main>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
/** 恒定线速度:px/s,图标多少只影响时长 */
const SCROLL_PX_PER_SEC = 36;
type AdIconMarqueeProps = {
children: ReactNode;
className?: string;
/** 段内间距,需与尾部 spacer 对齐以无缝衔接 */
gapClassName?: string;
/** 段尾 spacer 宽度,应等于 gap(gap-2→w-2,gap-3→w-3) */
spacerClassName?: string;
/** 内容签名:列表变化时重算时长 */
contentKey?: string;
};
/** Icon 广告从右向左无缝自动滚动(内容复制两段,translateX -50%) */
export function AdIconMarquee({
children,
className = "",
gapClassName = "gap-3",
spacerClassName = "w-3",
contentKey = "",
}: AdIconMarqueeProps) {
const segmentRef = useRef<HTMLDivElement>(null);
const [durationSec, setDurationSec] = useState(20);
useLayoutEffect(() => {
const el = segmentRef.current;
if (!el) return;
const update = () => {
const width = el.getBoundingClientRect().width;
if (width <= 0) return;
setDurationSec(Math.max(width / SCROLL_PX_PER_SEC, 8));
};
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, [contentKey]);
return (
<div
data-component="ad-icon-marquee"
className={`overflow-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${className}`}
>
<div
className="ad-icon-marquee-track flex w-max items-start"
style={{ animationDuration: `${durationSec}s` }}
>
<div ref={segmentRef} className={`flex shrink-0 items-start ${gapClassName}`}>
{children}
<span className={`${spacerClassName} shrink-0`} aria-hidden />
</div>
<div className={`flex shrink-0 items-start ${gapClassName}`} aria-hidden>
{children}
<span className={`${spacerClassName} shrink-0`} aria-hidden />
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { AdIcons } from "./ads/ad-icons";
/** @deprecated 使用 AdIcons;保留别名兼容旧引用 */
export function AdIconRow({
labels = true,
items,
advitise,
className = "",
}: {
labels?: boolean;
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
className?: string;
}) {
return <AdIcons variant="pc" labels={labels} items={items} advitise={advitise} className={className} />;
}
+94
View File
@@ -0,0 +1,94 @@
import { DecryptedImage } from "@/components/common/decrypted-image";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { adHref, adTrackProps, pickBannerAds } from "./pick";
type AdBannersProps = {
variant?: "pc" | "h5";
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
className?: string;
};
/** 公共 Banner 广告:对接 advitise.detailbannerList;无数据不渲染 */
export function AdBanners({
variant = "pc",
items,
advitise,
className = "",
}: AdBannersProps) {
const isH5 = variant === "h5";
const apiList = pickBannerAds(items, advitise);
if (!apiList.length) return null;
if (isH5) {
return (
<div
data-component="ad-banners"
data-variant="h5"
className={`flex flex-col gap-[18px] ${className}`}
>
{apiList.map((ad, index) => {
const href = adHref(ad.link, "#h5-download");
const external = Boolean(ad.link?.trim());
return (
<a
key={ad.id || `banner-${index}`}
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className="block h-10 overflow-hidden rounded-xl"
{...adTrackProps(ad, {
slotKey: `content_banner_${index + 1}`,
slotName: `内容横幅第${index + 1}位`,
adType: "banner",
})}
>
<DecryptedImage
src={ad.images || null}
fallback={IMG_PLACEHOLDER}
alt={ad.title || "推广"}
className="h-full w-full object-cover"
/>
</a>
);
})}
</div>
);
}
return (
<div
data-component="ad-banners"
data-variant="pc"
className={`grid grid-cols-2 gap-5 ${className}`}
>
{apiList.map((ad, index) => {
const href = adHref(ad.link);
const external = Boolean(ad.link?.trim());
return (
<a
key={ad.id || `banner-${index}`}
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className="block h-[97px] overflow-hidden rounded-xl"
{...adTrackProps(ad, {
slotKey: `content_banner_${index + 1}`,
slotName: `内容横幅第${index + 1}位`,
adType: "banner",
})}
>
<DecryptedImage
src={ad.images || null}
fallback={IMG_PLACEHOLDER}
alt={ad.title || "推广"}
className="h-full w-full object-cover"
/>
</a>
);
})}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { DecryptedImage } from "@/components/common/decrypted-image";
import { AdIconMarquee } from "@/components/common/ad-icon-marquee";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { adContentKey, adHref, adTrackProps, pickIconAds } from "./pick";
type AdIconsProps = {
variant?: "pc" | "h5";
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
/** PC 是否显示标题;H5 始终显示 */
labels?: boolean;
className?: string;
};
/** 公共 Icon 广告:对接 advitise.nineGridList,无数据不渲染;从右向左无缝滚动 */
export function AdIcons({
variant = "pc",
items,
advitise,
labels = true,
className = "",
}: AdIconsProps) {
const isH5 = variant === "h5";
const apiList = pickIconAds(items, advitise, variant);
if (!apiList.length) return null;
const showLabels = isH5 || labels;
const iconClass = isH5
? "flex w-12 shrink-0 flex-col items-center gap-1"
: "flex w-20 shrink-0 flex-col items-center gap-1";
const imgClass = isH5
? "aspect-square size-12 rounded-xl border border-white/30 object-cover"
: "size-20 rounded-xl border border-white/30 object-cover";
const titleClass = isH5
? "max-w-full truncate text-xs leading-[18px] text-white/90"
: "text-center text-sm leading-5 text-white/90";
return (
<AdIconMarquee
className={className}
gapClassName={isH5 ? "gap-2" : "gap-3"}
spacerClassName={isH5 ? "w-2" : "w-3"}
contentKey={adContentKey(apiList)}
>
{apiList.map((ad, index) => {
const href = adHref(ad.link, isH5 ? "#h5-download" : "#download");
const external = Boolean(ad.link?.trim());
return (
<a
key={ad.id || `ad-${index}`}
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className={iconClass}
{...adTrackProps(ad, {
slotKey: `icon_grid_${index + 1}`,
slotName: `九宫格第${index + 1}位`,
adType: "icon",
})}
>
<DecryptedImage
src={ad.images || null}
fallback={IMG_PLACEHOLDER}
alt={ad.title || ""}
className={imgClass}
/>
{showLabels ? <span className={titleClass}>{ad.title || "广告"}</span> : null}
</a>
);
})}
</AdIconMarquee>
);
}
+4
View File
@@ -0,0 +1,4 @@
/** 仅导出可进 Client Component 的广告 UI;Slot 请从 `@/components/common/ads/slots` 引入 */
export { AdIcons } from "./ad-icons";
export { AdBanners } from "./ad-banners";
export { pickIconAds, pickBannerAds, pickFriendAds } from "./pick";
+56
View File
@@ -0,0 +1,56 @@
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
export function hasAdVisual(item: AdvertiseInfo) {
return Boolean(item.images?.trim() || item.title?.trim());
}
/** Icon:统一对接 advitise.nineGridList(可显式传 items 覆盖) */
export function pickIconAds(
items?: AdvertiseInfo[] | null,
advitise?: AdvitiseData | null,
_variant: "pc" | "h5" = "pc",
) {
void _variant;
const source = items ?? advitise?.nineGridList ?? [];
return source.filter(hasAdVisual);
}
/** Banner:detailbannerList(需有图) */
export function pickBannerAds(items?: AdvertiseInfo[] | null, advitise?: AdvitiseData | null) {
const source = items ?? advitise?.detailbannerList ?? [];
return source.filter((item) => Boolean(item.images?.trim()));
}
/** 友情链接:friendList(需有标题) */
export function pickFriendAds(items?: AdvertiseInfo[] | null, advitise?: AdvitiseData | null) {
const source = items ?? advitise?.friendList ?? [];
return source.filter((item): item is AdvertiseInfo & { title: string } => Boolean(item.title?.trim()));
}
export function adHref(link?: string, fallback = "#download") {
const value = link?.trim();
if (!value) return fallback;
return value.startsWith("http") || value.startsWith("/") ? value : `https://${value}`;
}
export function adContentKey(list: AdvertiseInfo[]) {
return list.map((item) => item.id || item.title || item.images || "").join("|") || "mock";
}
/** 外链广告埋点属性(对齐 hkdj / jha-1043 data-track-ad) */
export function adTrackProps(
ad: AdvertiseInfo,
options?: { slotKey?: string; slotName?: string; adType?: string },
) {
const adId = String(ad.advertiseCode || ad.id || "").trim();
if (!adId) return {};
return {
"data-track-ad": "external" as const,
"data-ad-id": adId,
"data-ad-clickable": "1",
"data-ad-type": options?.adType || "banner",
"data-ad-slot-key": options?.slotKey || ad.advertiseLocationCode || undefined,
"data-ad-slot-name": options?.slotName || ad.advertiseLocationName || ad.title || undefined,
"data-creative-id": String(ad.id || ""),
};
}
+43
View File
@@ -0,0 +1,43 @@
import { getAdvitiseCached } from "@/lib/api/home";
import { AdBanners } from "./ad-banners";
import { AdIcons } from "./ad-icons";
import { FriendLinks, H5FriendLinks } from "../friend-links";
/** 服务端:Icon 广告(home/base → advitise) */
export async function AdIconsSlot({
variant = "pc",
labels = true,
className = "",
}: {
variant?: "pc" | "h5";
labels?: boolean;
className?: string;
}) {
const advitise = await getAdvitiseCached();
return <AdIcons variant={variant} advitise={advitise} labels={labels} className={className} />;
}
/** 服务端:Banner 广告 */
export async function AdBannersSlot({
variant = "pc",
className = "",
}: {
variant?: "pc" | "h5";
className?: string;
}) {
const advitise = await getAdvitiseCached();
return <AdBanners variant={variant} advitise={advitise} className={className} />;
}
/** 服务端:PC 友情链接 */
export async function FriendLinksSlot() {
const advitise = await getAdvitiseCached();
return <FriendLinks advitise={advitise} />;
}
/** 服务端:H5 友情链接 */
export async function H5FriendLinksSlot() {
const advitise = await getAdvitiseCached();
return <H5FriendLinks advitise={advitise} />;
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { useRouter } from "next/navigation";
export function BackButton({ fallback = "/", className = "" }: { fallback?: string; className?: string }) {
const router = useRouter();
const goBack = () => {
if (window.history.length > 1) router.back();
else router.push(fallback);
};
return (
<button data-component="back-button" type="button" aria-label="返回" onClick={goBack} className={`cursor-pointer size-6 ${className}`}>
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import Link from "next/link";
import { useLayoutEffect, useRef, useState } from "react";
import { tagPageHref, type TagContentKind } from "@/components/tag/href";
import { cardTagCanLink, normalizeCardTags, type CardTagItem } from "./pick-card-tags";
/**
* 卡片左下角标签:按容器宽度尽量完整展示;
* 多个放不下时只显示 1 个(可截断),避免「激… · 爆… · 仙…」。
* 跳转优先 slug,无 slug 采用 id。
*
* 注意:空态与有标签态必须使用相同根节点(div),避免 SSR/水合 span↔div 不一致。
*/
export function CardTags({
tags,
kind,
className = "",
}: {
tags?: Array<CardTagItem | string> | string | null;
/** 短剧 / 视频标签库,决定跳转后查哪类内容 */
kind?: TagContentKind;
className?: string;
}) {
const list = normalizeCardTags(tags);
const listKey = list.map((tag) => `${tag.slug ?? ""}:${tag.id ?? ""}:${tag.name}`).join("\0");
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(() => Math.max(1, list.length ? 1 : 0));
const linkKind = kind ?? "playlet";
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
if (!list.length) {
setVisibleCount(0);
return;
}
if (!container || !measure || list.length <= 1) {
setVisibleCount(1);
return;
}
const fit = () => {
const maxWidth = container.clientWidth;
if (maxWidth <= 0) {
setVisibleCount(1);
return;
}
const items = measure.querySelectorAll<HTMLElement>("[data-measure-tag]");
if (!items.length) {
setVisibleCount(1);
return;
}
const origin = items[0].offsetLeft;
let count = 0;
for (let i = 0; i < items.length; i++) {
const right = items[i].offsetLeft + items[i].offsetWidth - origin;
if (right > maxWidth + 0.5) break;
count = i + 1;
}
setVisibleCount(Math.max(1, count));
};
fit();
const observer = new ResizeObserver(fit);
observer.observe(container);
return () => observer.disconnect();
}, [listKey, list.length]);
const visible = list.slice(0, Math.max(visibleCount, list.length ? 1 : 0));
const single = visible.length === 1;
return (
<div className={`relative min-w-0 flex-1 ${className}`}>
{list.length ? (
<>
<div
ref={measureRef}
className="pointer-events-none absolute top-0 left-0 flex items-center gap-1 whitespace-nowrap opacity-0"
aria-hidden="true"
>
{list.map((tag, index) => (
<span key={`${tag.slug ?? tag.id ?? tag.name}-${index}`} data-measure-tag className="flex items-center gap-1">
{index > 0 ? <span className="text-current/40">·</span> : null}
<span>{tag.name}</span>
</span>
))}
</div>
<div ref={containerRef} className="flex min-w-0 items-center gap-1 overflow-hidden">
{visible.map((tag, index) => (
<span
key={`${tag.slug ?? tag.id ?? tag.name}-${index}`}
className={`flex items-center gap-1 ${single ? "min-w-0" : "shrink-0"}`}
>
{index > 0 ? <span className="shrink-0 text-current/40">·</span> : null}
{cardTagCanLink(tag) ? (
<Link
href={tagPageHref(tag, linkKind)}
className={`transition-colors hover:text-[#F12B9E] ${
single ? "min-w-0 truncate" : "whitespace-nowrap"
}`}
>
{tag.name}
</Link>
) : (
<span className={single ? "min-w-0 truncate" : "whitespace-nowrap"}>{tag.name}</span>
)}
</span>
))}
</div>
</>
) : null}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
/** 服务端安全导出:纯函数与 Client 组件拆分,避免 pickCardTags 被标成 client */
export {
CARD_TAG_MAX,
cardTagCanLink,
normalizeCardTags,
pickCardTags,
pickPlayletCardTags,
pickVideoCardTags,
type CardTagItem,
} from "./pick-card-tags";
export { CardTags } from "./card-tags-client";
+27
View File
@@ -0,0 +1,27 @@
type ChannelHeaderProps = {
eyebrow?: string;
title: string;
subtitle?: string;
};
/** PC 标题:与 H5 同页时仅 H5 使用 h1,避免重复;传空字符串可隐藏副标题 */
export function ChannelHeader({ eyebrow = "CHANNEL", title, subtitle = "高能反转,短集连看" }: ChannelHeaderProps) {
return (
<header data-component="channel-header" className="flex flex-col gap-[7px]">
<p className="text-xs leading-[18px] font-semibold text-[#F12B9E]">{eyebrow}</p>
<p className="text-[34px] leading-10 font-semibold text-white">{title}</p>
{subtitle ? <p className="text-xs leading-[18px] text-[#9994AD]">{subtitle}</p> : null}
</header>
);
}
/** H5 频道页唯一 h1(移动优先);传空字符串可隐藏副标题 */
export function H5ChannelHeader({ eyebrow = "CHANNEL", title, subtitle = "高能反转,短集连看" }: ChannelHeaderProps) {
return (
<header className="flex flex-col gap-1.5">
<p className="text-xs leading-[18px] font-semibold text-[#F12B9E]">{eyebrow}</p>
<h1 className="text-lg leading-6 font-semibold text-white">{title}</h1>
{subtitle ? <p className="text-xs leading-[18px] text-[#9994AD]">{subtitle}</p> : null}
</header>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { useSyncExternalStore, type ReactNode } from "react";
const subscribe = () => () => {};
export function ClientOnly({ children, fallback = null }: { children: ReactNode; fallback?: ReactNode }) {
const mounted = useSyncExternalStore(
subscribe,
() => true,
() => false,
);
return mounted ? children : fallback;
}
@@ -0,0 +1,284 @@
"use client";
import { useState, type FormEvent } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { useMockUser } from "@/components/profile/use-mock-user";
import type { CommentInfo } from "@/lib/api/types";
import { COMMENT_MAX_LENGTH, COMMENT_SELF_NAME, COMMENT_SORTS, formatCommentTime, type CommentTarget } from "./data";
import { CommentReplyPreview } from "./comment-reply-preview";
import { HeartIcon } from "./heart-icon";
import { InlineReplyBox } from "./inline-reply-box";
import { useComments } from "./use-comments";
const fallbackAvatar = "/images/default_avatar.svg";
const panelStyles = {
detail: {
section: "rounded-2xl bg-[rgba(24,23,46,0.88)] outline-[#383657]",
item: "py-4",
more: "mt-4 h-[43px] rounded-[7px]",
},
play: {
section: "rounded-xl bg-[rgba(19,15,22,0.91)] outline-[rgba(199,145,89,0.7)]",
item: "py-3",
more: "mt-3 h-8 rounded-lg",
},
};
type ReplyTarget = {
id: number;
userName: string;
};
function CommentComposer({
onSubmit,
posting,
error,
}: {
onSubmit: (content: string) => Promise<boolean>;
posting: boolean;
error: string | null;
}) {
const { user } = useMockUser();
const avatar = user?.avatar || fallbackAvatar;
const displayName = user?.name || COMMENT_SELF_NAME;
const [text, setText] = useState("");
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const content = text.trim();
if (!content || posting) return;
const ok = await onSubmit(content);
if (ok) setText("");
};
return (
<div className="mt-2 flex flex-col gap-2">
<form onSubmit={submit} className="flex h-[76px] items-center gap-4 rounded-[10px] bg-[#211F38] px-4 py-3">
<DecryptedImage
src={avatar}
fallback={fallbackAvatar}
alt=""
className="size-9 shrink-0 rounded-full object-cover"
/>
<label className="flex h-[52px] min-w-0 flex-1 items-center gap-3 rounded-lg bg-[#110F1F] px-3 py-2.5 outline outline-1 -outline-offset-1 outline-[#383657] focus-within:outline-[#F12B9E]">
<input
value={text}
maxLength={COMMENT_MAX_LENGTH}
disabled={posting}
onChange={(event) => setText(event.target.value)}
placeholder="说说你对这部剧的看法…"
aria-label="评论内容"
className="min-w-0 flex-1 bg-transparent text-xs leading-[18px] text-white outline-none placeholder:text-[#948CA8] disabled:opacity-60"
/>
<span className="flex shrink-0 gap-2 text-xs leading-[18px]">
<span className="text-[#F12B9E]">已登录</span>
<span className="text-[#948CA8]">{displayName}</span>
<span className="text-[#736E87]">
{text.length} / {COMMENT_MAX_LENGTH}
</span>
</span>
</label>
<button
type="submit"
disabled={!text.trim() || posting}
className="cursor-pointer h-[52px] w-[120px] shrink-0 rounded-[10px] bg-[#F12B9E] text-xs leading-[18px] font-semibold text-white transition-colors enabled:hover:bg-[#F23DA8] disabled:opacity-60"
>
{posting ? "发布中…" : "发表评论"}
</button>
</form>
{error && !posting ? <p className="text-xs leading-[18px] text-[#FF8FA8]">{error}</p> : null}
</div>
);
}
/** PC 评论面板(详情 / 播放页)— GET /api/app/comment/list */
export function CommentPanel({
variant = "detail",
target,
}: {
variant?: keyof typeof panelStyles;
target: CommentTarget | null;
}) {
const {
sort,
setSort,
comments,
total,
hasMore,
loading,
loadingMore,
error,
loadMore,
toggleLike,
post,
posting,
postError,
reload,
} = useComments({ target });
const styles = panelStyles[variant];
const [replyTo, setReplyTo] = useState<ReplyTarget | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
const startReply = (comment: CommentInfo) => {
const id = Number(comment.id);
if (!Number.isInteger(id) || id < 1) return;
setReplyTo({ id, userName: (comment.userName || "").trim() || "用户" });
};
const toggleExpanded = (commentId: number) => {
setExpandedIds((current) => {
const next = new Set(current);
if (next.has(commentId)) next.delete(commentId);
else next.add(commentId);
return next;
});
};
const submitRoot = async (content: string) => post(content);
const submitReply = async (content: string) => {
if (!replyTo) return false;
const ok = await post(content, { parentsId: replyTo.id });
if (ok) setReplyTo(null);
return ok;
};
return (
<section
data-component="comment-panel"
className={`flex w-[1196px] shrink-0 flex-col p-6 outline outline-1 -outline-offset-1 ${styles.section}`}
>
<div className="flex h-10 items-center justify-between">
<h2 className="text-[22px] leading-[30px] text-white">评论 ({total})</h2>
<div className="flex h-8 items-center gap-3 rounded-[7px] bg-[#110F1F] px-2 text-xs leading-[18px]">
{COMMENT_SORTS.map((item) => (
<button
key={item}
type="button"
aria-pressed={sort === item}
onClick={() => setSort(item)}
className={`cursor-pointer ${sort === item ? "text-white" : "text-[#948CA8] hover:text-white"}`}
>
{item}
</button>
))}
</div>
</div>
<CommentComposer
onSubmit={submitRoot}
posting={posting && !replyTo}
error={!replyTo ? postError : null}
/>
{loading ? (
<p className="py-8 text-center text-sm text-[#948CA8]">评论加载中…</p>
) : error ? (
<div className="flex flex-col items-center gap-3 py-8">
<p className="text-sm text-[#948CA8]">{error}</p>
<button
type="button"
onClick={reload}
className="cursor-pointer rounded-lg bg-[#211F38] px-4 py-2 text-xs text-white hover:opacity-90"
>
重试
</button>
</div>
) : comments.length === 0 ? (
<p className="py-8 text-center text-sm text-[#948CA8]">暂无评论</p>
) : (
<ul>
{comments.map((comment) => {
const liked = Boolean(comment.isLike);
const commentId = Number(comment.id) || 0;
const expanded = expandedIds.has(commentId);
const replies = comment.subComment ?? [];
const isReplying = replyTo?.id === commentId;
return (
<li key={comment.id} className={`flex gap-4 border-b border-[#2E2B47] ${styles.item}`}>
<DecryptedImage
src={comment.avatar}
fallback="/images/default_avatar.svg"
alt=""
className="size-12 shrink-0 rounded-full object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<p className="text-sm leading-[21px] text-[#948CA8]">{comment.userName}</p>
<p className="text-base leading-6 text-white">{comment.text}</p>
<div className="flex items-center gap-2 text-xs leading-[18px] text-[#948CA8]">
<span className="font-semibold">{formatCommentTime(comment.createdAt)}</span>
<button
type="button"
aria-label={liked ? "取消点赞" : "点赞"}
aria-pressed={liked}
onClick={() => void toggleLike(comment)}
className="cursor-pointer flex items-center gap-2 font-semibold"
>
<HeartIcon filled={liked} />
{comment.countLike ?? 0}
</button>
<button
type="button"
onClick={() => (isReplying ? setReplyTo(null) : startReply(comment))}
className={`cursor-pointer hover:text-white ${isReplying ? "text-[#F12B9E]" : ""}`}
>
回复
</button>
</div>
{isReplying && replyTo ? (
<InlineReplyBox
userName={replyTo.userName}
posting={posting}
error={postError}
onSubmit={submitReply}
onCancel={() => setReplyTo(null)}
/>
) : null}
{expanded && replies.length > 1 ? (
<div className="flex flex-col gap-2 rounded-lg bg-[#211F38] px-3 py-2.5 text-xs leading-[18px] text-[#EBE8F5]">
{replies.map((reply) => (
<p key={reply.id}>
{reply.replyUserName
? `${reply.userName || ""} 回复 ${reply.replyUserName}:${reply.text || ""}`
: `${reply.userName || ""}:${reply.text || ""}`}
</p>
))}
<button
type="button"
onClick={() => toggleExpanded(commentId)}
className="cursor-pointer self-start text-[#F12B9E]"
>
收起回复
</button>
</div>
) : (
<CommentReplyPreview
comment={comment}
className="flex flex-col gap-1.5 rounded-lg bg-[#211F38] px-3 py-2.5 text-xs leading-[18px]"
textClassName="text-[#EBE8F5]"
onMore={replies.length > 1 ? () => toggleExpanded(commentId) : undefined}
/>
)}
</div>
</li>
);
})}
</ul>
)}
{!loading && !error && comments.length > 0 ? (
<button
type="button"
disabled={!hasMore || loadingMore}
onClick={loadMore}
className={`cursor-pointer ${styles.more} bg-[#211F38] text-xs leading-[18px] text-[#948CA8] enabled:hover:text-white disabled:opacity-60`}
>
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
</button>
) : null}
</section>
);
}
@@ -0,0 +1,35 @@
import type { CommentInfo } from "@/lib/api/types";
/** 直接展示接口 subComment / commentCount,不做字段改名 */
export function CommentReplyPreview({
comment,
className = "",
textClassName = "",
onMore,
}: {
comment: CommentInfo;
className?: string;
textClassName?: string;
onMore?: () => void;
}) {
const first = comment.subComment?.[0];
const more = Number(comment.commentCount) || comment.subComment?.length || 0;
if (!first && more < 1) return null;
const replyLabel = first
? first.replyUserName
? `${first.userName || ""} 回复 ${first.replyUserName}:${first.text || ""}`
: `${first.userName || ""}:${first.text || ""}`
: `共 ${more} 条回复`;
return (
<div data-component="comment-reply-preview" className={className}>
<p className={textClassName}>{replyLabel}</p>
{more > 0 && onMore ? (
<button type="button" onClick={onMore} className="cursor-pointer self-start text-[#F12B9E]">
查看更多 {more} 条回复 ›
</button>
) : null}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { COMMENT_OBJECT_TYPE, type CommentInfo, type CommentObjectType } from "@/lib/api/types";
export type { CommentInfo };
export type CommentTarget = {
objectId: number;
objectType: CommentObjectType;
};
export const COMMENT_SORTS = ["热门", "最新"] as const;
export type CommentSort = (typeof COMMENT_SORTS)[number];
export const COMMENT_SELF_NAME = "剧迷1024";
export const COMMENT_MAX_LENGTH = 200;
export const COMMENT_PAGE_SIZE = 6;
/** 列表请求超时,避免上游卡住拖住 UI */
export const COMMENT_FETCH_TIMEOUT_MS = 8000;
export function sortToOrder(sort: CommentSort): 1 | 2 {
return sort === "热门" ? 1 : 2;
}
/** 过滤无效评论,保留接口原字段 */
export function filterCommentList(list: CommentInfo[] | null | undefined): CommentInfo[] {
return (list ?? []).filter((item) => {
const id = Number(item?.id);
return Number.isInteger(id) && id > 0;
});
}
/** 本地乐观插入用,字段名与 CommentInfo 一致 */
export function createSelfComment(
text: string,
options?: { userName?: string; avatar?: string },
): CommentInfo {
return {
id: -Date.now(),
userName: (options?.userName || "").trim() || COMMENT_SELF_NAME,
avatar: (options?.avatar || "").trim() || "/images/default_avatar.svg",
text,
createdAt: new Date().toISOString(),
countLike: 0,
isLike: false,
commentCount: 0,
subComment: [],
};
}
export function formatCommentCount(value: number) {
return value >= 10000 ? `${(value / 10000).toFixed(1)}w` : String(value);
}
/** 仅做展示格式化,不改字段名;空则回退原串 */
export function formatCommentTime(value?: string | null) {
const raw = (value || "").trim();
if (!raw) return "";
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return raw;
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
export function commentTarget(
objectId: number | string | null | undefined,
objectType: CommentObjectType,
): CommentTarget | null {
const id = Number(objectId);
if (!Number.isInteger(id) || id < 1) return null;
return { objectId: id, objectType };
}
export function playletCommentTarget(playletId: number | string | null | undefined) {
return commentTarget(playletId, COMMENT_OBJECT_TYPE.playlet);
}
export function playletChapterCommentTarget(chapterId: number | string | null | undefined) {
return commentTarget(chapterId, COMMENT_OBJECT_TYPE.playletChapter);
}
export function videoCommentTarget(videoId: number | string | null | undefined) {
return commentTarget(videoId, COMMENT_OBJECT_TYPE.video);
}
@@ -0,0 +1,204 @@
"use client";
import { useState, type FormEvent, type ReactNode } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { COMMENT_MAX_LENGTH, COMMENT_SORTS, formatCommentTime, type CommentTarget } from "./data";
import { CommentReplyPreview } from "./comment-reply-preview";
import { HeartIcon } from "./heart-icon";
import { InlineReplyBox } from "./inline-reply-box";
import { useComments } from "./use-comments";
function SheetHead({ title, onClose, children }: { title: ReactNode; onClose: () => void; children?: ReactNode }) {
return (
<div className="flex items-center gap-3">
<button
type="button"
aria-label="收起"
onClick={onClose}
className="cursor-pointer flex size-6 items-center justify-center text-white"
>
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
<h2 className="min-w-0 flex-1 text-center text-base leading-[22px] font-medium text-white">{title}</h2>
{children ?? <span className="size-6" />}
</div>
);
}
/** H5 短剧播放页评论底部弹层 */
export function H5CommentSheet({ onClose, target }: { onClose: () => void; target: CommentTarget | null }) {
const {
sort,
setSort,
comments,
total,
hasMore,
loading,
loadingMore,
error,
loadMore,
toggleLike,
post,
posting,
postError,
reload,
} = useComments({ target });
const [text, setText] = useState("");
const [replyTo, setReplyTo] = useState<{ id: number; userName: string } | null>(null);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const content = text.trim();
if (!content || posting || replyTo) return;
const ok = await post(content);
if (ok) setText("");
};
const submitReply = async (content: string) => {
if (!replyTo) return false;
const ok = await post(content, { parentsId: replyTo.id });
if (ok) setReplyTo(null);
return ok;
};
return (
<div data-component="h5-comment-sheet" className="flex h-[70vh] flex-col">
<div className="px-4 pt-[18px] pb-3">
<SheetHead title={`评论 ${total}`} onClose={onClose}>
<div className="flex gap-3 text-xs leading-[18px]">
{COMMENT_SORTS.map((item) => (
<button
key={item}
type="button"
aria-pressed={sort === item}
onClick={() => setSort(item)}
className={`cursor-pointer ${sort === item ? "text-white" : "text-white/35"}`}
>
{item}
</button>
))}
</div>
</SheetHead>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4">
{loading ? (
<p className="py-10 text-center text-sm text-white/50">评论加载中…</p>
) : error ? (
<div className="flex flex-col items-center gap-3 py-10">
<p className="text-sm text-white/50">{error}</p>
<button
type="button"
onClick={reload}
className="cursor-pointer rounded-md bg-white/10 px-4 py-2 text-xs text-white"
>
重试
</button>
</div>
) : comments.length === 0 ? (
<p className="py-10 text-center text-sm text-white/50">暂无评论</p>
) : (
<ul>
{comments.map((comment) => {
const liked = Boolean(comment.isLike);
const commentId = Number(comment.id) || 0;
const isReplying = replyTo?.id === commentId;
return (
<li key={comment.id} className="flex gap-3 border-b border-white/5 py-3">
<DecryptedImage
src={comment.avatar}
fallback="/images/default_avatar.svg"
alt=""
className="size-8 shrink-0 rounded-full object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<p className="text-xs leading-[18px] text-white/50">{comment.userName}</p>
<p className="text-sm leading-5 text-white">{comment.text}</p>
<div className="flex items-center gap-3 text-[11px] leading-4 text-white/35">
<span>{formatCommentTime(comment.createdAt)}</span>
<button
type="button"
aria-pressed={liked}
onClick={() => void toggleLike(comment)}
className="cursor-pointer flex items-center gap-1"
>
<HeartIcon filled={liked} />
{comment.countLike ?? 0}
</button>
<button
type="button"
onClick={() => {
if (isReplying) {
setReplyTo(null);
return;
}
if (!Number.isInteger(commentId) || commentId < 1) return;
setReplyTo({
id: commentId,
userName: (comment.userName || "").trim() || "用户",
});
}}
className={`cursor-pointer ${isReplying ? "text-[#F12B9E]" : "hover:text-white"}`}
>
回复
</button>
</div>
{isReplying && replyTo ? (
<InlineReplyBox
compact
userName={replyTo.userName}
posting={posting}
error={postError}
onSubmit={submitReply}
onCancel={() => setReplyTo(null)}
/>
) : null}
<CommentReplyPreview
comment={comment}
className="rounded-md bg-white/5 px-2.5 py-2 text-xs leading-[18px]"
textClassName="text-white/70"
/>
</div>
</li>
);
})}
</ul>
)}
{!loading && !error && comments.length > 0 ? (
<button
type="button"
disabled={!hasMore || loadingMore}
onClick={loadMore}
className="cursor-pointer my-3 h-9 w-full rounded-md bg-white/5 text-xs text-white/50 disabled:opacity-50"
>
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
</button>
) : null}
</div>
<div className="border-t border-white/5 px-4 py-2.5">
{!replyTo && postError ? <p className="mb-2 text-xs text-[#FF8FA8]">{postError}</p> : null}
<form onSubmit={submit} className="flex items-center gap-2.5">
<input
value={text}
maxLength={COMMENT_MAX_LENGTH}
disabled={posting || Boolean(replyTo)}
onChange={(event) => setText(event.target.value)}
placeholder="发表评论…"
aria-label="评论内容"
className="h-9 min-w-0 flex-1 rounded-full bg-white/5 px-4 text-sm text-white outline-none placeholder:text-white/35 disabled:opacity-60"
/>
<button
type="submit"
disabled={!text.trim() || posting || Boolean(replyTo)}
className="cursor-pointer h-9 shrink-0 rounded-full bg-[#F12B9E] px-4 text-sm text-white disabled:opacity-50"
>
{posting && !replyTo ? "发送中" : "发送"}
</button>
</form>
</div>
</div>
);
}
@@ -0,0 +1,209 @@
"use client";
import { useState, type FormEvent, type ReactNode } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { COMMENT_MAX_LENGTH, COMMENT_SORTS, formatCommentCount, formatCommentTime } from "./data";
import { CommentReplyPreview } from "./comment-reply-preview";
import { InlineReplyBox } from "./inline-reply-box";
import type { UseCommentsResult } from "./use-comments";
type H5CommentsTabProps = {
/** 由页面 useComments 托管,便于 Tab 标题同步总数 */
state: UseCommentsResult;
footerExtra?: ReactNode;
};
/** H5 视频播放页「评论」Tab */
export function H5CommentsTab({ state, footerExtra }: H5CommentsTabProps) {
const {
sort,
setSort,
comments,
total,
hasMore,
loading,
loadingMore,
error,
loadMore,
toggleLike,
post,
posting,
postError,
reload,
} = state;
const [text, setText] = useState("");
const [replyTo, setReplyTo] = useState<{ id: number; userName: string } | null>(null);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const content = text.trim();
if (!content || posting || replyTo) return;
const ok = await post(content);
if (ok) setText("");
};
const submitReply = async (content: string) => {
if (!replyTo) return false;
const ok = await post(content, { parentsId: replyTo.id });
if (ok) setReplyTo(null);
return ok;
};
return (
<div data-component="h5-comments-tab" className="flex flex-col">
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex items-center justify-between">
<h2 className="text-base leading-[22px] font-medium text-white">{total}条评论</h2>
<div className="flex rounded-full bg-white/5 p-0.5">
{COMMENT_SORTS.map((item) => (
<button
key={item}
type="button"
aria-pressed={sort === item}
onClick={() => setSort(item)}
className={`cursor-pointer rounded-full px-2.5 py-1 text-[10px] leading-4 ${
sort === item ? "bg-white/10 text-white" : "text-white/65"
}`}
>
{item === "最新" ? "最新" : "最热"}
</button>
))}
</div>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-white/50">评论加载中…</p>
) : error ? (
<div className="flex flex-col items-center gap-3 py-8">
<p className="text-sm text-white/50">{error}</p>
<button
type="button"
onClick={reload}
className="cursor-pointer rounded-[3px] bg-white/10 px-4 py-2 text-xs text-white"
>
重试
</button>
</div>
) : comments.length === 0 ? (
<p className="py-8 text-center text-sm text-white/50">暂无评论</p>
) : (
<ul className="flex flex-col gap-5">
{comments.map((comment) => {
const liked = Boolean(comment.isLike);
const commentId = Number(comment.id) || 0;
const isReplying = replyTo?.id === commentId;
return (
<li key={comment.id} className="flex gap-3">
<DecryptedImage
src={comment.avatar}
fallback="/images/default_avatar.svg"
alt=""
className="size-10 shrink-0 rounded-full object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
<p className="text-sm leading-5 text-white/65">{comment.userName}</p>
<p className="text-sm leading-5 break-words text-white">{comment.text}</p>
<div className="flex items-center justify-between text-xs leading-[18px]">
<div className="flex items-center gap-3">
<span className="text-white/65">{formatCommentTime(comment.createdAt)}</span>
<button
type="button"
className={`cursor-pointer ${isReplying ? "text-[#F12B9E]" : "text-white"}`}
onClick={() => {
if (isReplying) {
setReplyTo(null);
return;
}
if (!Number.isInteger(commentId) || commentId < 1) return;
setReplyTo({
id: commentId,
userName: (comment.userName || "").trim() || "用户",
});
}}
>
回复
</button>
</div>
<button
type="button"
aria-pressed={liked}
onClick={() => void toggleLike(comment)}
className="cursor-pointer flex items-center gap-1 text-white/65"
>
<svg viewBox="0 0 18 18" className="size-[18px]" aria-hidden="true">
<path
d="M9 15.5c-.22 0-.45-.08-.62-.23C5.97 13.25 2.25 10.65 2.25 7.2c0-2.25 1.6-4.05 3.6-4.05 1.24 0 2.37.68 3.15 1.7.78-1.02 1.9-1.7 3.15-1.7 2 0 3.6 1.8 3.6 4.05 0 3.45-3.72 6.05-6.13 8.07-.17.15-.4.23-.62.23Z"
fill={liked ? "#F52C56" : "none"}
stroke="#F52C56"
strokeWidth="1.4"
/>
</svg>
{formatCommentCount(Number(comment.countLike) || 0)}
</button>
</div>
{isReplying && replyTo ? (
<InlineReplyBox
compact
userName={replyTo.userName}
posting={posting}
error={postError}
onSubmit={submitReply}
onCancel={() => setReplyTo(null)}
/>
) : null}
<CommentReplyPreview
comment={comment}
className="flex flex-col gap-1 rounded-[3px] bg-white/5 px-3 py-1.5 text-xs leading-[18px]"
textClassName="text-white/80"
/>
</div>
</li>
);
})}
</ul>
)}
{!loading && !error && comments.length > 0 ? (
<button
type="button"
disabled={!hasMore || loadingMore}
onClick={loadMore}
className="cursor-pointer h-9 w-full rounded-[3px] bg-white/5 text-xs text-white/50 disabled:opacity-50"
>
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
</button>
) : null}
</div>
<div className="sticky bottom-0 z-10 bg-[#1A1A1A]">
{!replyTo && postError ? <p className="px-4 pt-2 text-xs text-[#FF8FA8]">{postError}</p> : null}
<form onSubmit={submit} className="flex h-[58px] items-center px-4 py-1.5">
<label className="flex h-full flex-1 items-center gap-2 rounded-[3px] bg-white/5 px-3 py-1.5">
<input
value={text}
maxLength={COMMENT_MAX_LENGTH}
disabled={posting || Boolean(replyTo)}
onChange={(event) => setText(event.target.value)}
placeholder={posting && !replyTo ? "发布中…" : "发表评论…"}
enterKeyHint="send"
className="min-w-0 flex-1 bg-transparent text-sm leading-5 text-white outline-none placeholder:text-white/35 disabled:opacity-60"
/>
<svg viewBox="0 0 24 24" className="size-6 shrink-0" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="rgba(255,255,255,0.65)" strokeWidth="1.5" />
<path
d="M8.5 14a4 4 0 0 0 7 0"
stroke="rgba(255,255,255,0.65)"
strokeWidth="1.5"
strokeLinecap="round"
/>
<circle cx="9" cy="10" r="1" fill="rgba(255,255,255,0.65)" />
<circle cx="15" cy="10" r="1" fill="rgba(255,255,255,0.65)" />
</svg>
</label>
</form>
</div>
{footerExtra}
</div>
);
}
@@ -0,0 +1,12 @@
export function HeartIcon({ filled, className = "size-[18px]" }: { filled: boolean; className?: string }) {
return (
<svg viewBox="0 0 14 14" fill="none" className={className} aria-hidden="true">
<path
d="M7 11.5c-.2 0-.4-.07-.55-.2C4.6 9.8 2.5 8.2 2.5 5.9 2.5 4.5 3.5 3.5 4.8 3.5c.9 0 1.7.5 2.2 1.2.5-.7 1.3-1.2 2.2-1.2 1.3 0 2.3 1 2.3 2.4 0 2.3-2.1 3.9-3.95 5.4-.15.13-.35.2-.55.2Z"
fill={filled ? "#F12B9E" : "none"}
stroke={filled ? "#F12B9E" : "rgba(255,255,255,0.55)"}
strokeWidth="1.5"
/>
</svg>
);
}
+25
View File
@@ -0,0 +1,25 @@
export {
COMMENT_FETCH_TIMEOUT_MS,
COMMENT_MAX_LENGTH,
COMMENT_PAGE_SIZE,
COMMENT_SELF_NAME,
COMMENT_SORTS,
commentTarget,
createSelfComment,
filterCommentList,
formatCommentCount,
formatCommentTime,
playletChapterCommentTarget,
playletCommentTarget,
sortToOrder,
videoCommentTarget,
type CommentSort,
type CommentTarget,
} from "./data";
export { CommentPanel } from "./comment-panel";
export { CommentReplyPreview } from "./comment-reply-preview";
export { H5CommentSheet } from "./h5-comment-sheet";
export { H5CommentsTab } from "./h5-comments-tab";
export { HeartIcon } from "./heart-icon";
export { useComments, type UseCommentsOptions, type UseCommentsResult } from "./use-comments";
export type { CommentInfo } from "@/lib/api/types";
@@ -0,0 +1,102 @@
"use client";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { COMMENT_MAX_LENGTH } from "./data";
/** 挂在单条评论下方的回复输入框 */
export function InlineReplyBox({
userName,
posting,
error,
onSubmit,
onCancel,
compact = false,
}: {
userName: string;
posting: boolean;
error?: string | null;
onSubmit: (content: string) => Promise<boolean>;
onCancel: () => void;
compact?: boolean;
}) {
const [text, setText] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const content = text.trim();
if (!content || posting) return;
const ok = await onSubmit(content);
if (ok) setText("");
};
return (
<div
data-component="inline-reply-box"
className={`flex flex-col gap-2 ${
compact
? "mt-1 rounded-md bg-white/[0.06] p-2.5 outline outline-1 -outline-offset-1 outline-white/15"
: "mt-2 rounded-xl bg-[#211F38] p-3 outline outline-1 -outline-offset-1 outline-[#4A4668]"
}`}
>
<div
className={`flex items-center justify-between leading-[18px] ${
compact ? "text-[11px] text-white/55" : "text-xs text-[#948CA8]"
}`}
>
<span>
回复 <span className="font-medium text-[#F12B9E]">@{userName}</span>
</span>
<button
type="button"
onClick={onCancel}
className="cursor-pointer text-white/50 transition-colors hover:text-white"
>
取消
</button>
</div>
<form onSubmit={submit} className="flex items-center gap-2">
<label
className={`flex min-w-0 flex-1 items-center gap-2 transition-[outline-color] ${
compact
? "h-10 rounded-[3px] bg-[#0F0F0F] px-3 outline outline-1 -outline-offset-1 outline-white/20 focus-within:outline-[#F12B9E]"
: "h-11 rounded-lg bg-[#110F1F] px-3 outline outline-1 -outline-offset-1 outline-[#5A5678] focus-within:outline-[#F12B9E]"
}`}
>
<input
ref={inputRef}
value={text}
maxLength={COMMENT_MAX_LENGTH}
disabled={posting}
onChange={(event) => setText(event.target.value)}
placeholder={`回复 @${userName}…`}
aria-label={`回复 ${userName}`}
className={`min-w-0 flex-1 bg-transparent text-white outline-none placeholder:text-white/40 disabled:opacity-60 ${
compact ? "text-sm leading-5" : "text-sm leading-5"
}`}
/>
<span className="shrink-0 text-[10px] leading-4 text-white/30">
{text.length}/{COMMENT_MAX_LENGTH}
</span>
</label>
<button
type="submit"
disabled={!text.trim() || posting}
className={`cursor-pointer shrink-0 font-medium text-white transition-colors enabled:hover:bg-[#F23DA8] disabled:opacity-45 ${
compact
? "h-10 rounded-[3px] bg-[#F12B9E] px-3.5 text-xs"
: "h-11 rounded-lg bg-[#F12B9E] px-4 text-sm"
}`}
>
{posting ? "发送中" : "发送"}
</button>
</form>
{error ? <p className="text-xs leading-[18px] text-[#FF8FA8]">{error}</p> : null}
</div>
);
}
@@ -0,0 +1,315 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "@heroui/react";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { getCommentList, sendComment, setCommentLike } from "@/lib/api/comment";
import type { CommentInfo } from "@/lib/api/types";
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import {
COMMENT_FETCH_TIMEOUT_MS,
COMMENT_PAGE_SIZE,
filterCommentList,
sortToOrder,
type CommentSort,
type CommentTarget,
} from "./data";
export type UseCommentsOptions = {
target: CommentTarget | null;
initialSort?: CommentSort;
pageSize?: number;
};
export type UseCommentsResult = {
sort: CommentSort;
setSort: (sort: CommentSort) => void;
comments: CommentInfo[];
total: number;
hasMore: boolean;
loading: boolean;
loadingMore: boolean;
posting: boolean;
error: string | null;
postError: string | null;
loadMore: () => void;
toggleLike: (comment: CommentInfo) => Promise<void>;
post: (text: string, options?: { parentsId?: number }) => Promise<boolean>;
reload: () => void;
};
function formatCommentError(err: unknown, fallback = "评论加载失败") {
if (err instanceof DOMException && err.name === "TimeoutError") return "加载超时,请重试";
if (err instanceof Error) {
if (/aborted|timeout|超时/i.test(err.message) || err.name === "AbortError") return "加载超时,请重试";
return err.message || fallback;
}
return fallback;
}
function abortAfter(ms: number) {
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
return AbortSignal.timeout(ms);
}
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
function mergeSignals(a: AbortSignal, b: AbortSignal) {
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.any === "function") {
return AbortSignal.any([a, b]);
}
const controller = new AbortController();
const onAbort = () => controller.abort();
if (a.aborted || b.aborted) {
controller.abort();
return controller.signal;
}
a.addEventListener("abort", onAbort, { once: true });
b.addEventListener("abort", onAbort, { once: true });
return controller.signal;
}
async function resolveAuthToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
function patchComment(list: CommentInfo[], id: number, patch: Partial<CommentInfo>): CommentInfo[] {
return list.map((item) => (Number(item.id) === id ? { ...item, ...patch } : item));
}
export function useComments({
target,
initialSort = "热门",
pageSize = COMMENT_PAGE_SIZE,
}: UseCommentsOptions): UseCommentsResult {
const [sort, setSort] = useState<CommentSort>(initialSort);
const [comments, setComments] = useState<CommentInfo[]>([]);
const [total, setTotal] = useState(0);
const [pageNum, setPageNum] = useState(1);
const [loading, setLoading] = useState(Boolean(target));
const [loadingMore, setLoadingMore] = useState(false);
const [posting, setPosting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [postError, setPostError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const fetchSeq = useRef(0);
const likingIds = useRef(new Set<number>());
const objectId = target?.objectId ?? 0;
const objectType = target?.objectType;
const enabled = Boolean(target && objectId > 0 && objectType != null);
useEffect(() => {
const controller = new AbortController();
let cancelled = false;
void (async () => {
await Promise.resolve();
if (cancelled) return;
if (!enabled || objectType == null) {
setComments([]);
setTotal(0);
setLoading(false);
setError(null);
return;
}
const seq = ++fetchSeq.current;
setLoading(true);
setError(null);
setPageNum(1);
likingIds.current.clear();
try {
const data = await getCommentList(
{
objectId,
objectType,
pageNum: 1,
pageSize,
sortOrder: sortToOrder(sort),
},
{
token: getAuthToken(),
signal: mergeSignals(controller.signal, abortAfter(COMMENT_FETCH_TIMEOUT_MS)),
},
);
if (cancelled || seq !== fetchSeq.current) return;
setComments(filterCommentList(data?.list));
setTotal(Number(data?.totalCount) || 0);
setPageNum(1);
} catch (err: unknown) {
if (cancelled || seq !== fetchSeq.current || controller.signal.aborted) return;
setComments([]);
setTotal(0);
setError(formatCommentError(err));
} finally {
if (!cancelled && seq === fetchSeq.current) setLoading(false);
}
})();
return () => {
cancelled = true;
controller.abort();
};
}, [enabled, objectId, objectType, sort, pageSize, reloadKey]);
const hasMore = comments.length < total;
const loadMore = useCallback(() => {
if (!enabled || objectType == null || loading || loadingMore || !hasMore) return;
const nextPage = pageNum + 1;
const controller = new AbortController();
setLoadingMore(true);
void getCommentList(
{
objectId,
objectType,
pageNum: nextPage,
pageSize,
sortOrder: sortToOrder(sort),
},
{
token: getAuthToken(),
signal: mergeSignals(controller.signal, abortAfter(COMMENT_FETCH_TIMEOUT_MS)),
},
)
.then((data) => {
const mapped = filterCommentList(data?.list);
setComments((current) => {
const seen = new Set(current.map((item) => Number(item.id)));
return [...current, ...mapped.filter((item) => !seen.has(Number(item.id)))];
});
setTotal(Number(data?.totalCount) || total);
setPageNum(nextPage);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setError(formatCommentError(err, "加载更多失败"));
})
.finally(() => setLoadingMore(false));
}, [enabled, objectId, objectType, loading, loadingMore, hasMore, pageNum, pageSize, sort, total]);
const toggleLike = useCallback(async (comment: CommentInfo) => {
const commentId = Number(comment.id);
if (!Number.isInteger(commentId) || commentId < 1) return;
if (likingIds.current.has(commentId)) return;
const token = await resolveAuthToken();
if (!token) {
openLoginDialog();
return;
}
const nextIsLike = !Boolean(comment.isLike);
const nextCountLike = Math.max(0, (Number(comment.countLike) || 0) + (nextIsLike ? 1 : -1));
const snapshot = { isLike: comment.isLike, countLike: comment.countLike };
likingIds.current.add(commentId);
setComments((current) => patchComment(current, commentId, { isLike: nextIsLike, countLike: nextCountLike }));
try {
const result = await setCommentLike(
{ commentId, isLike: nextIsLike },
token,
abortAfter(COMMENT_FETCH_TIMEOUT_MS),
);
setComments((current) =>
patchComment(current, commentId, {
isLike: result?.isLike ?? nextIsLike,
countLike: typeof result?.countLike === "number" ? result.countLike : nextCountLike,
}),
);
} catch (err: unknown) {
setComments((current) => patchComment(current, commentId, snapshot));
handleUnauthorized(err);
} finally {
likingIds.current.delete(commentId);
}
}, []);
const post = useCallback(
async (text: string, options?: { parentsId?: number }) => {
const trimmed = text.trim();
if (!trimmed || posting) return false;
if (!enabled || objectType == null) {
setPostError("无法发布评论");
return false;
}
setPostError(null);
const token = await resolveAuthToken();
if (!token) {
openLoginDialog();
return false;
}
setPosting(true);
try {
await sendComment(
{
objectId,
objectType,
text: trimmed,
parentsId: options?.parentsId ?? 0,
},
token,
abortAfter(COMMENT_FETCH_TIMEOUT_MS),
);
// 审核通过前不写入列表(对齐 1045)
toast.success("发布成功,评论审核后才会展示");
return true;
} catch (err: unknown) {
if (handleUnauthorized(err)) {
setPostError("登录已过期或没有权限,请重新登录");
return false;
}
const message = formatCommentError(err, "发表失败,请稍后重试");
setPostError(message);
toast.danger(message);
return false;
} finally {
setPosting(false);
}
},
[enabled, objectId, objectType, posting],
);
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
return {
sort,
setSort,
comments,
total,
hasMore,
loading,
loadingMore,
posting,
error,
postError,
loadMore,
toggleLike,
post,
reload,
};
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useRef, useState, type ImgHTMLAttributes } from "react";
import { useDecryptedSrc } from "@/hooks/use-decrypted-src";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
type DecryptedImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
src?: string | null;
fallback?: string;
/** 首屏/关键图:立即解密,不懒加载 */
priority?: boolean;
/**
* 视口外不发起解密请求。默认:非 priority 时开启。
* 解密图无法靠原生 loading=lazy 省流量,必须在进视口后再 fetch。
*/
lazy?: boolean;
/** IntersectionObserver rootMargin,提前预取(默认上下 320px) */
lazyRootMargin?: string;
};
export function DecryptedImage({
src,
fallback = IMG_PLACEHOLDER,
priority = false,
lazy,
lazyRootMargin = "320px 0px",
alt = "",
onError,
loading,
decoding,
fetchPriority,
...props
}: DecryptedImageProps) {
const enableLazy = lazy ?? !priority;
const imgRef = useRef<HTMLImageElement>(null);
const [inView, setInView] = useState(!enableLazy);
useEffect(() => {
if (!enableLazy || inView) return;
const node = imgRef.current;
if (!node) return;
if (typeof IntersectionObserver === "undefined") {
setInView(true);
return;
}
const io = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting || entry.intersectionRatio > 0)) {
setInView(true);
io.disconnect();
}
},
{ root: null, rootMargin: lazyRootMargin, threshold: 0 },
);
io.observe(node);
return () => io.disconnect();
}, [enableLazy, inView, lazyRootMargin]);
const resolved = useDecryptedSrc(src, { priority, fallback, enabled: inView });
return (
<img
{...props}
ref={imgRef}
alt={alt}
src={resolved}
loading={loading ?? (priority ? "eager" : "lazy")}
decoding={decoding ?? (priority ? "sync" : "async")}
fetchPriority={fetchPriority ?? (priority ? "high" : "auto")}
onError={(event) => {
if (event.currentTarget.src !== fallback) {
event.currentTarget.src = fallback;
}
onError?.(event);
}}
/>
);
}
+85
View File
@@ -0,0 +1,85 @@
import Link from "next/link";
import { useId, type ReactNode } from "react";
import { ReloadButton } from "./reload-button";
const actionClass =
"flex h-10 w-[108px] items-center justify-center rounded-[22px] text-sm leading-5 font-semibold transition-opacity hover:opacity-90 lg:h-11 lg:w-[120px]";
type EmptyStateProps = {
title?: string;
description?: string;
showHome?: boolean;
/** undefined=默认刷新;null=不显示刷新;函数=自定义 */
onReload?: (() => void) | null;
actions?: ReactNode;
className?: string;
};
export function EmptyState({
title = "这里还没有内容",
description = "暂时没有找到相关短剧,换个分类或返回首页看看吧",
showHome = true,
onReload,
actions,
className = "px-4 pt-14 pb-12 lg:px-[110px] lg:pt-32 lg:pb-24",
}: EmptyStateProps) {
const showReload = onReload !== null;
const hasActions = actions !== undefined || showHome || showReload;
return (
<section
data-component="empty-state"
className={`flex flex-col items-center justify-center gap-4 bg-[radial-gradient(ellipse_50%_50%_at_50%_50%,#2E1233_0%,#07060E_100%)] text-center lg:gap-5 ${className}`}
>
<EmptyIllustration />
<div className="flex flex-col items-center gap-2 lg:gap-2.5">
<h2 className="text-lg leading-[26px] font-semibold text-white lg:text-2xl lg:leading-[30px]">{title}</h2>
{description ? (
<p className="max-w-[300px] text-xs leading-[18px] text-[#948CA8] lg:max-w-none lg:text-sm lg:leading-5">
{description}
</p>
) : null}
</div>
{hasActions ? (
<div className="flex gap-3">
{actions ?? (
<>
{showHome ? (
<Link href="/" className={`${actionClass} bg-[#F12B9E] text-white`}>
返回首页
</Link>
) : null}
{showReload ? <ReloadButton onReload={onReload ?? undefined} /> : null}
</>
)}
</div>
) : null}
</section>
);
}
function EmptyIllustration() {
const glowId = useId();
return (
<svg viewBox="0 0 220 180" className="h-36 w-44 lg:h-[180px] lg:w-[220px]" aria-hidden="true">
<defs>
<radialGradient id={glowId}>
<stop offset="0" stopColor="#F12B9E" stopOpacity="0.28" />
<stop offset="1" stopColor="#F12B9E" stopOpacity="0" />
</radialGradient>
</defs>
<circle cx="110" cy="84" r="80" fill={`url(#${glowId})`} />
<rect x="45" y="63" width="130" height="82" rx="17" fill="#211F38" stroke="#F12B9E" strokeOpacity="0.65" strokeWidth="2" />
<rect x="78" y="84" width="64" height="8" rx="4" fill="#F12B9E" fillOpacity="0.75" />
<rect x="72" y="106" width="76" height="6" rx="3" fill="#6B6385" />
<rect x="87" y="120" width="46" height="6" rx="3" fill="#4D476B" />
<g fill="#F12B9E" fillOpacity="0.8">
<circle cx="32" cy="44" r="4" />
<circle cx="186" cy="58" r="4" />
<circle cx="178" cy="138" r="4" />
</g>
</svg>
);
}
+65
View File
@@ -0,0 +1,65 @@
import Link from "next/link";
export function FilterChipLink({
label,
href,
active,
}: {
label: string;
href: string;
active?: boolean;
}) {
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`shrink-0 whitespace-nowrap rounded-[14px] px-3 py-1.5 text-xs leading-[18px] transition-colors ${
active ? "bg-[#F12B9E] text-white" : "bg-[#26223A] text-[#B7B2C7] hover:text-white"
}`}
>
{label}
</Link>
);
}
export function FilterOptionLink({
label,
href,
active,
}: {
label: string;
href: string;
active?: boolean;
}) {
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`shrink-0 whitespace-nowrap text-xs leading-[18px] ${active ? "text-[#F12B9E]" : "text-[#9994A3]/84 hover:text-white"}`}
>
{label}
</Link>
);
}
export function TabLink({
label,
href,
active,
}: {
label: string;
href: string;
active?: boolean;
}) {
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`rounded-2xl px-4 py-[7px] text-xs leading-[18px] transition-colors ${
active ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
}`}
>
{label}
</Link>
);
}
@@ -0,0 +1 @@
export { FriendLinksSlot, H5FriendLinksSlot } from "./ads/slots";
+75
View File
@@ -0,0 +1,75 @@
import { H5SectionHeading } from "@/components/home/h5-cards";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { adHref, pickFriendAds } from "./ads/pick";
/** PC 友情链接:对接 advitise.friendList,无数据不渲染 */
export function FriendLinks({
items,
advitise,
}: {
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
}) {
const apiList = pickFriendAds(items, advitise);
if (!apiList.length) return null;
// 不用 design-unscaled:嵌套 zoom 会在 Chrome 把热区错位叠到上方分页上
return (
<section data-component="friend-links" id="links" className="border-t border-white/10 pt-1">
<h2 className="text-2xl leading-8 font-semibold text-white/90">友情链接</h2>
<div className="mt-[18px] flex flex-wrap gap-3">
{apiList.map((item, index) => {
const href = adHref(item.link, "#links");
const external = Boolean(item.link?.trim());
return (
<a
key={item.id || `${item.title}-${index}`}
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className="inline-flex min-w-[146px] items-center justify-center rounded-md bg-white/5 px-2 py-[9px] text-base leading-[22px] text-white/55"
>
{item.title}
</a>
);
})}
</div>
</section>
);
}
/** H5 友情链接:对接 advitise.friendList,无数据不渲染 */
export function H5FriendLinks({
items,
advitise,
}: {
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
}) {
const apiList = pickFriendAds(items, advitise);
if (!apiList.length) return null;
return (
<section id="h5-links" className="flex flex-col gap-3">
<H5SectionHeading title="友情链接" more={false} />
<div className="flex flex-wrap gap-[18px]">
{apiList.map((item, index) => {
const href = adHref(item.link, "#h5-links");
const external = Boolean(item.link?.trim());
return (
<a
key={item.id || `${item.title}-${index}`}
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className="rounded-md border border-white/5 bg-white/10 px-3 py-[9px] text-xs leading-[18px] text-white/90"
>
{item.title}
</a>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,6 @@
import { AdIconsSlot } from "./ads/slots";
/** @deprecated 使用 AdIconsSlot */
export async function H5AdGridSlot({ className = "" }: { className?: string }) {
return <AdIconsSlot variant="h5" className={className} />;
}
+16
View File
@@ -0,0 +1,16 @@
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { AdIcons } from "./ads/ad-icons";
/** @deprecated 使用 AdIcons variant="h5";保留别名兼容旧引用 */
export function H5AdGrid({
items,
advitise,
className = "",
}: {
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
className?: string;
}) {
return <AdIcons variant="h5" items={items} advitise={advitise} className={className} />;
}
@@ -0,0 +1,6 @@
import { AdBannersSlot } from "./ads/slots";
/** @deprecated 使用 AdBannersSlot */
export async function H5BannerAdsSlot({ className = "" }: { className?: string }) {
return <AdBannersSlot variant="h5" className={className} />;
}
+16
View File
@@ -0,0 +1,16 @@
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { AdBanners } from "./ads/ad-banners";
/** @deprecated 使用 AdBanners variant="h5";保留别名兼容旧引用 */
export function H5BannerAds({
items,
advitise,
className = "",
}: {
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
className?: string;
}) {
return <AdBanners variant="h5" items={items} advitise={advitise} className={className} />;
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { BackButton } from "@/components/common/back-button";
type H5PageHeaderProps = {
title: string;
/** 无历史记录时的回退路径 */
fallback?: string;
/** 右侧插槽;默认占位与返回同宽,保证标题居中 */
right?: ReactNode;
className?: string;
};
/**
* H5 页顶栏:返回 + 居中标题,滚动时固定在视口顶部(仅 lg 以下)。
*/
export function H5PageHeader({ title, fallback = "/", right, className = "" }: H5PageHeaderProps) {
return (
<>
<header
data-component="h5-page-header"
className={`fixed inset-x-0 top-0 z-20 flex h-12 items-center bg-black px-4 lg:hidden ${className}`}
>
<BackButton fallback={fallback} />
<h1 className="min-w-0 flex-1 truncate text-center text-lg leading-6 font-semibold text-white">{title}</h1>
<div className="flex size-6 shrink-0 items-center justify-center">{right ?? null}</div>
</header>
{/* 占位,避免 fixed 顶栏遮挡内容 */}
<div className="h-12 shrink-0 lg:hidden" aria-hidden="true" />
</>
);
}
+62
View File
@@ -0,0 +1,62 @@
/** 媒体卡片统一 hover:粉光 + 封面微放大 + 标题变粉
* 注意:阴影在根节点,根节点不能 overflow-hidden,否则光晕会被裁切;圆角裁切只放封面容器。
*
* PC:标题 / 描述 / 标签 / 热度 / 集数用 media-card-* 字号 × --page-unscale,
* 抵消 design-frame zoom,窗口缩小时视觉大小不变(见 globals.css)。
*/
export const MEDIA_CARD_ROOT =
"group rounded-lg bg-[#110F1F] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]";
export const MEDIA_CARD_COVER =
"absolute inset-0 h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-105";
export const MEDIA_CARD_TITLE = "transition-colors duration-300 group-hover:text-[#F12B9E]";
/** PC 卡片标题:视觉 24px(抵消 page-scale);shrink-0 避免 flex+truncate 纵向裁切字形 */
export const MEDIA_CARD_TITLE_PC = `media-card-title block min-h-[1.2em] shrink-0 truncate font-medium text-white ${MEDIA_CARD_TITLE}`;
/** PC 卡片标题:视觉 20px(抵消 page-scale) */
export const MEDIA_CARD_TITLE_PC_MD = `media-card-title-md block min-h-[1.2em] shrink-0 truncate font-medium text-white ${MEDIA_CARD_TITLE}`;
/** PC 大卡标题:视觉 32px(抵消 page-scale) */
export const MEDIA_CARD_TITLE_PC_LG = "media-card-title-lg block min-h-[1.2em] shrink-0 truncate font-semibold text-[#F2F2FA]";
/** PC 大卡标题(可换行):视觉 32px */
export const MEDIA_CARD_TITLE_PC_LG_CLAMP = "media-card-title-lg shrink-0 line-clamp-2 font-semibold text-[#F2F2FA]";
/** H5 卡片标题:18px */
export const MEDIA_CARD_TITLE_H5 = `block min-h-[1.2em] shrink-0 truncate text-lg leading-6 font-medium text-white ${MEDIA_CARD_TITLE}`;
/** PC 封面角标「N集」:视觉 14px(抵消 page-scale) */
export const MEDIA_CARD_EPISODES = "media-card-episodes";
/** H5 封面角标「N集」 */
export const MEDIA_CARD_EPISODES_H5 = "text-xs leading-[18px]";
/** PC 描述:视觉 14px(抵消 page-scale) */
export const MEDIA_CARD_DESC = "media-card-desc block min-h-[1.2em] shrink-0 truncate";
/** PC 描述:视觉 16px(抵消 page-scale) */
export const MEDIA_CARD_DESC_MD = "media-card-desc-md shrink-0";
/** H5 描述:12px */
export const MEDIA_CARD_DESC_SM = "media-card-desc-sm block min-h-[1.2em] shrink-0 truncate";
/** PC 标签 / 热度:视觉 14px(抵消 page-scale) */
export const MEDIA_CARD_META = "media-card-meta shrink-0";
/** PC 标签 / 热度:视觉 16px(抵消 page-scale) */
export const MEDIA_CARD_META_MD = "media-card-desc-md";
/** H5 标签 / 热度:12px */
export const MEDIA_CARD_META_H5 = "media-card-meta-sm";
/** H5 标题+描述区 */
export const MEDIA_CARD_INFO_H5 = "flex min-w-0 flex-col gap-0.5 px-2 pt-1.5";
/** H5 标签热度行:紧凑、可截断标签 */
export const MEDIA_CARD_FOOTER_H5 = `flex min-w-0 items-center justify-between gap-1.5 px-2 pt-0.5 pb-1.5 ${MEDIA_CARD_META_H5}`;
/** @deprecated 保留空串兼容旧引用 */
export const MEDIA_CARD_CONTAINER = "";
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { Spinner } from "@heroui/react";
type PageLoadingProps = {
label?: string;
className?: string;
};
export function PageLoading({ label = "加载中…", className = "" }: PageLoadingProps) {
return (
<div data-component="page-loading"
role="status"
aria-live="polite"
className={`flex min-h-[240px] flex-col items-center justify-center gap-3 text-sm leading-5 text-white/55 ${className}`}
>
<Spinner size="lg" color="accent" className="text-[#F12B9E]" />
<span>{label}</span>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { navigatePageHref } from "./pagination-map";
type PaginationJumpProps = {
total: number;
onJump?: (page: number) => void;
/** 可序列化页码 → 地址,供 RSC 父级使用 */
hrefMap?: Record<number, string>;
};
export function PaginationJump({ total, onJump, hrefMap }: PaginationJumpProps) {
const [jump, setJump] = useState("");
const submitJump = () => {
const target = Number(jump);
if (!Number.isInteger(target) || target < 1 || target > total) return;
const href = hrefMap?.[target];
if (href) navigatePageHref(href);
else onJump?.(target);
setJump("");
};
return (
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
submitJump();
}}
>
<span className="text-sm leading-5 text-white/55">前往</span>
<input
type="number"
min={1}
max={total}
value={jump}
onChange={(event) => setJump(event.target.value)}
aria-label="页码"
className="h-8 w-16 rounded-2xl bg-[#211F38] text-center text-xs font-semibold text-[#948CA8]/90 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<span className="text-sm leading-5 text-white/55">页</span>
<button type="submit" className="cursor-pointer h-8 w-20 rounded-2xl bg-[#F12B9E] text-xs font-semibold text-white">
确定
</button>
</form>
);
}
+15
View File
@@ -0,0 +1,15 @@
/** 供列表页生成可序列化页码 → href(可在 RSC 中调用) */
export function buildPageHrefMap(total: number, hrefForPage: (page: number) => string): Record<number, string> {
const map: Record<number, string> = {};
for (let i = 1; i <= total; i++) map[i] = hrefForPage(i);
return map;
}
/**
* 分页硬跳转:避开 design-frame CSS zoom 下 Next 软导航静默失败。
* 仅在客户端调用。
*/
export function navigatePageHref(href: string) {
if (typeof window === "undefined") return;
window.location.assign(href);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import type { MouseEvent, ReactNode } from "react";
import { navigatePageHref } from "./pagination-map";
import { PaginationJump } from "./pagination-jump";
type PaginationBase = {
page: number;
total: number;
className?: string;
showJump?: boolean;
};
type PaginationCallbackProps = PaginationBase & {
onChange: (page: number) => void;
hrefMap?: never;
};
type PaginationLinkProps = PaginationBase & {
/** 页码 → href,须为可序列化对象(RSC 不可传函数给 Client) */
hrefMap: Record<number, string>;
onChange?: never;
};
export type PaginationProps = PaginationCallbackProps | PaginationLinkProps;
const itemClass = "flex h-8 min-w-9 items-center justify-center rounded-2xl px-2 text-xs leading-[18px]";
function pageItems(page: number, total: number): (number | "gap")[] {
if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1);
if (page <= 3) return [1, 2, 3, "gap", total];
if (page >= total - 2) return [1, "gap", total - 2, total - 1, total];
return [1, "gap", page - 1, page, page + 1, "gap", total];
}
function Chevron({ direction }: { direction: "left" | "right" }) {
return (
<svg viewBox="0 0 14 14" fill="none" className={`size-3.5 ${direction === "left" ? "rotate-180" : ""}`} aria-hidden="true">
<path d="M5.5 4l3 3-3 3" stroke="#8F8F9F" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function PageControl({
disabled,
href,
onClick,
ariaLabel,
children,
}: {
disabled: boolean;
href?: string;
onClick?: () => void;
ariaLabel: string;
children: ReactNode;
}) {
const className = `${itemClass} bg-[#211F38] ${disabled ? "pointer-events-none opacity-40" : "cursor-pointer"}`;
if (href && !disabled) {
return (
<a
href={href}
aria-label={ariaLabel}
className={className}
onClick={(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
navigatePageHref(href);
}}
>
{children}
</a>
);
}
return (
<button type="button" aria-label={ariaLabel} disabled={disabled} onClick={onClick} className={className}>
{children}
</button>
);
}
function PageNumber({
item,
current,
href,
onClick,
}: {
item: number;
current: number;
href?: string;
onClick?: () => void;
}) {
const active = item === current;
const className = `${itemClass} ${active ? "bg-[#F12B9E] font-semibold text-white" : "bg-[#211F38] text-[#948CA8] hover:text-white"} ${href || onClick ? "cursor-pointer" : ""}`;
if (href && !active) {
return (
<a
href={href}
className={className}
onClick={(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
navigatePageHref(href);
}}
>
{item}
</a>
);
}
if (href && active) {
return (
<span aria-current="page" className={className}>
{item}
</span>
);
}
return (
<button type="button" aria-current={active ? "page" : undefined} onClick={onClick} className={className}>
{item}
</button>
);
}
export function Pagination({ page, total, className = "", showJump = true, ...rest }: PaginationProps) {
const hrefMap = "hrefMap" in rest ? rest.hrefMap : undefined;
const onChange = "onChange" in rest ? rest.onChange : undefined;
return (
<nav
data-component="pagination"
className={`relative z-20 flex flex-wrap items-center justify-center gap-2 ${className}`}
aria-label="分页"
>
<PageControl
disabled={page === 1}
href={hrefMap && page > 1 ? hrefMap[page - 1] : undefined}
onClick={onChange ? () => onChange(page - 1) : undefined}
ariaLabel="上一页"
>
<Chevron direction="left" />
</PageControl>
{pageItems(page, total).map((item, index) =>
item === "gap" ? (
<span key={`gap-${index}`} className="text-xs text-[#948CA8]">
…
</span>
) : (
<PageNumber
key={item}
item={item}
current={page}
href={hrefMap?.[item]}
onClick={onChange ? () => onChange(item) : undefined}
/>
),
)}
<PageControl
disabled={page === total}
href={hrefMap && page < total ? hrefMap[page + 1] : undefined}
onClick={onChange ? () => onChange(page + 1) : undefined}
ariaLabel="下一页"
>
<Chevron direction="right" />
</PageControl>
{showJump ? <PaginationJump total={total} onJump={onChange} hrefMap={hrefMap} /> : null}
</nav>
);
}
+185
View File
@@ -0,0 +1,185 @@
export const CARD_TAG_MAX = 3;
export type CardTagItem = {
/** 标签 ID;无 slug 时用于跳转 /tag/{kind}/{id} */
id?: number;
/** 标签 slug;有则优先跳转 /tag/{kind}/{slug} */
slug?: string;
name: string;
};
/**
* 按 index 对齐 ids / names / slugs;缺则空着,不自行补展示文案。
* 短剧:pickPlayletCardTags(item) → tagIds + tagNames + tagSlugs
* 视频:pickVideoCardTags(item) → tags + tagsName + tagSlugs(字段名与短剧不同)
*/
export function pickCardTags(
ids?: Array<number | string | null | undefined> | null,
names?: Array<string | null | undefined> | null,
extras?: Array<string[] | undefined | null>,
slugs?: Array<string | null | undefined> | null,
): CardTagItem[] {
const seen = new Set<string>();
const out: CardTagItem[] = [];
const idList = ids ?? [];
const nameList = names ?? [];
const slugList = slugs ?? [];
const len = Math.max(idList.length, nameList.length, slugList.length);
for (let i = 0; i < len; i++) {
const id = coerceTagId(idList[i]);
const slug = String(slugList[i] ?? "").trim() || undefined;
// 展示文案只用名称(或 slug);禁止把数字 id 当成标签名
const name = String(nameList[i] ?? "").trim() || slug || "";
if (!name) continue;
const key = slug ? `slug:${slug}` : id != null ? `id:${id}` : `name:${name}`;
if (seen.has(key) || seen.has(`name:${name}`)) continue;
seen.add(key);
seen.add(`name:${name}`);
out.push({ id, slug, name });
if (out.length >= CARD_TAG_MAX) return out;
}
for (const list of extras ?? []) {
for (const raw of list ?? []) {
const name = String(raw || "").trim();
if (!name || seen.has(`name:${name}`)) continue;
seen.add(`name:${name}`);
out.push({ name });
if (out.length >= CARD_TAG_MAX) return out;
}
}
return out;
}
/**
* 短剧列表/卡片标签:
* 仅用 tagIds + tagNames + tagSlugs(按 index 对齐);跳转优先 slug,无 slug 用 id。
* 缺字段则空着,不用 themeNames / anchor 等补位。
*/
export function pickPlayletCardTags(item: {
tagIds?: Array<number | string | null | undefined> | null;
tagNames?: Array<string | null | undefined> | null;
tagSlugs?: Array<string | null | undefined> | null;
}): CardTagItem[] {
return pickCardTags(item.tagIds, flattenTagNames(item.tagNames), undefined, flattenTagNames(item.tagSlugs));
}
function coerceTagId(raw: unknown): number | undefined {
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) return Math.floor(raw);
if (typeof raw === "string" && /^\d+$/.test(raw.trim())) {
const id = Number(raw.trim());
return id > 0 ? id : undefined;
}
return undefined;
}
function flattenTagNames(
list?: Array<string | null | undefined | Array<string | null | undefined>> | null,
): string[] {
if (!list?.length) return [];
const out: string[] = [];
for (const item of list) {
if (Array.isArray(item)) {
for (const nested of item) {
const name = String(nested ?? "").trim();
if (name) out.push(name);
}
continue;
}
const name = String(item ?? "").trim();
if (name) out.push(name);
}
return out;
}
function isVideoTagObject(
tag: unknown,
): tag is { id?: number; name?: string; tagName?: string; slug?: string } {
return tag != null && typeof tag === "object" && !Array.isArray(tag);
}
/**
* 视频列表/卡片标签(与短剧字段名不同,见 swagger video/list):
* - tags → 标签 id(短剧为 tagIds)
* - tagsName → 标签名称(短剧为 tagNames)
* - tagSlugs → 标签 slug(与短剧同名);跳转优先 slug,无 slug 用 id
* 详情态 tags 可能为对象数组(含 name / slug)。
*/
export function pickVideoCardTags(item: {
tags?: Array<
| number
| string
| { id?: number; name?: string; tagName?: string; slug?: string }
| Array<number | string | null | undefined>
| null
| undefined
> | null;
/** swagger:标签名称 tagsName */
tagsName?: Array<string | null | undefined | Array<string | null | undefined>> | null;
/** swagger:标签 slug tagSlugs */
tagSlugs?: Array<string | null | undefined | Array<string | null | undefined>> | null;
}): CardTagItem[] {
const rawTags = item.tags ?? [];
// 详情态:tags 为对象数组
if (rawTags.some(isVideoTagObject)) {
return normalizeCardTags(
rawTags.filter(isVideoTagObject).map((tag) => ({
id: tag.id,
slug: tag.slug,
name: String(tag.name || tag.tagName || "").trim(),
})),
);
}
const ids: number[] = [];
for (const tag of rawTags) {
if (Array.isArray(tag)) {
for (const nested of tag) {
const id = coerceTagId(nested);
if (id != null) ids.push(id);
}
continue;
}
const id = coerceTagId(tag);
if (id != null) ids.push(id);
}
return pickCardTags(ids, flattenTagNames(item.tagsName), undefined, flattenTagNames(item.tagSlugs));
}
export function normalizeCardTags(
tags?: Array<CardTagItem | string> | string | null,
): CardTagItem[] {
const list = Array.isArray(tags) ? tags : tags ? [tags] : [];
const out: CardTagItem[] = [];
const seen = new Set<string>();
for (const item of list) {
if (typeof item === "string") {
const name = item.trim();
if (!name || seen.has(`name:${name}`)) continue;
seen.add(`name:${name}`);
out.push({ name });
} else {
const name = String(item?.name || "").trim();
const slug = String(item?.slug || "").trim() || undefined;
const id =
typeof item?.id === "number" && Number.isFinite(item.id) && item.id > 0 ? item.id : undefined;
const label = name || slug || "";
if (!label) continue;
const key = slug ? `slug:${slug}` : id != null ? `id:${id}` : `name:${label}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ id, slug, name: label });
}
if (out.length >= CARD_TAG_MAX) break;
}
return out;
}
/** 可跳转:有 slug 或 id */
export function cardTagCanLink(tag: CardTagItem) {
return Boolean(tag.slug?.trim() || tag.id != null);
}
+38
View File
@@ -0,0 +1,38 @@
import { rankNumberFont } from "@/lib/fonts";
export function RankNumber({ rank, className = "text-[28px] leading-7" }: { rank?: number; className?: string }) {
if (rank === undefined) return null;
return (
<span
data-component="rank-number"
className={`${rankNumberFont.className} shrink-0 font-extrabold text-[#F12B9E] italic tabular-nums ${className}`}
>
{String(rank).padStart(2, "0")}
</span>
);
}
const rankBadgeBgs = [
"/images/rank_num1.png",
"/images/rank_num2.png",
"/images/rank_num3.png",
] as const;
const rankBadgeFallback = "/images/rank_num4.png";
export function CoverRankBadge({ rank, className = "" }: { rank?: number; className?: string }) {
if (rank === undefined) return null;
const bg = rankBadgeBgs[rank - 1] ?? rankBadgeFallback;
const textClass = rank <= 1 ? "text-xl leading-7 font-medium" : "text-lg leading-6 font-semibold";
return (
<span
data-component="cover-rank-badge"
className={`absolute bottom-0 left-0 flex h-[42px] w-[54px] items-center justify-center bg-left-bottom bg-no-repeat text-white tabular-nums ${textClass} ${className}`}
style={{ backgroundImage: `url(${bg})`, backgroundSize: "100% 100%" }}
>
{rank}
</span>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
const actionClass =
"flex h-10 w-[108px] items-center justify-center rounded-[22px] text-sm leading-5 font-semibold transition-opacity hover:opacity-90 lg:h-11 lg:w-[120px]";
export function ReloadButton({ onReload, className = "" }: { onReload?: () => void; className?: string }) {
return (
<button
type="button"
onClick={onReload ?? (() => window.location.reload())}
className={`cursor-pointer ${actionClass} bg-[#211F38] text-[#D1CCE0] outline outline-1 -outline-offset-1 outline-[#4D476B] ${className}`}
>
重新加载
</button>
);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { Toast } from "@heroui/react";
/** 全局 Toast 宿主:深色主题 + 顶部居中(适配站点视觉) */
export function ToastHost() {
return <Toast.Provider placement="top" width={360} maxVisibleToasts={3} />;
}
@@ -0,0 +1,17 @@
import { connection } from "next/server";
import { getHomeBaseCached } from "@/lib/api/home";
import { HomeBaseDebug } from "./home-base-debug";
/** 调试插槽:拉取 home/base 并在浏览器控制台打印 */
export async function HomeBaseDebugSlot() {
await connection();
let data: Awaited<ReturnType<typeof getHomeBaseCached>> = {};
try {
data = await getHomeBaseCached();
} catch {
data = {};
}
return <HomeBaseDebug data={data} />;
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { useEffect } from "react";
import type { HomeBaseResp } from "@/lib/api/types";
/** 浏览器控制台打印 /api/app/home/base 返回数据(调试用) */
export function HomeBaseDebug({ data }: { data: HomeBaseResp }) {
useEffect(() => {
console.info("[home/base]", data);
}, [data]);
return null;
}
@@ -0,0 +1,17 @@
import { connection } from "next/server";
import { getHomeBaseCached } from "@/lib/api/home";
import { DownloadDialogHost } from "./download-dialog";
/** 服务端插槽:从 /api/app/home/base 拉取 config.appAddr(Suspense + connection 排除 prerender) */
export async function DownloadDialogSlot() {
await connection();
let appAddr: string | undefined;
try {
appAddr = (await getHomeBaseCached()).config?.appAddr;
} catch {
appAddr = undefined;
}
return <DownloadDialogHost appAddr={appAddr} />;
}
+280
View File
@@ -0,0 +1,280 @@
"use client";
import { QRCodeSVG } from "qrcode.react";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type MouseEvent } from "react";
type Platform = "iOS" | "Android";
/** 全站下载地址:仅使用 /api/app/home/base → config.appAddr */
let appDownloadAddr = "";
const addrListeners = new Set<() => void>();
function notifyAddr() {
addrListeners.forEach((listener) => listener());
}
export function setAppDownloadAddr(appAddr?: string | null) {
const next = appAddr?.trim() || "";
if (next === appDownloadAddr) return;
appDownloadAddr = next;
notifyAddr();
}
/** 当前下载链接(config.appAddr),无配置时为空字符串 */
export function getAppDownloadHref() {
return appDownloadAddr;
}
/** 兼容旧调用:iOS / Android 均指向同一 appAddr */
export const downloadLinks = {
get iOS() {
return appDownloadAddr;
},
get Android() {
return appDownloadAddr;
},
};
const features = [
{ title: "追剧更稳定", text: "App 内播放、切集和续看更稳定,适合长时间追剧。" },
{ title: "记录自动同步", text: "观看历史、收藏和追剧记录与 Web 账号同步。" },
{ title: "更新及时提醒", text: "关注的剧集更新后提醒,减少回访流失。" },
];
let dialogOpen = false;
const listeners = new Set<() => void>();
function setDialogOpen(open: boolean) {
dialogOpen = open;
listeners.forEach((listener) => listener());
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
const getSnapshot = () => dialogOpen;
const getServerSnapshot = () => false;
function detectPlatform(): Platform {
return /iPhone|iPad|iPod|Macintosh/i.test(navigator.userAgent) ? "iOS" : "Android";
}
function copyWithTextarea(text: string) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand("copy");
textarea.remove();
return copied;
}
function useCopy() {
const [copied, setCopied] = useState(false);
const timer = useRef<number>(undefined);
useEffect(() => () => window.clearTimeout(timer.current), []);
const copy = useCallback(async (text: string) => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
} catch {
if (!copyWithTextarea(text)) return;
}
setCopied(true);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), 1500);
}, []);
return { copied, copy };
}
/** 订阅 appAddr,保证二维码 / 按钮随接口更新重渲染(SSR / hydrate 快照为空,避免 hydration mismatch) */
export function useAppDownloadHref() {
return useSyncExternalStore(
(listener) => {
addrListeners.add(listener);
return () => addrListeners.delete(listener);
},
getAppDownloadHref,
() => "",
);
}
/** 打开下载落地页:仅当有 config.appAddr */
export function openAppDownload() {
const href = getAppDownloadHref();
if (!href) return;
window.open(href, "_blank", "noopener,noreferrer");
}
/** 下载入口:直接打开 config.appAddr 落地页,不再弹窗 */
export function openDownloadDialog(event?: MouseEvent) {
event?.preventDefault();
openAppDownload();
}
/** 下载弹窗:使用 home/base → config.appAddr */
export function DownloadDialogHost({ appAddr }: { appAddr?: string | null }) {
const open = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const close = useCallback(() => setDialogOpen(false), []);
useEffect(() => {
setAppDownloadAddr(appAddr);
}, [appAddr]);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => event.key === "Escape" && close();
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = overflow;
window.removeEventListener("keydown", onKeyDown);
};
}, [open, close]);
if (!open) return null;
return (
<div
data-component="download-dialog-host"
className="fixed inset-0 z-[60] flex flex-col items-center overflow-y-auto bg-[#04030B]/82 px-4 py-10"
onClick={(event) => event.target === event.currentTarget && close()}
>
<DownloadPanel onClose={close} />
</div>
);
}
function DownloadPanel({ onClose }: { onClose: () => void }) {
const [platform, setPlatform] = useState<Platform>(detectPlatform);
const { copied, copy } = useCopy();
const link = useAppDownloadHref();
return (
<>
<div className="my-auto flex flex-col items-center gap-[11px] lg:hidden">
<section
role="dialog"
aria-modal="true"
aria-label="下载海角社区 App"
className="flex w-80 flex-col items-center gap-[18px] rounded-[18px] bg-[#121118] p-6 text-center shadow-[0_14px_32px_rgba(0,0,0,0.45)]"
>
<img src="/logo.svg" alt="海角社区" className="h-[65px] w-auto" />
<p className="text-sm leading-5 text-[#BEB9CB]">扫码下载「海角社区 App」</p>
<p className="text-xs leading-[18px] text-[#8E889C]">海量短剧,随时畅看</p>
{link ? <QrBox link={link} className="size-[180px] rounded-[10px] p-2" /> : null}
<button
type="button"
onClick={() => copy(link)}
disabled={!link}
className="cursor-pointer text-xs leading-[18px] break-all text-[#F12B9E] disabled:opacity-50"
>
{copied ? "下载链接已复制" : link || "暂无下载地址"}
</button>
<p className="bg-[linear-gradient(54deg,#FF5693_0%,#B421D5_58%,#76DFF2_100%)] bg-clip-text text-lg leading-[25px] font-bold tracking-[0.2em] text-transparent">
海角社区
</p>
</section>
<CloseCircle onClick={onClose} />
</div>
<section
role="dialog"
aria-modal="true"
aria-labelledby="download-dialog-title"
className="relative my-auto hidden h-[500px] w-[760px] flex-col overflow-hidden rounded-3xl bg-[linear-gradient(90deg,#2B1233_0%,#101F41_100%)] shadow-[0_24px_50px_rgba(0,0,0,0.55)] outline outline-1 -outline-offset-1 outline-white/14 lg:flex"
>
<header className="flex h-[92px] shrink-0 items-start gap-[17px] border-b border-white/12 px-7 pt-[22px]">
<img src="/logo.svg" alt="海角社区" className="h-[58px] w-32" />
<div className="flex flex-col gap-2">
<h2 id="download-dialog-title" className="text-2xl leading-[30px] font-semibold text-white">
下载海角社区 App
</h2>
<p className="text-sm leading-5 text-[#A49DB4]">扫码安装,继续追剧不丢进度</p>
</div>
<button
type="button"
aria-label="关闭"
onClick={onClose}
className="absolute top-6 right-6 flex size-9 cursor-pointer items-center justify-center rounded-full bg-white/8 text-xl leading-[26px] font-semibold text-[#BCB7C8] outline outline-1 -outline-offset-1 outline-white/18 transition-colors hover:bg-white/14"
>
×
</button>
</header>
<div className="flex gap-6 px-7 pt-6">
<ul className="flex w-[440px] flex-col gap-3.5">
{features.map((feature) => (
<li
key={feature.title}
className="flex h-[102px] flex-col gap-[11px] rounded-2xl bg-white/5 px-[18px] pt-[15px] outline outline-1 -outline-offset-1 outline-white/12"
>
<h3 className="text-lg leading-6 font-semibold text-white">{feature.title}</h3>
<p className="text-sm leading-5 text-[#A6A0B5]">{feature.text}</p>
</li>
))}
</ul>
<div className="flex h-[334px] w-[236px] flex-col items-center rounded-[18px] bg-white/6 pt-4 outline outline-1 -outline-offset-1 outline-white/14">
<div className="flex gap-2 self-stretch px-[18px]" role="tablist" aria-label="选择系统">
{(["iOS", "Android"] as Platform[]).map((item) => (
<button
key={item}
type="button"
role="tab"
aria-selected={platform === item}
onClick={() => setPlatform(item)}
className={`h-[30px] cursor-pointer rounded-[15px] text-xs leading-[18px] font-semibold text-white transition-colors ${
item === "iOS" ? "w-14" : "w-[76px]"
} ${
platform === item ? "bg-[#F12B9E]" : "bg-white/7 outline outline-1 -outline-offset-1 outline-white/16 hover:bg-white/12"
}`}
>
{item}
</button>
))}
</div>
{link ? <QrBox link={link} className="mt-3 size-[184px] rounded-[14px] p-2.5" /> : null}
<button
type="button"
onClick={() => copy(link)}
disabled={!link}
className="mt-3 h-[42px] w-[204px] cursor-pointer rounded-[21px] bg-[#F12B9E] text-sm leading-5 font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>
{copied ? "已复制" : link ? "复制下载链接" : "暂无下载地址"}
</button>
<p className="mt-2.5 text-xs leading-[18px] text-[#918A9E]">二维码与下载链接指向同一地址</p>
</div>
</div>
</section>
</>
);
}
function QrBox({ link, className }: { link: string; className: string }) {
return (
<div data-component="download-dialog" className={`shrink-0 bg-white ${className}`}>
<QRCodeSVG value={link} size={164} level="M" className="size-full" aria-hidden />
</div>
);
}
function CloseCircle({ onClick }: { onClick: () => void }) {
return (
<button type="button" aria-label="关闭" onClick={onClick} className="size-11 cursor-pointer">
<svg viewBox="0 0 44 44" className="size-full" aria-hidden="true">
<circle cx="22" cy="22" r="21" fill="none" stroke="white" strokeWidth="2" />
<path d="M16 16l12 12M28 16L16 28" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
);
}
@@ -0,0 +1,32 @@
"use client";
import type { ReactNode } from "react";
import { openAppDownload, useAppDownloadHref } from "@/components/download/download-dialog";
export function DownloadTrigger({
className,
children,
ariaLabel,
}: {
className?: string;
children: ReactNode;
ariaLabel?: string;
}) {
const href = useAppDownloadHref();
const classes = `cursor-pointer ${className ?? ""}`;
if (href) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" aria-label={ariaLabel} className={classes}>
{children}
</a>
);
}
return (
<button type="button" aria-label={ariaLabel} onClick={() => openAppDownload()} className={classes}>
{children}
</button>
);
}
+253
View File
@@ -0,0 +1,253 @@
import type {
PlayletChapterInfo,
PlayletData,
PlayletDetailInfo,
PlayletGuessListResp,
PlayletRecommendListResp,
QueryPlayletChapterInfoResp,
QueryPlayletInfoResp,
} from "@/lib/api/types";
import { resolvePlayURL } from "@/lib/media/playback";
export type EpisodeState = "free" | "locked";
/** 详情页数据:直接使用接口 playletInfo + chapterList 字段名 */
export type DramaDetail = PlayletDetailInfo & {
chapterList?: PlayletChapterInfo[];
};
/** 猜你喜欢:直接使用接口短剧字段 */
export type RelatedDrama = PlayletData;
export type EpisodeRange = { label: string; start: number; end: number };
/** 本地 mock 不再使用 picsum;无封面时留空 */
const cover = (..._args: [string, number, number]) => {
void _args;
return "";
};
const introText = "第二季两极反转,熟悉的人物重新回归,人物关系与隐藏秘密再次升级。";
export function getDramaDetail(id: string): DramaDetail {
const numericId = Number(id) || 1;
return {
id: numericId,
title: "禁忌的喘息第二季",
cover: cover(`haijiao-detail-${id}`, 720, 960),
themeNames: ["都市剧情", "情感纠葛", "悬疑反转"],
anchor: "王朝阳",
totalEpisode: 68,
updateStatus: 2,
countBrowse: 3333000,
desc: introText.repeat(10),
chapterList: Array.from({ length: 3 }, (_, index) => ({
id: index + 1,
currentEpisode: index + 1,
payType: 0,
isOfficialOpen: true,
title: String(index + 1),
})),
};
}
export function totalEpisodeOf(detail: DramaDetail) {
const fromInfo = Number(detail.totalEpisode) || 0;
const fromChapters = Math.max(0, ...(detail.chapterList ?? []).map((item) => Number(item.currentEpisode) || 0));
return Math.max(fromInfo, fromChapters, 1);
}
export function updateStatusLabel(status?: number) {
if (status === 2) return "已完结";
if (status === 1) return "连载中";
return "更新中";
}
export function yearFromOnlineAt(onlineAt?: string) {
if (!onlineAt) return "年份未知";
const year = new Date(onlineAt).getFullYear();
return Number.isFinite(year) ? String(year) : "年份未知";
}
export function formatPlays(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}次播放`;
}
/** 是否可播:优先 chapter.isOfficialOpen;无章节信息时按开放集数回退 */
export function getEpisodeState(episode: number, detail: DramaDetail): EpisodeState {
const chapter = detail.chapterList?.find((item) => Number(item.currentEpisode) === episode);
if (chapter) return chapter.isOfficialOpen ? "free" : "locked";
const openCount = (detail.chapterList ?? []).filter((item) => item.isOfficialOpen).length;
return episode <= openCount ? "free" : "locked";
}
/** 猜你喜欢侧栏展示条数(详情 / 播放) */
export const RELATED_DISPLAY_COUNT = 8;
/** 更多好剧推荐条数(三列网格 3×3) */
export const RECOMMEND_DISPLAY_COUNT = 9;
/** 猜你喜欢:过滤广告后保留接口原字段 */
export function mapPlayletGuessList(data: PlayletGuessListResp | null | undefined): RelatedDrama[] {
return (data?.list ?? [])
.filter((item): item is RelatedDrama => item.type !== 2 && Boolean(item.id))
.slice(0, RELATED_DISPLAY_COUNT);
}
/** 推荐短剧:过滤广告后保留接口原字段;excludeId 排除当前短剧 */
export function mapPlayletRecommendList(
data: PlayletRecommendListResp | null | undefined,
options?: { excludeId?: number | string | null; limit?: number },
): RelatedDrama[] {
const excludeId = options?.excludeId != null ? Number(options.excludeId) : NaN;
const limit = options?.limit ?? RECOMMEND_DISPLAY_COUNT;
return (data?.list ?? [])
.filter(
(item): item is RelatedDrama =>
item.type !== 2 &&
Boolean(item.id) &&
!(Number.isFinite(excludeId) && Number(item.id) === excludeId),
)
.slice(0, limit);
}
/** 相对媒资路径拼接 CDN(优先 mediaAppUsCdn);图片等非播放场景用 */
export function resolveMediaUrl(path?: string | null, cdn?: string | null) {
const raw = (path || "").trim();
if (!raw) return "";
if (/^https?:\/\//i.test(raw)) return raw;
if (raw.startsWith("//")) return `https:${raw}`;
const base = (cdn || "").trim().replace(/\/$/, "");
if (!base) return raw;
return `${base}/${raw.replace(/^\//, "")}`;
}
export { resolvePlayURL } from "@/lib/media/playback";
export type DramaPlayView = {
detail: DramaDetail;
episode: number;
/** 当前剧集标题(来自 chapter/info → chapterInfo.title) */
chapterTitle?: string;
src: string;
related: RelatedDrama[];
};
export function mapPlayletChapterPlay(
data: QueryPlayletChapterInfoResp | null | undefined,
fallback: DramaDetail,
episode: number,
): DramaPlayView | null {
if (!data?.chapterInfo) return null;
const detailFromApi = data.playletInfo
? mapPlayletInfo({
playletInfo: data.playletInfo as PlayletDetailInfo,
chapterList: data.chapterList,
isLike: data.isLike,
isFavorite: data.isFavorite,
})
: null;
const detail: DramaDetail = detailFromApi
? {
...detailFromApi,
chapterList: data.chapterList?.length ? data.chapterList : detailFromApi.chapterList,
isLike: data.isLike ?? detailFromApi.isLike,
isFavorite: data.isFavorite ?? detailFromApi.isFavorite,
}
: {
...fallback,
chapterList: data.chapterList?.length ? data.chapterList : fallback.chapterList,
isLike: data.isLike ?? fallback.isLike,
isFavorite: data.isFavorite ?? fallback.isFavorite,
};
const currentEpisode = Number(data.chapterInfo.currentEpisode) || episode;
// 播放地址走同域 /h5/m3u8/(加密流 + /enkey),不要拼 CDN 直链
const src = resolvePlayURL(data.chapterInfo.videoUrl);
const related = (data.hotPlayletList ?? [])
.filter((item): item is RelatedDrama => Boolean(item.id))
.slice(0, RELATED_DISPLAY_COUNT);
return {
detail,
episode: currentEpisode,
chapterTitle: data.chapterInfo.title?.trim() || undefined,
src,
related,
};
}
/** 透传接口字段,仅补齐 chapterList / 根级 isLike、isFavorite */
export function mapPlayletInfo(data: QueryPlayletInfoResp | null | undefined): DramaDetail | null {
const info = data?.playletInfo;
if (!info?.id) return null;
return {
...info,
isLike: Boolean(data?.isLike ?? info.isLike),
isFavorite: Boolean(data?.isFavorite ?? info.isFavorite),
chapterList: data?.chapterList ?? [],
};
}
export function getEpisodeRanges(total: number, size: number): EpisodeRange[] {
const count = Math.max(1, Math.floor(total / size));
return Array.from({ length: count }, (_, index) => {
const start = index * size + 1;
const end = index === count - 1 ? total : start + size - 1;
return { label: `${start}–${end}`, start, end };
});
}
export function getEpisodeChunks(total: number, size: number): EpisodeRange[] {
return Array.from({ length: Math.ceil(total / size) }, (_, index) => {
const start = index * size + 1;
const end = Math.min(total, start + size - 1);
return { label: `${start}–${end}`, start, end };
});
}
export function playHref(id: string | number, episode: number, slug?: string | null) {
const key = (slug?.trim() || String(id)).trim();
return `/short-drama/${key}/${episode}`;
}
export function dramaDetailHref(detail: Pick<DramaDetail, "id" | "slug">) {
return `/short-drama/${(detail.slug?.trim() || String(detail.id)).trim()}`;
}
export function parseEpisode(value: string, detail: DramaDetail) {
const episode = Number(value);
return Number.isInteger(episode) && episode >= 1 && episode <= totalEpisodeOf(detail) ? episode : null;
}
export const sampleVideo = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8";
const related: [string, string, string][] = [
["十载梦碎,寂悔无用", "权谋交锋,爱意藏在局中", "精选"],
["风禾尽起", "逆风翻盘,人生重新开场", "精选"],
["陆沉渊,你老婆是神仙", "从校服到婚纱的双向奔赴", "甜宠"],
["重生后:我带儿女华丽逆转", "一剑问长生,一念渡相思", "仙侠"],
["八零飒妻脸红红硬汉老公追着宠", "迷雾深处,每个人都有秘密", "悬疑"],
["七零开挂:甩掉极品全家桶", "破镜重圆,再赴炽热爱意", "都市"],
["小祖宗千金归来,我是他的掌中娇", "权谋交锋,爱意藏在局中", "精选"],
["代号:杀神", "逆风翻盘,人生重新开场", "精选"],
];
export const relatedDramas: RelatedDrama[] = related.map(([title, desc, tag], index) => ({
id: index + 1,
title,
desc,
tagNames: [tag],
heatValue: 258000 - index * 12000,
totalEpisode: 60,
cover: cover(`haijiao-related-${index + 1}`, 216, 288),
}));
@@ -0,0 +1,418 @@
"use client";
import { toast } from "@heroui/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { BackButton } from "@/components/common/back-button";
import { cardTagCanLink, pickPlayletCardTags } from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { openDownloadDialog } from "@/components/download/download-dialog";
import { FloatingActions } from "@/components/home/floating-actions";
import { tagPageHref } from "@/components/tag/href";
import { favoritePlaylet, getPlayletInfo, likePlaylet } from "@/lib/api/playlet-actions";
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { CommentPanel, playletCommentTarget } from "@/components/common/comments";
import { PageBreadcrumb } from "@/components/seo/page-breadcrumb";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import {
formatPlays,
playHref,
totalEpisodeOf,
yearFromOnlineAt,
type DramaDetail,
type RelatedDrama,
} from "./data";
import { EpisodePanel, H5EpisodeStrip, H5MorePlaylets } from "./episodes";
import { RelatedAside } from "./related-aside";
async function ensurePlayletAuthToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
function resolvePlayletLikeFavorite(data: Awaited<ReturnType<typeof getPlayletInfo>> | null | undefined) {
const info = data?.playletInfo;
return {
isLike: Boolean(data?.isLike ?? info?.isLike),
isFavorite: Boolean(data?.isFavorite ?? info?.isFavorite),
displayCountLike: Number(info?.displayCountLike) || 0,
countCollect: Number(info?.countCollect) || 0,
};
}
type ActionProps = {
icon: string;
label: string;
active?: boolean;
onClick?: () => void;
href?: string;
};
function DetailAction({ icon, label, active = false, onClick, href }: ActionProps) {
const className = `flex items-center gap-2.5 text-base leading-[22px] transition-opacity hover:opacity-85 ${
active ? "text-[#F12B9E]" : "text-white/90"
}`;
const content: ReactNode = (
<>
<span
className={`flex size-9 items-center justify-center rounded-full ${active ? "bg-[#F12B9E]" : "bg-[#F12B9E]/10"}`}
>
<img src={icon} alt="" className="max-h-[21px] max-w-[22px]" />
</span>
{label}
</>
);
return href ? (
<Link href={href} className={className}>
{content}
</Link>
) : (
<button type="button" aria-pressed={active} onClick={onClick} className={`cursor-pointer ${className}`}>
{content}
</button>
);
}
export function DramaDetailPage({
detail,
related = [],
relatedAside,
promo,
adIcons,
adGrid,
bannerAds,
}: {
detail: DramaDetail;
related?: RelatedDrama[];
/** 服务端渲染的热门推荐侧栏 */
relatedAside?: ReactNode;
/** 服务端渲染的 PC banner */
promo?: ReactNode;
/** 服务端渲染的 PC icon */
adIcons?: ReactNode;
/** 服务端渲染的 H5 icon */
adGrid?: ReactNode;
/** 服务端渲染的 H5 banner */
bannerAds?: ReactNode;
}) {
const [liked, setLiked] = useState(Boolean(detail.isLike));
const [liking, setLiking] = useState(false);
const [collected, setCollected] = useState(Boolean(detail.isFavorite));
const [collecting, setCollecting] = useState(false);
const router = useRouter();
const tags = pickPlayletCardTags(detail);
const totalEpisode = totalEpisodeOf(detail);
const playEpisode = (episode: number) => router.push(playHref(detail.id, episode, detail.slug));
const likeFavoriteKey = `${detail.id}:${detail.isLike}:${detail.isFavorite}`;
const [likeFavoriteSync, setLikeFavoriteSync] = useState(likeFavoriteKey);
if (likeFavoriteSync !== likeFavoriteKey) {
setLikeFavoriteSync(likeFavoriteKey);
setLiked(Boolean(detail.isLike));
setCollected(Boolean(detail.isFavorite));
}
// SSR 详情走 "use cache" 无登录态;客户端带 token 再拉一次,纠正已点赞 / 已收藏
useEffect(() => {
let cancelled = false;
void (async () => {
const token = await ensurePlayletAuthToken();
if (!token || cancelled) return;
try {
const data = await getPlayletInfo(
detail.slug?.trim() ? { slug: detail.slug.trim() } : { id: detail.id },
token,
);
if (cancelled) return;
const status = resolvePlayletLikeFavorite(data);
setLiked(status.isLike);
setCollected(status.isFavorite);
} catch (error) {
console.warn("[drama-detail/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 playletId = Number(detail.id);
if (!Number.isInteger(playletId) || playletId < 1) return;
const token = await ensurePlayletAuthToken();
if (!token) {
openLoginDialog();
return;
}
const next = !liked;
setLiked(next);
setLiking(true);
try {
const result = await likePlaylet({ id: playletId, isLike: next }, token);
setLiked(Boolean(result?.isLike ?? next));
} catch (error) {
setLiked(!next);
handleUnauthorized(error);
} finally {
setLiking(false);
}
};
const toggleCollect = async () => {
if (collecting) return;
const playletId = Number(detail.id);
if (!Number.isInteger(playletId) || playletId < 1) return;
const token = await ensurePlayletAuthToken();
if (!token) {
openLoginDialog();
return;
}
const next = !collected;
setCollected(next);
setCollecting(true);
try {
const result = await favoritePlaylet({ id: playletId, isFavorite: next }, token);
setCollected(Boolean(result?.isFavorite ?? next));
} catch (error) {
setCollected(!next);
handleUnauthorized(error);
} finally {
setCollecting(false);
}
};
return (
<main data-component="drama-detail-page" className="relative flex-1 bg-[#0E0E0E] lg:bg-[#060611]">
<div className="pointer-events-none absolute inset-x-0 top-0 h-[544px] overflow-hidden lg:hidden" aria-hidden="true">
<DecryptedImage src={detail.cover} fallback={IMG_PLACEHOLDER} alt="" priority className="size-full object-cover" />
<div className="absolute inset-0 bg-black/80" />
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.1)_0%,rgba(102,102,102,0)_40%,rgba(14,14,14,0.55)_70%,#0E0E0E_100%)]" />
</div>
<BackButton fallback="/short-drama/hot" className="absolute top-3 left-4 z-10 lg:hidden" />
<div
data-pc-scale
className="relative hidden flex-col gap-8 pt-8 pb-12 lg:mx-auto lg:flex lg:w-[1700px] lg:[zoom:var(--page-scale,1)]"
>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "短剧详情" },
]}
/>
<PageBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "短剧详情" },
]}
/>
<section className="flex h-[544px] gap-10 overflow-hidden rounded-[20px] bg-[rgba(14,13,29,0.92)] p-8 outline outline-1 -outline-offset-1 outline-[rgba(59,56,92,0.1)]">
<DecryptedImage
src={detail.cover}
fallback={IMG_PLACEHOLDER}
alt={detail.title}
priority
className="h-[480px] w-[360px] shrink-0 rounded-xl object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-[18px]">
<div className="flex gap-[9px] text-xs leading-[18px]">
<span className="flex h-[26px] items-center rounded-[13px] bg-[#F12B9E] px-[11px] text-white">全站免费</span>
{tags.map((tag) =>
cardTagCanLink(tag) ? (
<Link
key={tag.slug ?? tag.id ?? tag.name}
href={tagPageHref(tag, "playlet")}
className="flex h-[26px] items-center rounded-[13px] bg-[#171624] px-[11px] text-[#9E96AD] hover:text-white"
>
{tag.name}
</Link>
) : (
<span
key={tag.name}
className="flex h-[26px] items-center rounded-[13px] bg-[#171624] px-[11px] text-[#9E96AD]"
>
{tag.name}
</span>
),
)}
</div>
<p className="text-[40px] leading-[47px] text-white">{detail.title}</p>
<p className="text-sm whitespace-pre text-[#9E96AD]">
{yearFromOnlineAt(detail.onlineAt)} {detail.themeNames?.[0] || "地区未知"} 共 {totalEpisode} 集
</p>
<p className="line-clamp-6 text-sm leading-[22px] text-[#9E96AD]">简介:{detail.desc}</p>
<div className="flex h-[52px] items-center gap-6">
<Link
href={playHref(detail.id, 1, detail.slug)}
className="flex h-[45px] w-36 items-center justify-center rounded-[23px] bg-[#F12B9E] text-base leading-[22px] font-semibold text-white transition-colors hover:bg-[#F23DA8]"
>
立即播放
</Link>
<div className="flex w-[490px] items-center justify-between">
<DetailAction
icon="/images/icon_like.svg"
label={liked ? "已点赞" : "点赞"}
active={liked}
onClick={liking ? undefined : toggleLike}
/>
<span className="h-[19px] w-0.5 bg-white/10" />
<DetailAction
icon="/images/icon_collect.svg"
label={collected ? "已收藏" : "收藏"}
active={collected}
onClick={collecting ? undefined : toggleCollect}
/>
<span className="h-[19px] w-0.5 bg-white/10" />
<DetailAction icon="/images/icon_share.svg" label="分享" onClick={share} />
<span className="h-[19px] w-0.5 bg-white/10" />
<DetailAction icon="/images/icon_feedback.svg" label="反馈" href="/feedback" />
</div>
</div>
<div className="flex h-[88px] shrink-0 items-center justify-between rounded-xl bg-[linear-gradient(90deg,#301438_0%,#171A40_100%)] px-5">
<div className="flex flex-col">
<p className="text-sm leading-5 text-white">App 内继续看,不丢进度</p>
<p className="text-xs leading-[18px] text-[#9E96AD]">同步观看记录、收藏、金币余额和更新提醒。</p>
</div>
<button
type="button"
onClick={() => openDownloadDialog()}
className="cursor-pointer h-[38px] w-[111px] rounded-[19px] bg-[linear-gradient(90deg,#F22BA1_0%,#4A59FF_100%)] text-base leading-[22px] text-white transition-opacity hover:opacity-90"
>
下载
</button>
</div>
</div>
</section>
{adIcons ?? <AdIcons variant="pc" />}
<EpisodePanel detail={detail} current={1} onSelect={playEpisode} />
<div className="flex items-start gap-6">
<CommentPanel target={playletCommentTarget(detail.id)} />
{relatedAside ?? <RelatedAside items={related} />}
</div>
{promo ?? <AdBanners variant="pc" />}
</div>
<div className="relative flex flex-col gap-[18px] px-4 pt-[60px] pb-8 lg:hidden">
<section className="flex items-center gap-3">
<DecryptedImage
src={detail.cover}
fallback={IMG_PLACEHOLDER}
alt={detail.title}
className="h-[106px] w-[76px] shrink-0 rounded-md object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<h1 className="line-clamp-2 text-base leading-[22px] font-medium text-white">{detail.title}</h1>
<p className="flex flex-wrap items-start gap-1.5 text-xs leading-[18px] text-white/35">
<span>全{totalEpisode}集</span>
<span aria-hidden="true">·</span>
<span>{formatPlays(detail.countBrowse ?? detail.heatValue)}</span>
</p>
<div className="flex flex-wrap items-center gap-1.5 text-xs leading-[18px] text-white/90">
{tags.slice(0, 3).map((tag) =>
cardTagCanLink(tag) ? (
<Link
key={tag.slug ?? tag.id ?? tag.name}
href={tagPageHref(tag, "playlet")}
className="rounded-[3px] bg-white/10 px-1.5 py-0.5"
>
#{tag.name}
</Link>
) : (
<span key={tag.name} className="rounded-[3px] bg-white/10 px-1.5 py-0.5">
#{tag.name}
</span>
),
)}
{detail.anchor ? (
<span className="rounded-[3px] bg-white/10 px-1.5 py-0.5">演员·{detail.anchor}</span>
) : null}
</div>
</div>
</section>
{detail.desc?.trim() ? (
<section className="flex w-full flex-col gap-3">
<h2 className="text-base leading-[22px] font-medium text-white">简介</h2>
<p className="text-xs leading-[18px] text-white/65">{detail.desc.trim()}</p>
</section>
) : null}
{adGrid ?? <AdIcons variant="h5" />}
<H5EpisodeStrip detail={detail} onSelect={playEpisode} />
<H5MorePlaylets items={related} />
{bannerAds ?? <AdBanners variant="h5" />}
</div>
<div
data-component="h5-bottom-bar"
data-h5-bottom-bar
className="fixed inset-x-0 bottom-0 z-40 flex h-[68px] items-center justify-center gap-3 bg-[#010101] px-4 pb-[env(safe-area-inset-bottom)] lg:hidden"
>
<button
type="button"
aria-pressed={collected}
disabled={collecting}
onClick={toggleCollect}
className={`cursor-pointer flex h-11 w-[108px] items-center justify-center gap-3 rounded-full bg-white text-base leading-[22px] font-medium disabled:opacity-70 ${
collected ? "text-[#F12B9E]" : "text-[#3D3D3D]"
}`}
>
<span
className={`size-[18px] [mask:url(/images/icon_collect.svg)_center/contain_no-repeat] ${
collected ? "bg-[#F12B9E]" : "bg-[#A3A3B8]"
}`}
/>
{collected ? "已收藏" : "收藏"}
</button>
<Link
href={playHref(detail.id, 1, detail.slug)}
className="flex h-11 w-[223px] items-center justify-center rounded-full bg-[#F12B9E] text-base leading-[22px] font-medium text-white"
>
开始播放
</Link>
</div>
<div className="max-lg:hidden">
<FloatingActions />
</div>
</main>
);
}
+306
View File
@@ -0,0 +1,306 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { CornerFlagBadge, resolveContentBadge } from "@/components/home/section-heading";
import { dramaHref } from "@/components/short-drama/data";
import {
getEpisodeChunks,
getEpisodeRanges,
getEpisodeState,
totalEpisodeOf,
updateStatusLabel,
type DramaDetail,
type RelatedDrama,
} from "./data";
type EpisodeProps = {
detail: DramaDetail;
current: number;
onSelect: (episode: number) => void;
};
function LockIcon({ className = "" }: { className?: string }) {
return (
<svg viewBox="0 0 18 18" fill="none" className={className} aria-hidden="true">
<rect x="3.75" y="7.5" width="10.5" height="8.25" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M6 7.5V5.25a3 3 0 0 1 6 0V7.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M9 10.5v2.25" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
/** 免费/未解锁均进播放页;未解锁由播放页在播放器上盖下载遮罩 */
function useEpisodeClick(_detail: DramaDetail, onSelect: (episode: number) => void) {
return (episode: number) => onSelect(episode);
}
/** 与播放页「更多好剧」一致的三列推荐卡片 */
export function MorePlayletsGrid({ items }: { items: RelatedDrama[] }) {
if (!items.length) {
return <p className="py-8 text-center text-xs text-white/35">暂无更多推荐</p>;
}
return (
<div className="grid grid-cols-3 gap-x-[5px] gap-y-3">
{items.map((item) => {
const badge = resolveContentBadge(item);
return (
<Link key={item.id} href={dramaHref(item.id, item.slug)} className="flex flex-col gap-1">
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-md bg-[#525252]">
<DecryptedImage src={item.cover} alt="" className="size-full object-cover" />
<CornerFlagBadge
tone={badge}
className="absolute top-2 right-2 z-[1]"
badgeClassName="h-[18px]"
/>
</div>
<span className="truncate text-sm leading-5 text-white">{item.title}</span>
<span className="truncate text-xs leading-[18px] text-white/50">
{item.tagNames?.[0]}
{item.tagNames?.[0] && item.totalEpisode ? " · " : ""}
{item.totalEpisode ? `${item.totalEpisode}集` : ""}
</span>
</Link>
);
})}
</div>
);
}
export function EpisodePanel({ detail, current, onSelect }: EpisodeProps) {
const totalEpisode = totalEpisodeOf(detail);
const ranges = getEpisodeRanges(totalEpisode, 20);
const [rangeIndex, setRangeIndex] = useState(-1);
const range = ranges[rangeIndex];
const start = range?.start ?? 1;
const end = range?.end ?? totalEpisode;
const handleClick = useEpisodeClick(detail, onSelect);
return (
<section
data-component="episode-panel"
data-episodes
className="flex flex-col gap-4 rounded-2xl bg-[rgba(20,23,46,0.9)] p-6 outline outline-1 -outline-offset-1 outline-[#333354]"
>
<div className="flex h-10 items-center gap-6">
<h2 className="text-[22px] leading-[30px] text-white">选集</h2>
<p className="flex-1 text-sm text-[#8C879C]">共 {totalEpisode} 集</p>
<div className="flex items-center gap-8 text-sm">
{[{ label: "全部", index: -1 }, ...ranges.map((item, index) => ({ label: item.label, index }))].map(
(item) => (
<button
key={item.label}
type="button"
aria-pressed={rangeIndex === item.index}
onClick={() => setRangeIndex(item.index)}
className={`cursor-pointer ${rangeIndex === item.index ? "text-[#F12B9E]" : "text-[#8C879C] hover:text-white"}`}
>
{item.label}
</button>
),
)}
</div>
</div>
<div className="grid grid-cols-[repeat(10,154px)] justify-between gap-y-3">
{Array.from({ length: end - start + 1 }, (_, index) => start + index).map((episode) => {
const locked = getEpisodeState(episode, detail) === "locked";
const playing = episode === current;
return (
<button
key={episode}
type="button"
aria-current={playing || undefined}
aria-label={`第${episode}集${locked ? "(未解锁)" : ""}`}
onClick={() => handleClick(episode)}
className={`cursor-pointer relative flex h-[58px] items-center justify-center gap-0.5 overflow-hidden rounded-lg text-lg leading-6 font-semibold transition-colors ${
playing
? "bg-[rgba(241,43,158,0.1)] text-[#F12B9E] outline outline-1 -outline-offset-1 outline-[#F12B9E]"
: locked
? "bg-white/10 text-[#828186] outline outline-1 -outline-offset-1 outline-white/5 hover:bg-white/15"
: "bg-[#3A3D53] text-white hover:bg-[#454866]"
}`}
>
第{episode}集
{locked ? <LockIcon className="size-[18px]" /> : null}
{!locked ? (
<span className="absolute top-1 right-1 flex h-5 w-9 items-center justify-center rounded bg-[linear-gradient(136deg,#74FFED_0%,#10B9A2_100%)] text-[10px] font-normal leading-4 text-[#002D27]">
免费
</span>
) : null}
</button>
);
})}
</div>
</section>
);
}
export function H5EpisodeStrip({ detail, onSelect, current }: Omit<EpisodeProps, "current"> & { current?: number }) {
const totalEpisode = totalEpisodeOf(detail);
const ranges = getEpisodeChunks(totalEpisode, 30);
const [rangeIndex, setRangeIndex] = useState(0);
const { start, end } = ranges[rangeIndex] ?? ranges[0];
const handleClick = useEpisodeClick(detail, onSelect);
const hasPreview = Boolean(detail.preview?.trim());
const previewChapter = detail.chapterList?.find(
(item) => item.title?.trim() === "预告" || Number(item.currentEpisode) === 0,
);
const showPreview = hasPreview || Boolean(previewChapter);
return (
<section data-component="h5-episode-strip" data-episodes className="inline-flex w-full flex-col items-start gap-3">
<div className="inline-flex w-full items-center justify-between">
<h2 className="text-sm leading-5 font-medium text-white/90">剧集</h2>
<div className="flex items-center gap-4">
<span className="flex items-center gap-0.5 text-xs leading-[18px] text-[#656565]">
{updateStatusLabel(detail.updateStatus)} 共{totalEpisode}集
<img src="/images/arrow_rught.svg" alt="" className="size-[18px] opacity-50" />
</span>
</div>
</div>
<div className="inline-flex w-full items-center gap-6 overflow-x-auto text-sm leading-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{ranges.map((item, index) => (
<button
key={item.label}
type="button"
aria-pressed={rangeIndex === index}
onClick={() => setRangeIndex(index)}
className={`cursor-pointer shrink-0 ${rangeIndex === index ? "text-white" : "text-white/35"}`}
>
{item.start}-{item.end}
</button>
))}
<Link href="/short-drama/hot" className="shrink-0 text-white/35">
更多好剧
</Link>
</div>
<div className="inline-flex w-full items-center gap-1.5 overflow-x-auto overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{showPreview && rangeIndex === 0 ? (
<button
type="button"
aria-label="预告"
onClick={() => {
if (previewChapter && Number(previewChapter.currentEpisode) > 0) {
handleClick(Number(previewChapter.currentEpisode));
return;
}
handleClick(1);
}}
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-[rgba(246,103,38,0.1)]"
>
<span className="absolute top-[21px] inset-x-0 flex items-end justify-center text-center text-base leading-[22px] font-medium text-[#F12B9E]">
预告
</span>
<img src="/images/playing.svg" alt="" className="absolute top-0 left-[38px] size-5" />
</button>
) : null}
{Array.from({ length: end - start + 1 }, (_, index) => start + index).map((episode) => {
const locked = getEpisodeState(episode, detail) === "locked";
const playing = current != null && episode === current;
if (playing) {
return (
<button
key={episode}
type="button"
aria-current="true"
aria-label={`第${episode}集`}
onClick={() => handleClick(episode)}
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-[rgba(246,103,38,0.1)]"
>
<span className="absolute top-[21px] inset-x-0 flex items-end justify-center text-center text-base leading-[22px] font-medium text-[#F12B9E]">
{episode}
</span>
<img src="/images/playing.svg" alt="" className="absolute top-0 left-[38px] size-5" />
{!locked ? (
<img
src="/images/tag_mf.png"
alt="免费"
className="pointer-events-none absolute top-0 right-0 h-[14px] w-[25px]"
/>
) : null}
</button>
);
}
if (locked) {
return (
<button
key={episode}
type="button"
aria-label={`第${episode}集(未解锁)`}
onClick={() => handleClick(episode)}
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-white/5 outline outline-1 -outline-offset-1 outline-[#222222]"
>
<span className="absolute top-[21px] left-2.5 inline-flex w-10 items-center justify-center text-base leading-[22px] font-medium text-[#828186]">
<span className="min-w-0 flex-1 text-center">{episode}</span>
<LockIcon className="size-[18px] shrink-0" />
</span>
</button>
);
}
return (
<button
key={episode}
type="button"
aria-label={`第${episode}集`}
onClick={() => handleClick(episode)}
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-white/5"
>
<span className="absolute top-[21px] inset-x-0 flex items-end justify-center text-center text-base leading-[22px] font-medium text-white">
{episode}
</span>
<img
src="/images/tag_mf.png"
alt="免费"
className="pointer-events-none absolute top-0 right-0 h-[14px] w-[25px]"
/>
</button>
);
})}
</div>
</section>
);
}
/** H5 详情:热门推荐 — 一行横向滚动 */
export function H5MorePlaylets({ items }: { items: RelatedDrama[] }) {
if (!items.length) return null;
return (
<section id="more-playlets" data-component="h5-more-playlets" className="flex scroll-mt-4 flex-col gap-3">
<h2 className="text-sm leading-5 font-medium text-white/90">热门推荐</h2>
<div className="-mx-4 flex gap-[9px] overflow-x-auto px-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{items.map((item) => {
const badge = resolveContentBadge(item);
return (
<Link key={item.id} href={dramaHref(item.id, item.slug)} className="flex w-[98px] shrink-0 flex-col gap-1">
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-md bg-[#525252]">
<DecryptedImage src={item.cover} alt="" className="size-full object-cover" />
<CornerFlagBadge
tone={badge}
className="absolute top-2 right-2 z-[1]"
badgeClassName="h-[18px]"
/>
</div>
<span className="truncate text-xs leading-[18px] font-medium text-white">{item.title}</span>
<span className="truncate text-xs leading-[18px] text-white/65">
{item.tagNames?.[0]}
{item.tagNames?.[0] && item.totalEpisode ? " · " : ""}
{item.totalEpisode ? `${item.totalEpisode}集` : ""}
</span>
</Link>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,48 @@
import Link from "next/link";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { formatHeat } from "@/components/ranking/data";
import { dramaHref } from "@/components/short-drama/data";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import type { RelatedDrama } from "./data";
export function RelatedAside({ items }: { items: RelatedDrama[] }) {
if (!items.length) return null;
return (
<aside data-component="drama-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="/short-drama/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>
{items.map((item) => (
<Link
key={item.id}
href={dramaHref(String(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-[72px] shrink-0 rounded-lg object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<h3 className="truncate text-base leading-[23px] text-[#F5F0FA] group-hover:text-[#F12B9E]">
{item.title}
</h3>
<p className="truncate text-sm leading-[21px] text-[#8C859C]">{item.desc}</p>
<div className="flex items-center justify-between text-xs leading-[18px]">
<span className="text-[#A89EB5]">{item.tagNames?.[0]}</span>
<span className="text-[#ABA1B8]">{formatHeat(item.heatValue ?? item.countBrowse)}</span>
</div>
</div>
</Link>
))}
</aside>
);
}
@@ -0,0 +1,401 @@
"use client";
import { toast } from "@heroui/react";
import Link from "next/link";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useDramaContext } from "@/components/analytics/use-playback-analytics";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
CommentPanel,
playletCommentTarget,
type CommentTarget,
} from "@/components/common/comments";
import {
dramaDetailHref,
getEpisodeState,
type DramaDetail,
type RelatedDrama,
} from "@/components/drama-detail/data";
import { RelatedAside } from "@/components/drama-detail/related-aside";
import { FloatingActions } from "@/components/home/floating-actions";
import { PageBreadcrumb } from "@/components/seo/page-breadcrumb";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import { favoritePlaylet, getPlayletInfo, likePlaylet } from "@/lib/api/playlet-actions";
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { H5Play } from "./h5-play";
import { PcPlayer } from "./pc-player";
import { PlayEpisodePanel } from "./play-episode-panel";
import { useReportPlayletHistory } from "./use-report-playlet-history";
const pcFrame = "mx-auto w-[1700px] [zoom:var(--page-scale,1)]";
const pcScaleProps = { "data-pc-scale": true as const };
async function ensurePlayletAuthToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
function resolvePlayletLikeFavorite(data: Awaited<ReturnType<typeof getPlayletInfo>> | null | undefined) {
const info = data?.playletInfo;
return {
isLike: Boolean(data?.isLike ?? info?.isLike),
isFavorite: Boolean(data?.isFavorite ?? info?.isFavorite),
displayCountLike: Number(info?.displayCountLike) || 0,
countCollect: Number(info?.countCollect) || 0,
};
}
function initialDisplayCountLike(detail: DramaDetail) {
return Number(detail.displayCountLike) || 0;
}
function initialCountCollect(detail: DramaDetail) {
return Number(detail.countCollect) || 0;
}
type PillProps = {
icon: string;
label: string;
active?: boolean;
onClick?: () => void;
href?: string;
};
export function ActionPill({ icon, label, active = false, onClick, href }: PillProps) {
const className = `inline-flex h-[52px] w-auto shrink-0 items-center justify-center gap-2.5 rounded-[26px] bg-[rgba(31,23,31,0.55)] px-4 text-base leading-[22px] whitespace-nowrap outline outline-1 -outline-offset-1 outline-[rgba(255,227,196,0.28)] transition-colors hover:bg-[rgba(31,23,31,0.85)] ${
active ? "text-[#F12B9E]" : "text-white/90"
}`;
const content = (
<>
<span
className={`flex size-9 shrink-0 items-center justify-center rounded-full ${active ? "bg-[#F12B9E]" : "bg-[#F12B9E]/10"}`}
>
<img src={icon} alt="" className="max-h-[21px] max-w-[22px]" />
</span>
{label}
</>
);
return href ? (
<Link href={href} className={className}>
{content}
</Link>
) : (
<button type="button" aria-pressed={active} onClick={onClick} className={`cursor-pointer ${className}`}>
{content}
</button>
);
}
export function DramaPlayPage({
detail,
episode,
src,
related = [],
relatedAside,
adIcons,
promo,
}: {
detail: DramaDetail;
episode: number;
src: string;
related?: RelatedDrama[];
relatedAside?: ReactNode;
adIcons?: ReactNode;
promo?: ReactNode;
}) {
const [liked, setLiked] = useState(Boolean(detail.isLike));
const [displayCountLike, setDisplayCountLike] = useState(() => initialDisplayCountLike(detail));
const [liking, setLiking] = useState(false);
const [collected, setCollected] = useState(Boolean(detail.isFavorite));
const [countCollect, setCountCollect] = useState(() => initialCountCollect(detail));
const [collecting, setCollecting] = useState(false);
const [expanded, setExpanded] = useState(false);
const locked = getEpisodeState(episode, detail) === "locked" || !src;
const playSrc = locked ? "" : src;
const episodeLabel = String(episode).padStart(2, "0");
const chapter = detail.chapterList?.find((item) => Number(item.currentEpisode) === episode);
const tagName = detail.tagNames?.[0] || detail.themeNames?.[0] || "";
const dramaTrackContext = useMemo(
() => ({
media_id: String(detail.id || ""),
drama_id: String(detail.id || ""),
drama_title: detail.title || "未命名短剧",
episode: Math.max(1, episode),
video_id: String(chapter?.id || detail.id || ""),
drama_duration: Math.max(0, Math.round(Number(chapter?.mediaTime) || 0)),
recommend_trace_id: "",
drama_tag_key: tagName ? "tag" : "default",
drama_tag_name: tagName || "默认标签",
}),
[detail.id, detail.title, episode, chapter?.id, chapter?.mediaTime, tagName],
);
useDramaContext(dramaTrackContext);
const likeFavoriteKey = `${detail.id}:${detail.isLike}:${detail.isFavorite}:${detail.countCollect}:${detail.displayCountLike}`;
const [likeFavoriteSync, setLikeFavoriteSync] = useState(likeFavoriteKey);
if (likeFavoriteSync !== likeFavoriteKey) {
setLikeFavoriteSync(likeFavoriteKey);
setLiked(Boolean(detail.isLike));
setDisplayCountLike(initialDisplayCountLike(detail));
setCollected(Boolean(detail.isFavorite));
setCountCollect(initialCountCollect(detail));
}
// SSR 详情走 "use cache" 无登录态;客户端带 token 再拉一次,纠正已点赞 / 已收藏
useEffect(() => {
let cancelled = false;
void (async () => {
const token = await ensurePlayletAuthToken();
if (!token || cancelled) return;
try {
const data = await getPlayletInfo(
detail.slug?.trim() ? { slug: detail.slug.trim() } : { id: detail.id },
token,
);
if (cancelled) return;
const status = resolvePlayletLikeFavorite(data);
setLiked(status.isLike);
setCollected(status.isFavorite);
setDisplayCountLike(status.displayCountLike);
setCountCollect(status.countCollect);
} catch (error) {
console.warn("[drama-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("复制失败,请稍后重试");
}
};
useEffect(() => {
if (!expanded) return;
const root = document.documentElement;
root.dataset.playTheater = "";
window.scrollTo({ top: 0 });
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setExpanded(false);
};
window.addEventListener("keydown", onKey);
return () => {
delete root.dataset.playTheater;
window.removeEventListener("keydown", onKey);
};
}, [expanded]);
const toggleExpanded = () => setExpanded((value) => !value);
const toggleLike = async () => {
if (liking) return;
const playletId = Number(detail.id);
if (!Number.isInteger(playletId) || playletId < 1) return;
const token = await ensurePlayletAuthToken();
if (!token) {
openLoginDialog();
return;
}
const next = !liked;
setLiked(next);
setDisplayCountLike((value) => Math.max(0, value + (next ? 1 : -1)));
setLiking(true);
try {
const result = await likePlaylet({ id: playletId, isLike: next }, token);
setLiked(Boolean(result?.isLike ?? next));
if (typeof result?.displayCountLike === "number") {
setDisplayCountLike(result.displayCountLike);
} else if (typeof result?.likeNum === "number") {
setDisplayCountLike(result.likeNum);
}
} catch (error) {
setLiked(!next);
setDisplayCountLike((value) => Math.max(0, value + (next ? -1 : 1)));
handleUnauthorized(error);
} finally {
setLiking(false);
}
};
const toggleCollect = async () => {
if (collecting) return;
const playletId = Number(detail.id);
if (!Number.isInteger(playletId) || playletId < 1) return;
const token = await ensurePlayletAuthToken();
if (!token) {
openLoginDialog();
return;
}
const next = !collected;
setCollected(next);
setCountCollect((value) => Math.max(0, value + (next ? 1 : -1)));
setCollecting(true);
try {
const result = await favoritePlaylet({ id: playletId, isFavorite: next }, token);
setCollected(Boolean(result?.isFavorite ?? next));
if (typeof result?.countCollect === "number") setCountCollect(result.countCollect);
} catch (error) {
setCollected(!next);
setCountCollect((value) => Math.max(0, value + (next ? -1 : 1)));
handleUnauthorized(error);
} finally {
setCollecting(false);
}
};
const chapterId = detail.chapterList?.find((item) => Number(item.currentEpisode) === episode)?.id;
/** 与短剧详情共用短剧维度评论,不按单集拆分 */
const commentTarget: CommentTarget | null = playletCommentTarget(detail.id);
const reportWatchHistory = useReportPlayletHistory(Number(detail.id), Number(chapterId) || undefined);
return (
<main data-component="drama-play-page" className="relative flex-1 bg-[#0B0709]">
{!expanded ? (
<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.cover}
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>
) : null}
<div
{...(expanded ? {} : pcScaleProps)}
className={`relative hidden flex-col gap-6 pb-8 lg:flex ${expanded ? "" : `pt-6 ${pcFrame}`}`}
>
{!expanded ? (
<>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "短剧", href: dramaDetailHref(detail) },
{ name: `第${episode}集` },
]}
/>
<PageBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "短剧", href: dramaDetailHref(detail) },
{ name: `第${episode}集` },
]}
/>
</>
) : null}
<div className={expanded ? "contents" : "flex flex-col gap-4"}>
<div className={expanded ? "contents" : "flex h-[648px] gap-5"}>
<PcPlayer
key={`${episode}-${playSrc}`}
src={playSrc}
poster={detail.cover || ""}
title={`${detail.title || ""} 第${episode}集`}
locked={locked}
episode={episode}
dramaId={detail.slug?.trim() || String(detail.id)}
dramaTitle={detail.title || ""}
layout={expanded ? "theater" : "inline"}
onToggleExpand={toggleExpanded}
onPlaySuccess={reportWatchHistory}
/>
{!expanded ? <PlayEpisodePanel detail={detail} current={episode} /> : null}
</div>
<div
{...(expanded ? pcScaleProps : {})}
className={expanded ? `flex flex-col gap-4 ${pcFrame}` : "contents"}
>
{expanded ? <PlayEpisodePanel detail={detail} current={episode} wide /> : null}
{adIcons ?? <AdIcons variant="pc" />}
<section className="flex h-[140px] items-center justify-between 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="text-sm leading-5 text-[#F12B9E]">第 {episode} 集</p>
<p className="truncate text-[28px] leading-[34px] font-semibold text-white">{detail.title}</p>
<p className="text-sm leading-5 text-[#9991AB]">
{detail.title} - 第{episodeLabel}集
</p>
</div>
<div className="flex shrink-0 gap-3">
<ActionPill
icon="/images/icon_like.svg"
label={liked ? "已点赞" : "点赞"}
active={liked}
onClick={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 target={commentTarget} />
{relatedAside ?? <RelatedAside items={related} />}
</div>
{promo ?? <AdBanners variant="pc" />}
</div>
</div>
</div>
<H5Play
key={`${episode}-${playSrc}`}
detail={detail}
episode={episode}
src={playSrc}
locked={locked}
liked={liked}
displayCountLike={displayCountLike}
collected={collected}
countCollect={countCollect}
related={related}
onLike={toggleLike}
onCollect={toggleCollect}
onShare={share}
commentTarget={commentTarget}
onPlaySuccess={reportWatchHistory}
/>
<div className="max-lg:hidden">
<FloatingActions />
</div>
</main>
);
}
+581
View File
@@ -0,0 +1,581 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { BackButton } from "@/components/common/back-button";
import { H5CommentSheet, type CommentTarget } from "@/components/common/comments";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
dramaDetailHref,
getEpisodeState,
playHref,
totalEpisodeOf,
updateStatusLabel,
type DramaDetail,
type RelatedDrama,
} from "@/components/drama-detail/data";
import { MorePlayletsGrid } from "@/components/drama-detail/episodes";
import { openAppDownload } from "@/components/download/download-dialog";
import { playbackRates, XgPlayer, type XgPlayerInstance } from "@/components/player/xg-player";
import { CheckIcon, ChevronIcon } from "./icons";
import { useEpisodeRange } from "./play-episode-panel";
type Sheet = "episodes" | "speed" | "comments" | null;
/** 播完自动下一集倒计时(秒) */
const AUTO_NEXT_SECONDS = 3;
/** 切集后自动起播标记(sessionStorage) */
const AUTOPLAY_NEXT_KEY = "hjsq-drama-autoplay-next";
function consumeAutoplayFlag() {
if (typeof window === "undefined") return false;
try {
if (sessionStorage.getItem(AUTOPLAY_NEXT_KEY) !== "1") return false;
sessionStorage.removeItem(AUTOPLAY_NEXT_KEY);
return true;
} catch {
return false;
}
}
function markAutoplayNext() {
try {
sessionStorage.setItem(AUTOPLAY_NEXT_KEY, "1");
} catch {
/* ignore */
}
}
type H5PlayProps = {
detail: DramaDetail;
episode: number;
src: string;
locked: boolean;
liked: boolean;
/** 点赞数 displayCountLike */
displayCountLike?: number;
collected: boolean;
/** 收藏数 countCollect */
countCollect?: number;
related?: RelatedDrama[];
onLike: () => void;
onCollect: () => void;
onShare: () => void;
commentTarget: CommentTarget | null;
onPlaySuccess?: () => void;
};
function LockedNotice() {
return (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-4 bg-black/70 text-center">
<p className="text-sm leading-5 text-white">本集暂未在官网开放</p>
<button
type="button"
onClick={openAppDownload}
className="cursor-pointer inline-flex h-9 items-center justify-center rounded-full bg-[#F12B9E] px-6 text-sm font-medium text-white"
>
下载APP观看完整剧集
</button>
</div>
);
}
function formatCount(value: number) {
return value >= 10000 ? `${(value / 10000).toFixed(1)}w` : String(value);
}
function BottomSheet({
open,
onClose,
label,
className = "",
children,
}: {
open: boolean;
onClose: () => void;
label: string;
className?: string;
children: ReactNode;
}) {
return (
<>
<div
aria-hidden="true"
onClick={onClose}
className={`fixed inset-0 z-40 bg-black/70 transition-opacity ${open ? "opacity-100" : "pointer-events-none opacity-0"}`}
/>
<div
role="dialog"
aria-modal="true"
aria-label={label}
aria-hidden={!open}
inert={!open}
className={`fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-[20px] bg-[#1A1A1A] pb-[env(safe-area-inset-bottom)] transition-transform duration-300 ${
open ? "translate-y-0" : "translate-y-full"
} ${className}`}
>
{children}
</div>
</>
);
}
function SheetHead({ title, onClose, children }: { title: ReactNode; onClose: () => void; children?: ReactNode }) {
return (
<div className="flex items-center gap-3">
<button
type="button"
aria-label="收起"
onClick={onClose}
className="cursor-pointer flex size-6 items-center justify-center text-white"
>
<ChevronIcon direction="down" className="size-4" />
</button>
<h2 className="min-w-0 flex-1 text-center text-base leading-[22px] font-medium text-white">{title}</h2>
{children ?? <span className="size-6" />}
</div>
);
}
function EpisodeSheet({
detail,
episode,
collected,
related = [],
onCollect,
onClose,
}: Pick<H5PlayProps, "detail" | "episode" | "collected" | "related" | "onCollect"> & {
onClose: () => void;
}) {
const { ranges, rangeIndex, setRangeIndex, episodes } = useEpisodeRange(detail, episode);
const [tab, setTab] = useState<"episodes" | "more">("episodes");
return (
<div className="flex max-h-[75vh] flex-col gap-3 px-4 pt-2.5 pb-[18px]">
<span className="mx-auto h-[5px] w-9 shrink-0 rounded-full bg-white/20" />
<div className="flex items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<DecryptedImage
src={detail.cover}
alt=""
className="h-[75px] w-[54px] shrink-0 rounded-[3px] object-cover"
/>
<div className="flex min-w-0 flex-col gap-2">
<Link href={dramaDetailHref(detail)} className="flex items-center gap-2 text-sm leading-5 text-white">
<span className="truncate">{detail.title}</span>
<ChevronIcon direction="right" className="size-3 shrink-0" />
</Link>
<p className="text-xs leading-[18px] text-white/35">
{updateStatusLabel(detail.updateStatus)} 共{totalEpisodeOf(detail)}集
</p>
</div>
</div>
<button
type="button"
aria-pressed={collected}
onClick={onCollect}
className={`cursor-pointer shrink-0 rounded-full px-3 py-1 text-[10px] leading-4 ${collected ? "bg-white/5 text-[#F12B9E]" : "bg-[#F12B9E] text-white"}`}
>
{collected ? "已收藏" : "收藏"}
</button>
</div>
<div className="flex shrink-0 gap-6 overflow-x-auto text-sm leading-5 [scrollbar-width:none]">
{ranges.map((range, index) => (
<button
key={range.label}
type="button"
aria-pressed={tab === "episodes" && rangeIndex === index}
onClick={() => {
setTab("episodes");
setRangeIndex(index);
}}
className={`cursor-pointer shrink-0 ${tab === "episodes" && rangeIndex === index ? "text-white" : "text-white/35"}`}
>
{range.start}-{range.end}
</button>
))}
<button
type="button"
aria-pressed={tab === "more"}
onClick={() => setTab("more")}
className={`cursor-pointer shrink-0 ${tab === "more" ? "text-white" : "text-white/35"}`}
>
更多好剧
</button>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain">
{tab === "episodes" ? (
<div className="grid grid-cols-5 gap-x-[11px] gap-y-2">
{episodes.map((item) => {
const active = item === episode;
const locked = getEpisodeState(item, detail) === "locked";
if (active) {
return (
<Link
key={item}
href={playHref(detail.id, item, detail.slug)}
replace
onClick={onClose}
aria-current="true"
className="relative flex h-[58px] items-center justify-center overflow-hidden rounded-md bg-[rgba(241,43,158,0.1)] text-base leading-[22px] text-[#F12B9E] outline outline-1 -outline-offset-1 outline-[#F12B9E]"
>
{item}
<img src="/images/playing.svg" alt="" className="absolute top-0 right-0 size-5 p-[3px]" />
{!locked ? (
<span className="absolute top-0 left-0 flex h-[14px] w-[25px] items-center justify-center rounded-sm bg-[linear-gradient(136deg,#74FFED_0%,#10B9A2_100%)] text-[8px] leading-3 text-[#002D27]">
免费
</span>
) : null}
</Link>
);
}
if (!locked) {
return (
<Link
key={item}
href={playHref(detail.id, item, detail.slug)}
replace
onClick={onClose}
className="relative flex h-[58px] items-center justify-center overflow-hidden rounded-md bg-[#3A3D53] text-base leading-[22px] text-white"
>
{item}
<span className="absolute top-0 right-0 flex h-[14px] w-[25px] items-center justify-center rounded-sm bg-[linear-gradient(136deg,#74FFED_0%,#10B9A2_100%)] text-[8px] leading-3 text-[#002D27]">
免费
</span>
</Link>
);
}
return (
<Link
key={item}
href={playHref(detail.id, item, detail.slug)}
replace
onClick={onClose}
aria-label={`第${item}集(未解锁)`}
className="relative flex h-[58px] items-center justify-center gap-0.5 overflow-hidden rounded-md bg-white/10 text-base leading-[22px] text-[#828186] outline outline-1 -outline-offset-1 outline-white/5"
>
{item}
<svg viewBox="0 0 18 18" fill="none" className="size-3.5" aria-hidden="true">
<path
d="M5.29 7.65V5.25a3.71 3.71 0 0 1 7.42 0v2.4"
stroke="currentColor"
strokeWidth="1.35"
strokeLinecap="round"
/>
<rect x="3.75" y="7.5" width="10.5" height="8.25" rx="1.5" stroke="currentColor" strokeWidth="1.35" />
</svg>
</Link>
);
})}
</div>
) : (
<MorePlayletsGrid items={related} />
)}
</div>
</div>
);
}
export function H5Play({
detail,
episode,
src,
locked,
liked,
displayCountLike,
collected,
countCollect,
related = [],
onLike,
onCollect,
onShare,
commentTarget,
onPlaySuccess,
}: H5PlayProps) {
const router = useRouter();
const playerRef = useRef<XgPlayerInstance | null>(null);
const [rate, setRate] = useState(1);
const [sheet, setSheet] = useState<Sheet>(null);
const [cleared, setCleared] = useState(false);
const [introOpen, setIntroOpen] = useState(false);
/** null=未进入自动下一集;数字=剩余秒数 */
const [autoNextSeconds, setAutoNextSeconds] = useState<number | null>(null);
/** 自动切集进入本集时消费一次,用于起播 */
const [shouldAutoplay] = useState(consumeAutoplayFlag);
const closeSheet = () => setSheet(null);
const totalEpisode = totalEpisodeOf(detail);
const nextEpisode = episode + 1;
const hasNext = nextEpisode <= totalEpisode;
const nextHref = hasNext ? playHref(detail.id, nextEpisode, detail.slug) : null;
useEffect(() => {
if (!sheet) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setSheet(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [sheet]);
// 换集后清倒计时
useEffect(() => {
setAutoNextSeconds(null);
}, [episode, src]);
// 倒计时结束跳转下一集并标记自动播放
useEffect(() => {
if (autoNextSeconds == null || !nextHref) return;
if (autoNextSeconds <= 0) {
markAutoplayNext();
router.push(nextHref);
return;
}
const timer = window.setTimeout(() => {
setAutoNextSeconds((value) => (value == null ? null : value - 1));
}, 1000);
return () => window.clearTimeout(timer);
}, [autoNextSeconds, nextHref, router]);
const goNextEpisode = () => {
if (!nextHref) return;
setAutoNextSeconds(null);
markAutoplayNext();
router.push(nextHref);
};
const handleEnded = () => {
if (!hasNext || locked) return;
setAutoNextSeconds(AUTO_NEXT_SECONDS);
};
const likeCount = displayCountLike ?? (Number(detail.displayCountLike) || 0);
const collectCount = countCollect ?? (Number(detail.countCollect) || 0);
/** 已点赞 #F52C56 · 已收藏 #F68902 · 文案始终 #DCDCDC */
const sideItems = [
{
label: formatCount(likeCount),
icon: "/images/icon_like.svg",
active: liked,
activeColor: "#F52C56",
onClick: onLike,
},
{
label: "评论",
icon: "/images/comment.svg",
active: false,
activeColor: "#FFFFFF",
onClick: () => setSheet("comments"),
},
{
label: formatCount(collectCount),
icon: "/images/icon_collect.svg",
active: collected,
activeColor: "#F68902",
onClick: onCollect,
},
{
label: "分享",
icon: "/images/share_h5.svg",
active: false,
activeColor: "#FFFFFF",
onClick: onShare,
},
];
return (
<div data-component="drama-h5-play" className="fixed inset-0 z-30 flex flex-col bg-[#0E0E0E] lg:hidden">
<div className="relative min-h-0 flex-1 overflow-hidden">
{locked ? (
<DecryptedImage
src={detail.cover}
alt={detail.title}
className="absolute inset-0 size-full object-cover"
/>
) : (
<XgPlayer
src={src}
poster={detail.cover}
playerRef={playerRef}
onReady={(player) => {
player.on("ratechange", () => setRate(player.playbackRate));
// 用户重新起播则取消自动下一集
player.on("playing", () => setAutoNextSeconds(null));
}}
onPlaySuccess={onPlaySuccess}
onEnded={handleEnded}
analyticsKind="drama"
clickToPlay={!shouldAutoplay}
className="absolute inset-0"
options={{ ignores: ["playbackrate", "cssfullscreen"] }}
/>
)}
<div className="pointer-events-none absolute inset-x-0 top-0 h-[min(300px,40%)] bg-[linear-gradient(180deg,#000_0%,rgba(0,0,0,0)_100%)]" />
{!cleared ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[min(212px,30%)] bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,rgba(0,0,0,0.75)_100%)]" />
) : null}
<div className="absolute inset-x-0 top-0 z-20 flex items-center gap-1 px-4 pt-[calc(12px+env(safe-area-inset-top))] pb-3">
<BackButton fallback={dramaDetailHref(detail)} />
<p className="min-w-0 flex-1 truncate text-lg leading-6 font-medium text-white">第{episode}集</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setSheet("speed")}
className="cursor-pointer flex items-center gap-1 text-xs leading-[18px] font-medium text-white"
>
<img src="/images/speed.svg" alt="" className="size-[18px]" />
{rate === 1 ? "1.0" : rate}x
</button>
<Link href="/feedback" aria-label="意见反馈" className="size-6 shrink-0">
<img src="/images/feedback.svg" alt="" className="size-full" />
</Link>
</div>
</div>
{locked ? <LockedNotice /> : null}
{!cleared ? (
<>
<div className="pointer-events-none absolute right-4 bottom-[120px] z-10 inline-flex flex-col items-center gap-[18px]">
<div className="flex flex-col items-center gap-2.5">
{sideItems.map((item) => (
<button
key={item.icon}
type="button"
aria-pressed={item.active}
onClick={item.onClick}
className="pointer-events-auto cursor-pointer flex flex-col items-center text-center text-xs leading-[18px] font-medium text-[#DCDCDC]"
>
<span className="relative size-[30px] overflow-hidden">
<span
aria-hidden="true"
className="absolute inset-0"
style={{
background: item.active ? item.activeColor : "#FFFFFF",
mask: `url(${item.icon}) center / 24px no-repeat`,
WebkitMask: `url(${item.icon}) center / 24px no-repeat`,
}}
/>
</span>
{item.label}
</button>
))}
</div>
</div>
<div className="pointer-events-none absolute bottom-[68px] left-4 z-10 flex w-[min(278px,calc(100%-90px))] flex-col gap-2.5">
<button
type="button"
aria-label="选集"
onClick={() => setSheet("episodes")}
className="pointer-events-auto flex max-w-full cursor-pointer items-center gap-[5px] text-left text-xs leading-[18px] font-medium text-white"
>
<h1 className="min-w-0 truncate">{detail.title}</h1>
<ChevronIcon direction="right" className="size-[18px] shrink-0" />
</button>
<div className="flex items-end gap-1.5">
<p
className={`max-w-[248px] text-xs leading-[18px] text-white/65 ${introOpen ? "pointer-events-auto max-h-40 overflow-y-auto" : "line-clamp-2"}`}
>
{detail.desc}
</p>
<button
type="button"
onClick={() => setIntroOpen((open) => !open)}
className="pointer-events-auto cursor-pointer shrink-0 text-xs leading-[18px] text-white"
>
{introOpen ? "收起" : "展开"}
</button>
</div>
</div>
</>
) : null}
{autoNextSeconds != null && hasNext ? (
<button
type="button"
onClick={goNextEpisode}
className="absolute inset-x-4 bottom-3 z-20 flex cursor-pointer items-center justify-between rounded-md bg-white/10 px-3 py-1.5 text-sm leading-5"
>
<span className="text-white">自动播放下一集</span>
<span className="text-[#FFAB6B]">倒计时{autoNextSeconds}s</span>
</button>
) : null}
</div>
<div className="flex min-h-[58px] shrink-0 items-center gap-2.5 bg-[#1A1A1A] px-4 pt-1.5 pb-[calc(6px+env(safe-area-inset-bottom))]">
<button
type="button"
onClick={() => setSheet("episodes")}
className="cursor-pointer flex min-w-0 flex-1 items-center justify-between rounded-[3px] bg-white/5 px-3 py-1.5 text-xs leading-[18px] text-white"
>
<span className="truncate">选集·全{totalEpisode}集·免费观看</span>
<ChevronIcon direction="up" className="size-6 shrink-0" />
</button>
{cleared ? (
<button
type="button"
aria-label="评论"
onClick={() => setSheet("comments")}
className="cursor-pointer rounded-[3px] bg-white/5 px-3 py-1.5 text-white"
>
<img src="/images/comment.svg" alt="" className="size-6" />
</button>
) : null}
<button
type="button"
aria-label={cleared ? "退出清屏" : "清屏"}
aria-pressed={cleared}
onClick={() => setCleared((value) => !value)}
className="cursor-pointer text-white"
>
<img src={cleared ? "/images/unscreen.svg" : "/images/screen.svg"} alt="" className="size-6" />
</button>
</div>
<BottomSheet open={sheet === "episodes"} onClose={closeSheet} label="选集">
<EpisodeSheet
detail={detail}
episode={episode}
collected={collected}
related={related}
onCollect={onCollect}
onClose={closeSheet}
/>
</BottomSheet>
<BottomSheet open={sheet === "speed"} onClose={closeSheet} label="倍速" className="gap-3 px-4 py-[18px]">
<SheetHead title="倍速" onClose={closeSheet} />
<div role="listbox" aria-label="选择倍速" className="flex flex-col">
{playbackRates.map((item) => (
<button
key={item}
type="button"
role="option"
aria-selected={item === rate}
onClick={() => {
if (playerRef.current) playerRef.current.playbackRate = item;
setRate(item);
closeSheet();
}}
className={`cursor-pointer flex h-12 items-center justify-between text-base leading-[22px] ${item === rate ? "text-[#F12B9E]" : "text-white"}`}
>
{item}x{item === rate ? <CheckIcon /> : null}
</button>
))}
</div>
</BottomSheet>
<BottomSheet open={sheet === "comments"} onClose={closeSheet} label="评论">
<H5CommentSheet onClose={closeSheet} target={commentTarget} />
</BottomSheet>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More