@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]" />;
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from "@/components/common/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading className="flex-1 bg-[#07060E]" />;
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from "@/components/common/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading className="flex-1 bg-[#07060E]" />;
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 || ""),
|
||||
};
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = "";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user