Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-26 17:51:31 +07:00
co-authored by Claude Opus 5.5
commit 61d0a11dfa
371 changed files with 35836 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { AboutPage } from "@/components/about/about-page";
import { JsonLd } from "@/components/seo/json-ld";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
seoInfo: {
title: "关于我们",
description: "了解海角社区的品牌、产品与服务。",
},
canonical: "/about",
});
}
export default async function Page() {
const siteSeo = await getHomeBaseSEOCached();
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/about",
title: "关于我们",
description: "了解海角社区的品牌、产品与服务。",
type: "AboutPage",
breadcrumbs: [
["首页", "/"],
["关于我们", "/about"],
],
siteName: siteSeo.siteName,
})}
/>
<AboutPage />
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { FavoritesPage } from "@/components/favorites/favorites-page";
export const metadata: Metadata = {
title: "我的收藏 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <FavoritesPage />;
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { FeedbackPage } from "@/components/feedback/feedback-page";
import { JsonLd } from "@/components/seo/json-ld";
import { buildWebPageJSONLD, getHomeBaseSEOCached } from "@/lib/seo";
export const metadata: Metadata = {
title: "问题反馈 - 海角社区",
robots: { index: false, follow: false },
};
export default async function Page() {
const siteSeo = await getHomeBaseSEOCached();
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/feedback",
title: "问题反馈",
description: "向海角社区提交问题反馈与建议。",
breadcrumbs: [
["首页", "/"],
["问题反馈", "/feedback"],
],
siteName: siteSeo.siteName,
})}
/>
<FeedbackPage gmailAddr={siteSeo.config?.gmailAddr} />
</>
);
}
+418
View File
@@ -0,0 +1,418 @@
@import "tailwindcss";
@import "@heroui/styles";
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
/* HeroUI 深色主题对齐站点主色(toast / select 等组件共用) */
.dark,
[data-theme="dark"] {
--accent: #f12b9e;
--accent-foreground: #ffffff;
--focus: #f12b9e;
--background: #07060d;
--surface: #171424;
--surface-foreground: #ffffff;
--overlay: #1a1628;
--overlay-foreground: #ffffff;
--muted: #948ca8;
--success: #3dd68c;
--danger: #ff5c6e;
}
body {
background: #07060d;
color: #ffffff;
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", "Microsoft YaHei", sans-serif;
}
/* 搜索框:不参与 scroll-padding / 滚动锚定,避免输入时页面跳动 */
.site-search-input {
scroll-margin: 0;
overflow-anchor: none;
}
[data-component="site-header"] form[role="search"] {
scroll-margin: 0;
overflow-anchor: none;
}
/* 深色搜索框:取消浏览器 autocomplete / autofill 灰蓝底 */
.site-search-input:-webkit-autofill,
.site-search-input:-webkit-autofill:hover,
.site-search-input:-webkit-autofill:focus,
.site-search-input:-webkit-autofill:active {
-webkit-text-fill-color: #ffffff !important;
caret-color: #ffffff;
border-color: transparent !important;
/* 延后 UA 写入背景,视觉上保持透明 */
transition: background-color 99999s ease-in-out 0s;
box-shadow: 0 0 0px 1000px transparent inset !important;
-webkit-box-shadow: 0 0 0px 1000px transparent inset !important;
background-color: transparent !important;
background-image: none !important;
}
/* Toast 视觉贴近站点深色浮层 */
.toast-region {
z-index: 80;
}
.toast {
border: 1px solid rgba(255, 255, 255, 0.08);
background: #1a1628;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.toast__title {
color: #f2f2fa;
}
.toast__description {
color: #a3a3b8;
}
.toast--success .toast__title,
.toast--success .toast__indicator {
color: #3dd68c;
}
.toast--danger .toast__title,
.toast--danger .toast__indicator {
color: #ff8fa8;
}
.toast--accent .toast__title,
.toast--accent .toast__indicator {
color: #f12b9e;
}
/*
* PC 缩放区文案:.design-frame / [data-pc-scale] 内
* 字号/行高 × --page-unscale,抵消 page-scale zoom,窗口缩小时视觉大小不变。
*/
:is([data-pc-scale], .design-frame) {
--_u: var(--page-unscale, 1);
}
:is([data-pc-scale], .design-frame) .text-\[10px\] {
font-size: calc(10px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-xs {
font-size: calc(12px * var(--_u)) !important;
line-height: calc(16px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-sm {
font-size: calc(14px * var(--_u)) !important;
line-height: calc(20px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-base {
font-size: calc(16px * var(--_u)) !important;
line-height: calc(24px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-lg {
font-size: calc(18px * var(--_u)) !important;
line-height: calc(28px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-xl {
font-size: calc(20px * var(--_u)) !important;
line-height: calc(28px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-2xl {
font-size: calc(24px * var(--_u)) !important;
line-height: calc(32px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[22px\] {
font-size: calc(22px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[28px\] {
font-size: calc(28px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[29px\] {
font-size: calc(29px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[30px\] {
font-size: calc(30px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[32px\] {
font-size: calc(32px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[34px\] {
font-size: calc(34px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[40px\] {
font-size: calc(40px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .text-\[44px\] {
font-size: calc(44px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-none {
line-height: 1 !important;
}
:is([data-pc-scale], .design-frame) .leading-4 {
line-height: calc(16px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-5 {
line-height: calc(20px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-6 {
line-height: calc(24px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-7 {
line-height: calc(28px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-8 {
line-height: calc(32px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-9 {
line-height: calc(36px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-10 {
line-height: calc(40px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[18px\] {
line-height: calc(18px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[21px\] {
line-height: calc(21px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[22px\] {
line-height: calc(22px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[23px\] {
line-height: calc(23px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[26px\] {
line-height: calc(26px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[30px\] {
line-height: calc(30px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[33px\] {
line-height: calc(33px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[34px\] {
line-height: calc(34px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[42px\] {
line-height: calc(42px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[44px\] {
line-height: calc(44px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[47px\] {
line-height: calc(47px * var(--_u)) !important;
}
:is([data-pc-scale], .design-frame) .leading-\[59px\] {
line-height: calc(59px * var(--_u)) !important;
}
/* PC 卡片文案:字号 × --page-unscale,抵消 design-frame zoom,缩小窗口时视觉大小不变 */
.media-card-title {
font-size: calc(24px * var(--page-unscale, 1));
line-height: calc(32px * var(--page-unscale, 1));
/* 必须 overflow:hidden,否则 truncate 省略号在部分浏览器不生效 */
overflow: hidden;
}
.media-card-title-md {
font-size: calc(20px * var(--page-unscale, 1));
line-height: calc(28px * var(--page-unscale, 1));
overflow: hidden;
}
.media-card-title-lg {
font-size: calc(32px * var(--page-unscale, 1));
line-height: calc(41px * var(--page-unscale, 1));
overflow: hidden;
}
.media-card-desc {
font-size: calc(14px * var(--page-unscale, 1));
line-height: calc(20px * var(--page-unscale, 1));
overflow: hidden;
}
.media-card-desc-md {
font-size: calc(16px * var(--page-unscale, 1));
line-height: calc(22px * var(--page-unscale, 1));
}
.media-card-desc-sm {
font-size: 12px;
line-height: 18px;
}
.media-card-meta {
font-size: calc(14px * var(--page-unscale, 1));
line-height: calc(20px * var(--page-unscale, 1));
}
.media-card-episodes {
font-size: calc(14px * var(--page-unscale, 1));
line-height: calc(20px * var(--page-unscale, 1));
}
/* H5 窄卡:标签 / 热度 12px(H5 无 page-scale) */
.media-card-meta-sm {
font-size: 12px;
line-height: 18px;
}
/*
* 样式 H 封面/榜单文案:× --page-unscale,不参与 design-frame zoom 缩小。
* 卡面需 overflow-hidden + 截断,避免反缩放后长文案撑破。
*/
.style-h-board-title {
font-size: calc(32px * var(--page-unscale, 1));
line-height: calc(42px * var(--page-unscale, 1));
}
.style-h-board-sub {
font-size: calc(16px * var(--page-unscale, 1));
line-height: calc(22px * var(--page-unscale, 1));
}
.style-h-board-item-title {
font-size: calc(24px * var(--page-unscale, 1));
line-height: calc(32px * var(--page-unscale, 1));
}
.style-h-board-item-heat {
font-size: calc(14px * var(--page-unscale, 1));
line-height: calc(20px * var(--page-unscale, 1));
}
/* --page-scale is written by the page-scale script in app/layout.tsx (viewport width / 1920, capped at 1, and 1 below the lg breakpoint), along with its reciprocal --page-unscale. */
.design-frame {
width: 1700px;
margin-inline: auto;
zoom: var(--page-scale, 1);
}
/* Cancels the .design-frame zoom so the element keeps its design size. */
.design-unscaled {
zoom: var(--page-unscale, 1);
}
.design-gutter {
padding-inline: max(24px, 50% - 850px * var(--page-scale, 1));
}
.xg-theme.xgplayer {
background: #000;
}
/* QQ / 夸克 / UC:隐藏原生 media controls,强制走 xgplayer 控件 */
html.is-cn-browser .xg-theme.xgplayer video::-webkit-media-controls {
display: none !important;
}
html.is-cn-browser .xg-theme.xgplayer video::-webkit-media-controls-enclosure {
display: none !important;
}
html.is-cn-browser .xg-theme.xgplayer video::-webkit-media-controls-panel {
display: none !important;
}
html.is-cn-browser .xg-theme.xgplayer video::-webkit-media-controls-start-playback-button {
display: none !important;
-webkit-appearance: none;
}
html.is-cn-browser .xg-theme.xgplayer.xgplayer-is-cssfullscreen {
position: fixed !important;
inset: 0 !important;
width: 100vw !important;
height: 100vh !important;
height: var(--app-vh, 100dvh) !important;
max-width: none !important;
max-height: none !important;
z-index: 200 !important;
border-radius: 0 !important;
}
.xg-theme.xgplayer .xgplayer-loading svg path,
.xg-theme.xgplayer .xgplayer-loading svg .path {
stroke: #f12b9e;
}
.xg-theme.xgplayer .xgplayer-start,
.xg-theme.xgplayer.xgplayer-mobile .xgplayer-start {
width: 72px;
height: 72px;
background: transparent;
transition: transform 0.2s ease, opacity 0.2s ease, filter 0.2s ease;
}
.xg-theme.xgplayer .xgplayer-start:hover {
opacity: 1;
transform: translate(-50%, -50%) scale(1.06);
filter: drop-shadow(0 0 12px rgb(241 43 158 / 0.35));
}
.xg-theme.xgplayer .xgplayer-start:active {
transform: translate(-50%, -50%) scale(0.96);
}
.xg-theme.xgplayer .xgplayer-start xg-start-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 9999px;
background: transparent;
overflow: hidden;
}
.xg-theme.xgplayer .xgplayer-start img,
.xg-theme.xgplayer .xgplayer-start svg {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
@media (max-width: 1023px) {
.xg-theme.xgplayer .xgplayer-start,
.xg-theme.xgplayer.xgplayer-mobile .xgplayer-start {
width: 64px;
height: 64px;
}
}
.xg-theme.xgplayer .xgplayer-drag,
.xg-theme.xgplayer .xgplayer-progress-played,
.xg-theme.xgplayer .xgplayer-switch-active {
background: #f12b9e;
}
.xg-theme.xgplayer .xgplayer-playbackrate ul li.selected,
.xg-theme.xgplayer .xgplayer-definition ul li.selected {
color: #f12b9e;
}
@keyframes notice-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.notice-marquee-track {
/* duration 由组件按内容宽度写入,保证线速度恒定 */
animation: notice-marquee 12s linear infinite;
}
.ad-icon-marquee-track {
/* duration 由组件按内容宽度写入,保证线速度恒定;从右向左无缝滚动 */
animation: notice-marquee 20s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.notice-marquee-track,
.ad-icon-marquee-track {
animation: none;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
/** 部署健康检查(对齐 hkdj-web /healthz) */
export async function GET() {
return NextResponse.json({
status: "ok",
environment: process.env.APP_ENV || process.env.NODE_ENV || "unknown",
time: new Date().toISOString(),
});
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { HelpPage } from "@/components/help/help-page";
import { HELP_RICHTEXT_TITLES, loadRichTextFirst } from "@/components/richtext/load-richtext";
import { JsonLd } from "@/components/seo/json-ld";
import { buildWebPageJSONLD, getHomeBaseSEOCached, mergeSEO, resolvePageMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
const data = await loadRichTextFirst([...HELP_RICHTEXT_TITLES]);
return resolvePageMetadata({
seoInfo: mergeSEO(data?.seoInfo, {
title: "帮助中心",
description:
data?.seoInfo?.description?.trim() ||
data?.desc?.trim() ||
"查看海角社区常见问题、播放帮助与客服支持信息。",
}),
canonical: "/help",
});
}
async function HelpRouteContent() {
const data = await loadRichTextFirst([...HELP_RICHTEXT_TITLES]);
const siteSeo = await getHomeBaseSEOCached();
const seo = mergeSEO(data?.seoInfo, siteSeo.seo);
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/help",
title: seo.title || "帮助中心",
description:
seo.description ||
data?.desc ||
data?.title ||
"查看海角社区常见问题、播放帮助与客服支持信息。",
type: "WebPage",
breadcrumbs: [
["首页", "/"],
["帮助中心", "/help"],
],
siteName: siteSeo.siteName,
})}
/>
<HelpPage data={data} />
</>
);
}
export default function Page() {
return (
<Suspense fallback={<PageLoading />}>
<HelpRouteContent />
</Suspense>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { HistoryPage } from "@/components/history/history-page";
export const metadata: Metadata = {
title: "观看历史 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <HistoryPage />;
}
+148
View File
@@ -0,0 +1,148 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script";
import { Suspense } from "react";
import { AdDialogSlot } from "@/components/ad/ad-dialog-slot";
import { AddressDialogSlot } from "@/components/address/address-dialog-slot";
import { AgeGateDialogHost } from "@/components/age-gate/age-gate-dialog";
import { TrackerHost } from "@/components/analytics/tracker-host";
import { RequestHeadersPrime } from "@/components/api/request-headers-prime";
import { AuthUnauthorizedBridge } from "@/components/auth/auth-unauthorized-bridge";
import { GuestAuthBootstrap } from "@/components/auth/guest-auth-bootstrap";
import { LoginDialogHost } from "@/components/auth/login-dialog";
import { ToastHost } from "@/components/common/toast-host";
import { HomeBaseDebugSlot } from "@/components/debug/home-base-debug-slot";
import { DownloadDialogSlot } from "@/components/download/download-dialog-slot";
import { SiteShell } from "@/components/layout/site-shell";
import { browserCompatScript } from "@/lib/browser/compat-inline";
import { getRequestOrigin, OG_DEFAULT_IMAGE } from "@/lib/seo";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const pageScaleScript = `(function () {
var style = document.createElement("style");
document.head.appendChild(style);
function update() {
var width = document.documentElement.clientWidth;
var scale = width < 1024 ? 1 : Math.min(1, width / 1920);
style.textContent = ":root{--page-scale:" + scale + ";--page-unscale:" + 1 / scale + "}";
}
update();
window.addEventListener("resize", update);
})();`;
/** GA4 测量 ID(全站 head 注入) */
const GA4_MEASUREMENT_ID = "G-748CPNEVP5";
/** metadataBase 随请求 Host 变化,保证 rel=canonical 与访问地址一致 */
export async function generateMetadata(): Promise<Metadata> {
const origin = await getRequestOrigin();
return {
metadataBase: new URL(origin),
title: {
default: "海角社区",
template: "%s",
},
description: "发现好故事,开启追剧时光",
applicationName: "海角社区",
verification: {
google: "0Bby47n1AEIB3W8wtLZ-oFQ3l8xRqRT78oA0JVRJ5pM",
yandex: "69b55a6aa9ee9a5f",
other: {
"msvalidate.01": "431F89B229D3A00661DAF708E5C24BC0",
},
},
openGraph: {
type: "website",
locale: "zh_CN",
siteName: "海角社区",
images: [{ url: OG_DEFAULT_IMAGE, alt: "海角社区" }],
},
twitter: {
card: "summary_large_image",
images: [OG_DEFAULT_IMAGE],
},
icons: {
// SVG 优先(现代浏览器),PNG / ICO 兜底
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/favicon.png", type: "image/png", sizes: "512x512" },
{ url: "/favicon.ico", type: "image/x-icon", sizes: "32x32" },
],
shortcut: [{ url: "/favicon.ico", type: "image/x-icon" }],
apple: [{ url: "/favicon.png", type: "image/png", sizes: "180x180" }],
},
};
}
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="zh-CN"
className={`dark ${geistSans.variable} ${geistMono.variable} h-full scroll-pt-14 antialiased lg:scroll-pt-20`}
data-theme="dark"
// beforeInteractive 会给 html 加 is-cn-browser 等 class,需压制告警
suppressHydrationWarning
>
<head>
{/* Google Analytics (GA4) */}
<Script
async
src={`https://www.googletagmanager.com/gtag/js?id=${GA4_MEASUREMENT_ID}`}
strategy="afterInteractive"
/>
<Script id="ga4-gtag" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA4_MEASUREMENT_ID}');
`.trim()}
</Script>
</head>
<body className="flex min-h-full flex-col">
<Suspense fallback={null}>
<RequestHeadersPrime />
</Suspense>
<Suspense fallback={null}>
<TrackerHost />
</Suspense>
<Suspense fallback={null}>
<HomeBaseDebugSlot />
</Suspense>
<SiteShell>{children}</SiteShell>
<GuestAuthBootstrap />
<AuthUnauthorizedBridge />
<LoginDialogHost />
<Suspense fallback={null}>
<DownloadDialogSlot />
</Suspense>
<Suspense fallback={null}>
<AdDialogSlot />
</Suspense>
<Suspense fallback={null}>
<AddressDialogSlot />
</Suspense>
<Suspense fallback={null}>
<AgeGateDialogHost />
</Suspense>
<ToastHost />
<Script id="browser-compat" strategy="beforeInteractive">
{browserCompatScript}
</Script>
<Script id="page-scale" strategy="beforeInteractive">
{pageScaleScript}
</Script>
</body>
</html>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { LikesPage } from "@/components/likes/likes-page";
export const metadata: Metadata = {
title: "我的点赞 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <LikesPage />;
}
@@ -0,0 +1,5 @@
import { PageLoading } from "@/components/common/page-loading";
export default function Loading() {
return <PageLoading className="flex-1 bg-[#07060E]" />;
}
+88
View File
@@ -0,0 +1,88 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { loadMorePage } from "@/components/more/data";
import { MorePage } from "@/components/more/more-page";
import { moreListHref, parseMorePageSegment } from "@/components/more/route";
import { JsonLd } from "@/components/seo/json-ld";
import { dramaHref } from "@/components/short-drama/data";
import { videoHref } from "@/components/video/video-card";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
type Params = Promise<{ id: string; page?: string[] }>;
function decodeId(id: string) {
try {
return decodeURIComponent(id);
} catch {
return id;
}
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { id, page } = await params;
const configId = decodeId(id).trim();
const pageNum = parseMorePageSegment(page) ?? 1;
if (!configId) return resolvePageMetadata({ fallbackTitle: "更多内容" });
try {
const data = await loadMorePage(configId, 1);
const pathKey = data.configId || configId;
return resolvePageMetadata({
seoInfo: {
title: data.title,
description: data.subtitle || `浏览海角社区「${data.title}」更多精选内容。`,
keyworld: ["海角社区", data.title],
},
page: pageNum,
canonical: moreListHref(pathKey, pageNum),
});
} catch {
return resolvePageMetadata({
fallbackTitle: "更多内容",
page: pageNum,
canonical: moreListHref(configId, pageNum),
});
}
}
export default async function Page({ params }: { params: Params }) {
const { id, page: pageSegment } = await params;
const configId = decodeId(id).trim();
if (!configId) notFound();
const page = parseMorePageSegment(pageSegment);
if (page == null) notFound();
if (page === 1 && pageSegment?.length) {
redirect(moreListHref(configId, 1));
}
const [data, siteSeo] = await Promise.all([loadMorePage(configId, page), getHomeBaseSEOCached()]);
// 有 slug 时规范到 /more/{slug}(旧 /more/{id} 301)
if (data.configId && data.configId !== configId) {
redirect(moreListHref(data.configId, page));
}
const pathKey = data.configId || configId;
const itemListUrls = data.items
.filter((item) => item.kind === "drama")
.slice(0, 24)
.map((item) => (data.tab === "视频" ? videoHref(item.id, item.slug) : dramaHref(item.id, item.slug)));
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: moreListHref(pathKey, page),
title: data.title,
description: data.subtitle || `浏览海角社区「${data.title}」更多精选内容。`,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
[data.title, moreListHref(pathKey, 1)],
],
itemListUrls,
siteName: siteSeo.siteName,
})}
/>
<MorePage data={data} />
</>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Suspense, type ReactNode } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { emptyMorePage, parseMoreTab } from "@/components/more/data";
import { MorePage } from "@/components/more/more-page";
import { moreListHref } from "@/components/more/route";
/** 无 id 的空壳页;正式内容在 /more/{id},本页不索引 */
export const metadata: Metadata = {
title: "更多内容 - 海角社区",
robots: { index: false, follow: false },
};
type SearchParams = Promise<{ id?: string | string[]; type?: string | string[]; page?: string | string[] }>;
function first(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
/** 兼容旧链接 /more?id=xx&page=n → /more/xx[/n] */
async function MoreQueryBody({ searchParams }: { searchParams: SearchParams }): Promise<ReactNode> {
const params = await searchParams;
const id = first(params.id)?.trim();
if (id) {
const page = Math.max(1, Number(first(params.page)) || 1);
redirect(moreListHref(id, page));
}
return <MorePage data={emptyMorePage(parseMoreTab(first(params.type)))} />;
}
export default function Page({ searchParams }: { searchParams: SearchParams }) {
return (
<Suspense fallback={<PageLoading />}>
<MoreQueryBody searchParams={searchParams} />
</Suspense>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata } from "next";
import { NavPage } from "@/components/nav/nav-page";
import { JsonLd } from "@/components/seo/json-ld";
import { getHomeBaseCached } from "@/lib/api/home";
import type { AppTranslationInfo, ConfigInfo } from "@/lib/api/types";
import { buildWebPageJSONLD, mergeSEO, resolvePageMetadata } from "@/lib/seo";
async function loadNavBase(): Promise<{
config?: ConfigInfo;
bottonTranslations?: AppTranslationInfo;
}> {
try {
const base = await getHomeBaseCached();
return {
config: base.config,
bottonTranslations: base.bottonTranslations,
};
} catch (error) {
console.warn("[nav] home/base failed", error);
return {};
}
}
export async function generateMetadata(): Promise<Metadata> {
const { config } = await loadNavBase();
// 导航页优先 dh*,缺字段再回退 home/base 的 web*
return resolvePageMetadata({
seoInfo: {
title: config?.dhTitle,
description: config?.dhDescription,
keyworld: config?.dhKeyworld,
},
siteName: config?.dhSiteName?.trim() || undefined,
fallbackTitle: `${config?.dhSiteName?.trim() || "海角社区"} · 官方导航`,
fallbackDescription: `${config?.dhSiteName?.trim() || "海角社区"}官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。`,
fallbackKeywords: [config?.dhSiteName || "海角社区", "官方导航", "最新地址"].filter(Boolean) as string[],
canonical: "/nav",
});
}
export default async function Page() {
const { config, bottonTranslations } = await loadNavBase();
const siteName = config?.dhSiteName?.trim() || config?.webSiteName?.trim() || "海角社区";
const seo = mergeSEO(
{ title: config?.dhTitle, description: config?.dhDescription, keyworld: config?.dhKeyworld },
{ title: config?.webTitle, description: config?.webDescription, keyworld: config?.webKeyworld },
);
const title = seo.title || `${siteName} · 官方导航`;
const description =
seo.description || `${siteName}官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。`;
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/nav",
title,
description,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
["官方导航", "/nav"],
],
siteName,
})}
/>
<NavPage config={config} bottonTranslations={bottonTranslations} />
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import Link from "next/link";
import { GoBackButton } from "@/components/not-found/go-back-button";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
export const metadata: Metadata = {
title: "页面不存在 - 海角社区",
robots: { index: false, follow: false },
};
const actionClass =
"flex h-11 w-[120px] items-center justify-center rounded-[22px] text-sm leading-5 font-semibold transition-opacity hover:opacity-90";
export default function NotFound() {
return (
<main data-component="not-found" className="flex flex-1 flex-col items-center justify-center gap-3.5 bg-[radial-gradient(ellipse_50%_50%_at_50%_50%,#210F2E_0%,#07060E_100%)] px-4 py-16 text-center lg:min-h-[603px] lg:gap-[18px] lg:px-[110px] lg:pt-[92px] lg:pb-[72px]">
<SeoBreadcrumb items={[{ name: "首页", href: "/" }, { name: "页面不存在" }]} />
<p className="text-[88px] leading-[92px] font-semibold text-[#F12B9E] lg:text-[136px] lg:leading-[142px]">404</p>
<span className="rounded-full bg-[#F12B9E]/14 px-3.5 py-2 text-xs leading-[18px] font-semibold text-[#F12B9E]">
页面走丢了
</span>
<h1 className="text-xl leading-7 font-semibold text-white lg:text-[28px] lg:leading-[34px]">抱歉,没有找到这个页面</h1>
<p className="text-xs leading-[18px] text-[#948CA8] lg:text-sm lg:leading-5">页面可能已被删除、链接已失效,或暂时无法访问</p>
<form
action="/search"
role="search"
className="flex w-full max-w-[480px] items-center gap-2 rounded-[23px] bg-[#110F1F] py-0.5 pr-0.5 pl-3 outline outline-1 -outline-offset-1 outline-[#383657] focus-within:outline-[#F12B9E]/60"
>
<input
type="search"
name="q"
placeholder="搜索想看的短剧"
aria-label="搜索想看的短剧"
className="min-w-0 flex-1 bg-transparent text-xs leading-[18px] text-white outline-none placeholder:text-[#7A738F]"
/>
<button
type="submit"
className="cursor-pointer h-10 w-[72px] shrink-0 rounded-[22px] bg-[#F12B9E] text-sm leading-5 font-semibold text-white transition-opacity hover:opacity-90 lg:h-11 lg:w-[84px]"
>
搜索
</button>
</form>
<div className="flex gap-3">
<Link href="/" className={`${actionClass} bg-[#F12B9E] text-white`}>
返回首页
</Link>
<GoBackButton
className={`${actionClass} bg-[#211F38] text-[#D1CCE0] outline outline-1 -outline-offset-1 outline-[#4D476B]`}
/>
</div>
</main>
);
}
+93
View File
@@ -0,0 +1,93 @@
import type { Metadata } from "next";
import { cache } from "react";
import { HomeDataDebug } from "@/components/home/home-data-debug";
import { HomePage } from "@/components/home/home-page";
import { JsonLd } from "@/components/seo/json-ld";
import { dramaHref } from "@/components/short-drama/data";
import { getHomeBaseCached } from "@/lib/api/home";
import { getPlayletHomeCached } from "@/lib/api/playlet";
import type { AdvitiseData, ConfigInfo, HomePlayletResponse, PlayletData } from "@/lib/api/types";
import {
buildWebPageJSONLD,
getHomeBaseSEOCached,
mergeSEO,
resolvePageMetadata,
} from "@/lib/seo";
function summarizeFetchError(error: unknown) {
if (!(error instanceof Error)) return String(error);
const cause = (error as Error & { cause?: { code?: string; message?: string } }).cause;
const code = cause?.code || (error as Error & { code?: string }).code;
if (code) return `${error.message} (${code})`;
return error.message;
}
/** 同请求内 metadata + page 共用一次结果 */
const loadHome = cache(async (): Promise<HomePlayletResponse | null> => {
try {
return await getPlayletHomeCached();
} catch (error) {
console.warn(`[playlet/home] ${summarizeFetchError(error)}; fallback to local mock`);
return null;
}
});
const loadHomeBase = cache(async (): Promise<{ advitise?: AdvitiseData; config?: ConfigInfo }> => {
try {
const base = await getHomeBaseCached();
return { advitise: base.advitise, config: base.config };
} catch (error) {
console.warn(`[home/base] ${summarizeFetchError(error)}`);
return {};
}
});
export async function generateMetadata(): Promise<Metadata> {
const home = await loadHome();
// seoInfo → home/base 公共 web* → 代码兜底
return resolvePageMetadata({
seoInfo: home?.seoInfo,
fallbackTitle: "海角社区 - 热门短剧在线看",
fallbackDescription: "海角社区汇集热门短剧、最近上新、排行榜与精选专题推荐,支持高清在线播放。",
fallbackKeywords: ["海角社区", "短剧", "在线短剧", "热门短剧"],
canonical: "/",
});
}
export default async function Home() {
const [home, base, siteSeo] = await Promise.all([loadHome(), loadHomeBase(), getHomeBaseSEOCached()]);
const merged = home
? { ...home, advitise: base.advitise ?? home.advitise }
: base.advitise
? { advitise: base.advitise }
: null;
const seo = mergeSEO(home?.seoInfo, siteSeo.seo);
const homeTitle = seo.title || "海角社区 - 热门短剧在线看";
const homeDescription =
seo.description || "海角社区汇集热门短剧、最近上新、排行榜与精选专题推荐,支持高清在线播放。";
const itemListUrls =
home?.homeBannerData
?.filter((item): item is PlayletData & { id: number } => Boolean(item?.id))
.slice(0, 12)
.map((item) => dramaHref(item.id, item.slug)) ?? [];
return (
<>
<HomeDataDebug home={merged} base={base} />
<JsonLd
data={buildWebPageJSONLD({
path: "/",
title: homeTitle,
description: homeDescription,
type: "CollectionPage",
breadcrumbs: [["首页", "/"]],
itemListUrls,
siteName: siteSeo.siteName,
})}
/>
<HomePage home={merged} heading={homeTitle} />
</>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { JsonLd } from "@/components/seo/json-ld";
import { PrivacyPage } from "@/components/privacy/privacy-page";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
seoInfo: {
title: "隐私政策",
description: "查看海角社区隐私政策与个人信息保护说明。",
},
canonical: "/privacy",
});
}
export default async function Page() {
const siteSeo = await getHomeBaseSEOCached();
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/privacy",
title: "隐私政策",
description: "查看海角社区隐私政策与个人信息保护说明。",
type: "WebPage",
breadcrumbs: [
["首页", "/"],
["隐私政策", "/privacy"],
],
siteName: siteSeo.siteName,
})}
/>
<PrivacyPage />
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { ProfilePage } from "@/components/profile/profile-page";
export const metadata: Metadata = {
title: "个人中心 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <ProfilePage />;
}
+124
View File
@@ -0,0 +1,124 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import {
buildRankingList,
rankingDataType,
rankingTabLabels,
rankingTabs,
RANKING_PAGE_SIZE,
type RankingListResult,
type RankingTab,
} from "@/components/ranking/data";
import { RankingPage } from "@/components/ranking/ranking-page";
import {
parseRankingPageSegment,
parseRankingTab,
rankingHref,
resolveRankingTab,
} from "@/components/ranking/route";
import { JsonLd } from "@/components/seo/json-ld";
import { dramaHref } from "@/components/short-drama/data";
import { videoHref } from "@/components/video/video-card";
import { getPlayletContentListCached } from "@/lib/api/playlet";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
type Params = Promise<{ type: string; page?: string[] }>;
export function generateStaticParams() {
return rankingTabs.map((type) => ({ type }));
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { type, page } = await params;
const tab = parseRankingTab(decodeType(type));
const tabText = rankingTabLabels[tab];
const pageNum = parseRankingPageSegment(page) ?? 1;
return resolvePageMetadata({
seoInfo: {
title: `${tabText}排行榜`,
description: `查看海角社区实时更新的${tabText}排行榜,浏览热播内容与精选推荐。`,
keyworld: ["海角社区", "排行榜", tabText],
},
page: pageNum,
canonical: rankingHref(tab, pageNum),
});
}
function decodeType(type: string) {
try {
return decodeURIComponent(type);
} catch {
return type;
}
}
async function loadRanking(tab: RankingTab, page: number): Promise<RankingListResult> {
const dataType = rankingDataType(tab);
try {
const data = await getPlayletContentListCached({
dataType,
pageNum: page,
pageSize: RANKING_PAGE_SIZE,
});
return buildRankingList(data, dataType, page, RANKING_PAGE_SIZE);
} catch (error) {
console.error("[ranking]", error);
return buildRankingList(null, dataType, page, RANKING_PAGE_SIZE);
}
}
async function RankingContent({ params }: { params: Params }) {
const { type, page: pageSegment } = await params;
const rawType = decodeType(type);
const tab = resolveRankingTab(rawType);
if (!tab) notFound();
const page = parseRankingPageSegment(pageSegment);
if (page == null) notFound();
// 旧中文 tab → 英文规范 URL
if (rawType !== tab) {
redirect(rankingHref(tab, page));
}
if (page === 1 && pageSegment?.length) {
redirect(rankingHref(tab, 1));
}
const tabText = rankingTabLabels[tab];
const [list, siteSeo] = await Promise.all([loadRanking(tab, page), getHomeBaseSEOCached()]);
const itemListUrls = list.items
.filter((item) => item.kind === "drama")
.slice(0, 24)
.map((item) => (tab === "video" ? videoHref(item.id, item.slug) : dramaHref(item.id, item.slug)));
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: rankingHref(tab, page),
title: `${tabText}排行榜`,
description: `查看海角社区实时更新的${tabText}排行榜,浏览热播内容与精选推荐。`,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
["排行榜", rankingHref(tab, 1)],
],
itemListUrls,
siteName: siteSeo.siteName,
})}
/>
<RankingPage tab={tab} list={list} />
</>
);
}
export default function Page({ params }: { params: Params }) {
return (
<Suspense fallback={<PageLoading />}>
<RankingContent params={params} />
</Suspense>
);
}
+63
View File
@@ -0,0 +1,63 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { toRichTextPath } from "@/components/layout/nav-href";
import { loadRichText, normalizeRichTextTitle } from "@/components/richtext/load-richtext";
import { RichTextPage } from "@/components/richtext/richtext-page";
import { JsonLd } from "@/components/seo/json-ld";
import { buildWebPageJSONLD, getHomeBaseSEOCached, mergeSEO, resolvePageMetadata } from "@/lib/seo";
type Params = Promise<{ title: string }>;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const title = normalizeRichTextTitle((await params).title);
const data = await loadRichText(title);
if (!data) return resolvePageMetadata({ fallbackTitle: "详情" });
const pathTitle = data.title?.trim() || title;
return resolvePageMetadata({
seoInfo: mergeSEO(data.seoInfo, {
title: data.desc?.trim() || pathTitle || "详情",
description: data.desc || data.title,
}),
ogType: "article",
canonical: toRichTextPath(pathTitle),
});
}
async function RichTextRouteContent({ params }: { params: Params }) {
const title = normalizeRichTextTitle((await params).title);
const data = await loadRichText(title);
if (!data) notFound();
const pathTitle = data.title?.trim() || title;
const siteSeo = await getHomeBaseSEOCached();
const seo = mergeSEO(data.seoInfo, siteSeo.seo);
const pageTitle = seo.title || data.desc?.trim() || pathTitle || "详情";
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: toRichTextPath(pathTitle),
title: pageTitle,
description: seo.description || data.desc || data.title || pageTitle,
type: "WebPage",
breadcrumbs: [
["首页", "/"],
[pageTitle, toRichTextPath(pathTitle)],
],
siteName: siteSeo.siteName,
})}
/>
<RichTextPage data={data} />
</>
);
}
export default function Page({ params }: { params: Params }) {
return (
<Suspense fallback={<PageLoading />}>
<RichTextRouteContent params={params} />
</Suspense>
);
}
+51
View File
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { Suspense, type ReactNode } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { toRichTextPath } from "@/components/layout/nav-href";
import { loadRichText, normalizeRichTextTitle } from "@/components/richtext/load-richtext";
import { mergeSEO, resolvePageMetadata } from "@/lib/seo";
type SearchParams = Promise<{ title?: string | string[] }>;
function first(value?: string | string[]) {
return Array.isArray(value) ? value[0] : value;
}
/** 查询参数模式统一跳转到路径模式 /richtext/{title};本页仅跳转,不收录 */
export async function generateMetadata({ searchParams }: { searchParams: SearchParams }): Promise<Metadata> {
const title = normalizeRichTextTitle(first((await searchParams).title));
if (!title) {
return resolvePageMetadata({ fallbackTitle: "详情", robots: { index: false, follow: false } });
}
const data = await loadRichText(title);
if (!data) {
return resolvePageMetadata({ fallbackTitle: "详情", robots: { index: false, follow: false } });
}
return resolvePageMetadata({
seoInfo: mergeSEO(data.seoInfo, {
title: data.desc?.trim() || data.title?.trim() || "详情",
description: data.desc || data.title,
}),
robots: { index: false, follow: false },
});
}
async function RichTextQueryRedirect({
searchParams,
}: {
searchParams: SearchParams;
}): Promise<ReactNode> {
const title = normalizeRichTextTitle(first((await searchParams).title));
if (!title) notFound();
redirect(toRichTextPath(title));
}
export default function Page({ searchParams }: { searchParams: SearchParams }) {
return (
<Suspense fallback={<PageLoading />}>
<RichTextQueryRedirect searchParams={searchParams} />
</Suspense>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { MetadataRoute } from "next";
import { getHomeBaseCached } from "@/lib/api/home";
import { getRequestOrigin, getSiteOrigin } from "@/lib/seo/site";
/** 从地址字符串解析 origin(支持无协议域名) */
function originFromAddr(value?: string | null) {
const text = value?.trim();
if (!text) return undefined;
try {
const withProto = /^https?:\/\//i.test(text) ? text : `https://${text}`;
return new URL(withProto).origin;
} catch {
return undefined;
}
}
/** 基础 robots:私有页 / 搜索 disallow,并声明 sitemap(永久域名取 config.foreverUrlAddr) */
export default async function robots(): Promise<MetadataRoute.Robots> {
const forever = originFromAddr((await getHomeBaseCached()).config?.foreverUrlAddr);
const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim();
const origin = forever || (configured ? getSiteOrigin() : await getRequestOrigin());
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/profile",
"/history",
"/favorites",
"/likes",
"/settings",
"/feedback",
// 前缀匹配:/search、/search/短剧/... 均不抓
"/search",
],
},
],
sitemap: `${origin.replace(/\/$/, "")}/sitemap.xml`,
};
}
@@ -0,0 +1,72 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { connection } from "next/server";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { loadSearchPage } from "@/components/search/data";
import { parseSearchListPath, searchListHref } from "@/components/search/route";
import { SearchPage } from "@/components/search/search-page";
import { buildServerForwardHeaders } from "@/lib/api/client-headers";
import { primeRequestForwardHeaders } from "@/lib/api/request-context";
import { resolvePageMetadata } from "@/lib/seo";
type Params = Promise<{ type: string; slug?: string[] }>;
const SEARCH_ROBOTS = { index: false, follow: false } as const;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { type, slug } = await params;
const parsed = parseSearchListPath(type, slug);
if (!parsed) {
return resolvePageMetadata({
fallbackTitle: "搜索",
robots: SEARCH_ROBOTS,
});
}
return resolvePageMetadata({
seoInfo: {
title: `搜索「${parsed.query}」`,
description: `在海角社区搜索「${parsed.query}」的${parsed.type}结果。`,
keyworld: ["海角社区", "搜索", parsed.query, parsed.type],
},
page: parsed.page,
canonical: searchListHref(parsed.query, parsed.type, parsed.page),
// 搜索结果无限 URL / 薄内容,不参与收录
robots: SEARCH_ROBOTS,
});
}
async function SearchRouteContent({ params }: { params: Params }) {
await connection();
try {
primeRequestForwardHeaders(await buildServerForwardHeaders());
} catch {
/* ignore */
}
const { type, slug } = await params;
const parsed = parseSearchListPath(type, slug);
if (!parsed) notFound();
// /search/短剧/foo/1 → /search/短剧/foo
if (parsed.page === 1 && (slug?.length ?? 0) > 1) {
redirect(searchListHref(parsed.query, parsed.type, 1));
}
const list = await loadSearchPage(parsed.query, parsed.type, parsed.page);
// 页码超出时回落到末页(有结果时)
if (list.totalCount > 0 && parsed.page > list.totalPages) {
redirect(searchListHref(parsed.query, parsed.type, list.totalPages));
}
return <SearchPage list={list} />;
}
export default function Page({ params }: { params: Params }) {
return (
<Suspense fallback={<PageLoading />}>
<SearchRouteContent params={params} />
</Suspense>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { buildSearchList, parseSearchType } from "@/components/search/data";
import { searchListHref } from "@/components/search/route";
import { SearchPage } from "@/components/search/search-page";
import { resolvePageMetadata } from "@/lib/seo";
type SearchParams = Promise<{ q?: string | string[]; type?: string | string[]; page?: string | string[] }>;
function first(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
fallbackTitle: "搜索",
fallbackDescription: "搜索海角社区短剧与视频。",
fallbackKeywords: ["海角社区", "搜索"],
canonical: "/search",
// 搜索入口不收录;robots.txt 同步 Disallow /search
robots: { index: false, follow: false },
});
}
/** 兼容旧链接 /search?q=&type=&page= → /search/{type}/{q}[/{page}] */
async function SearchIndexContent({ searchParams }: { searchParams: SearchParams }) {
const params = await searchParams;
const query = first(params.q)?.trim() ?? "";
const type = parseSearchType(first(params.type));
const page = Number(first(params.page)) || 1;
if (query) {
redirect(searchListHref(query, type, page > 1 ? page : 1));
}
return <SearchPage list={buildSearchList(null, "", type, 1)} />;
}
export default function Page({ searchParams }: { searchParams: SearchParams }) {
return (
<Suspense fallback={<PageLoading />}>
<SearchIndexContent searchParams={searchParams} />
</Suspense>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { SettingsPage } from "@/components/settings/settings-page";
export const metadata: Metadata = {
title: "账号设置 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <SettingsPage />;
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { EditProfilePage } from "@/components/settings/edit-profile-page";
export const metadata: Metadata = {
title: "编辑个人资料 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <EditProfilePage />;
}
@@ -0,0 +1,427 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { connection } from "next/server";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import {
dramaDetailHref,
mapPlayletChapterPlay,
mapPlayletInfo,
mapPlayletRecommendList,
parseEpisode,
playHref,
RECOMMEND_DISPLAY_COUNT,
resolvePlayURL,
type DramaDetail,
type DramaPlayView,
type RelatedDrama,
} from "@/components/drama-detail/data";
import { DramaDetailPage } from "@/components/drama-detail/drama-detail-page";
import { RelatedAside } from "@/components/drama-detail/related-aside";
import { DramaPlayPage } from "@/components/drama-play/drama-play-page";
import { JsonLd } from "@/components/seo/json-ld";
import {
buildShortDramaList,
dramaHref,
PAGE_SIZE,
shortDramaSortType,
sortLabels,
sorts,
type ShortDramaListResult,
type Sort,
} from "@/components/short-drama/data";
import {
parseShortDramaListPath,
parseShortDramaSortSegment,
shortDramaListHref,
} from "@/components/short-drama/route";
import { ShortDramaPage } from "@/components/short-drama/short-drama-page";
import {
getPlayletChapterInfoCached,
getPlayletInfoCached,
getPlayletListCached,
getPlayletRecommendListCached,
getPlayletThemeListCached,
} from "@/lib/api/playlet";
import type { PlayletThemeItem } from "@/lib/api/types";
import { buildServerForwardHeaders } from "@/lib/api/client-headers";
import { primeRequestForwardHeaders } from "@/lib/api/request-context";
import { AdBannersSlot, AdIconsSlot } from "@/components/common/ads/slots";
import {
buildPlayletDetailJSONLD,
buildPlayletPlayJSONLD,
buildWebPageJSONLD,
getHomeBaseSEOCached,
mergeSEO,
resolveContentMetadata,
resolvePageMetadata,
} from "@/lib/seo";
type Params = Promise<{ sort: string; page?: string[] }>;
/** 详情 / 播放「更多好剧」:多取几条,排除当前剧后仍够展示 */
const RECOMMEND_PAGE_SIZE = RECOMMEND_DISPLAY_COUNT + 4;
export function generateStaticParams() {
return sorts.map((sort) => ({ sort }));
}
function decodeSegment(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function playletLookup(idOrSlug: string): { id?: string; slug?: string } {
if (/^\d+$/.test(idOrSlug)) return { id: idOrSlug };
return { slug: idOrSlug };
}
/** 更多好剧:GET /api/app/playlet/recommend/list(sortList=1 推荐) */
async function loadPlayletRecommend(excludeId?: number | string | null): Promise<RelatedDrama[]> {
try {
const data = await getPlayletRecommendListCached({
pageNum: 1,
pageSize: RECOMMEND_PAGE_SIZE,
sortList: 1,
});
return mapPlayletRecommendList(data, { excludeId, limit: RECOMMEND_DISPLAY_COUNT });
} catch (error) {
console.error("[short-drama/recommend]", error);
return [];
}
}
async function loadPlayletDetail(idOrSlug: string): Promise<DramaDetail | null> {
try {
const data = await getPlayletInfoCached(playletLookup(idOrSlug));
return mapPlayletInfo(data);
} catch (error) {
console.error("[short-drama/info]", error);
return null;
}
}
async function loadPlayletDetailWithRecommend(idOrSlug: string): Promise<{
detail: DramaDetail;
related: RelatedDrama[];
} | null> {
const lookup = playletLookup(idOrSlug);
try {
const detail = mapPlayletInfo(await getPlayletInfoCached(lookup));
if (!detail) return null;
// 更多好剧失败不影响详情主数据
const related = await loadPlayletRecommend(detail.id);
return { detail, related };
} catch (error) {
console.error("[short-drama/detail]", error);
return null;
}
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { sort, page } = await params;
const sortKey = parseShortDramaSortSegment(sort);
if (sortKey) {
const sortText = sortLabels[sortKey];
const listPath = parseShortDramaListPath(page);
const pageNum = listPath?.page ?? 1;
const themeSlug = listPath?.themeSlug;
// 列表 TDK:有题材时用 theme/list 的 seoInfo,缺字段再回退 home/base
const themes = await loadPlayletThemes();
const theme = themeSlug
? themes.find((item) => item.slug === themeSlug)
: undefined;
const themeName = theme?.name?.trim() || "";
return resolvePageMetadata({
seoInfo: theme?.seoInfo,
fallbackTitle: themeName ? `短剧·${themeName}·${sortText}` : `短剧·${sortText}`,
fallbackDescription: themeName
? `浏览海角社区短剧「${themeName}」题材,按${sortText}发现精彩作品。`
: `浏览海角社区短剧列表,按${sortText}发现精彩作品。`,
fallbackKeywords: ["海角社区", "短剧", themeName, sortText].filter(Boolean),
page: pageNum,
canonical: shortDramaListHref(sortKey, pageNum, themeSlug),
});
}
// 播放页 title:短剧标题 + 第几集 + 网页标题(站点名由 withSiteSuffix 追加)
if (page?.length === 1) {
const play = await loadPlayletPlay(decodeSegment(sort), page[0]);
if (!play) return resolveContentMetadata({ fallbackTitle: "海角社区" });
const dramaTitle = play.detail.title?.trim() || "短剧";
const playTitle = `${dramaTitle} 第${play.episode}集`;
const { title: _seoTitle, ...playSeoRest } = play.detail.seoInfo ?? {};
void _seoTitle;
return resolveContentMetadata({
seoInfo: play.detail.seoInfo ? playSeoRest : null,
fallbackTitle: playTitle,
fallbackDescription:
play.detail.desc || `在线观看《${dramaTitle}》第${play.episode}集`,
fallbackKeywords: [
"海角社区",
"短剧",
"在线播放",
dramaTitle,
playTitle,
...(play.detail.tagNames ?? []),
].filter(Boolean),
ogType: "video.episode",
canonical: playHref(play.detail.id, play.episode, play.detail.slug),
});
}
if (page?.length) return resolvePageMetadata({ fallbackTitle: "海角社区" });
// 详情页:TDK 用短剧详情(playlet/info)
const detail = await loadPlayletDetail(decodeSegment(sort));
if (!detail) return resolveContentMetadata({ fallbackTitle: "海角社区" });
return resolveContentMetadata({
seoInfo: detail.seoInfo,
fallbackTitle: detail.title,
fallbackDescription: detail.desc || `在线观看《${detail.title}》,查看选集、剧情信息与相关推荐。`,
fallbackKeywords: ["海角社区", "短剧", detail.title || "", ...(detail.tagNames ?? [])].filter(
Boolean,
),
canonical: dramaDetailHref(detail),
});
}
async function loadPlayletPlay(idOrSlug: string, episodeRaw: string): Promise<DramaPlayView | null> {
const detail = await loadPlayletDetail(idOrSlug);
if (!detail) return null;
const episode = parseEpisode(episodeRaw, detail);
if (!episode) return null;
const chapter = detail.chapterList?.find((item) => Number(item.currentEpisode) === episode);
if (!chapter?.id) return null;
const recommendPromise = loadPlayletRecommend(detail.id);
try {
const data = await getPlayletChapterInfoCached(chapter.id);
const mapped = mapPlayletChapterPlay(data, detail, episode);
const related = await recommendPromise;
if (mapped) return { ...mapped, related: related.length ? related : mapped.related };
// 接口异常时回退剧集列表里的相对地址(无 CDN 时可能无法播)
return {
detail,
episode,
chapterTitle: chapter.title?.trim() || undefined,
src: resolvePlayURL(chapter.videoUrl),
related,
};
} catch (error) {
console.error("[short-drama/chapter]", error);
return {
detail,
episode,
chapterTitle: chapter?.title?.trim() || undefined,
src: resolvePlayURL(chapter?.videoUrl),
related: await recommendPromise,
};
}
}
async function loadPlayletThemes(): Promise<PlayletThemeItem[]> {
try {
const data = await getPlayletThemeListCached();
return data.list ?? [];
} catch (error) {
console.error("[short-drama/theme]", error);
return [];
}
}
async function loadShortDramaList(
sort: Sort,
page: number,
themeSlug?: string,
): Promise<ShortDramaListResult> {
const themes = await loadPlayletThemes();
const activeTheme = themeSlug
? themes.find((theme) => theme.slug === themeSlug)
: undefined;
// 非法题材路径段时不当作筛选条件,仍展示题材列表
const resolvedSlug = activeTheme?.slug;
try {
const data = await getPlayletListCached({
pageNum: page,
pageSize: PAGE_SIZE,
sortType: shortDramaSortType(sort),
themeSlug: resolvedSlug,
});
return buildShortDramaList(data, sort, page, PAGE_SIZE, {
themes,
themeSlug: resolvedSlug,
});
} catch (error) {
console.error("[short-drama]", error);
return buildShortDramaList(null, sort, page, PAGE_SIZE, {
themes,
themeSlug: resolvedSlug,
});
}
}
async function ShortDramaRouteContent({ params }: { params: Params }) {
// 请求期渲染:fetch 超时等会触达当前时间,需排除在 prerender shell 外(已有 Suspense)
await connection();
try {
primeRequestForwardHeaders(await buildServerForwardHeaders());
} catch {
/* ignore */
}
const { sort, page: pageSegment } = await params;
const sortKey = parseShortDramaSortSegment(sort);
if (sortKey) {
const parsed = parseShortDramaListPath(pageSegment);
if (parsed == null) notFound();
// 旧中文排序段 → 英文规范 URL
if (decodeSegment(sort) !== sortKey) {
redirect(shortDramaListHref(sortKey, parsed.page, parsed.themeSlug));
}
// 页码为 1 时去掉尾段 /1:/sort/1 → /sort,/sort/theme/1 → /sort/theme
if (parsed.page === 1) {
if (!parsed.themeSlug && pageSegment?.length === 1) {
redirect(shortDramaListHref(sortKey, 1));
}
if (parsed.themeSlug && pageSegment?.length === 2) {
redirect(shortDramaListHref(sortKey, 1, parsed.themeSlug));
}
}
const sortText = sortLabels[sortKey];
const list = await loadShortDramaList(sortKey, parsed.page, parsed.themeSlug);
const itemListUrls = list.items
.filter((item) => item.kind === "drama")
.slice(0, 24)
.map((item) => dramaHref(item.id, item.slug));
const siteSeo = await getHomeBaseSEOCached();
const activeTheme = list.themeSlug
? list.themes.find((item) => item.slug === list.themeSlug)
: undefined;
const themeName = activeTheme?.name?.trim() || "";
const listSeo = mergeSEO(activeTheme?.seoInfo, siteSeo.seo);
const listTitle =
listSeo.title ||
(themeName ? `短剧·${themeName}·${sortText}` : `短剧·${sortText}`);
const listDescription =
listSeo.description ||
(themeName
? `浏览海角社区短剧「${themeName}」题材,按${sortText}发现精彩作品。`
: `浏览海角社区短剧列表,按${sortText}发现精彩作品。`);
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: shortDramaListHref(sortKey, parsed.page, parsed.themeSlug),
title: listTitle,
description: listDescription,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
["短剧", shortDramaListHref(sortKey, 1)],
],
itemListUrls,
siteName: siteSeo.siteName,
})}
/>
<ShortDramaPage list={list} />
</>
);
}
if (!pageSegment?.length) {
const mapped = await loadPlayletDetailWithRecommend(decodeSegment(sort));
if (!mapped) notFound();
const detailPath = dramaDetailHref(mapped.detail);
const siteSeo = await getHomeBaseSEOCached();
return (
<>
<JsonLd
data={buildPlayletDetailJSONLD({
path: detailPath,
title: mapped.detail.title,
description: mapped.detail.desc,
cover: mapped.detail.cover,
totalEpisode: mapped.detail.totalEpisode,
onlineAt: mapped.detail.onlineAt,
tagNames: mapped.detail.tagNames,
themeNames: mapped.detail.themeNames,
anchor: mapped.detail.anchor,
seoInfo: mapped.detail.seoInfo,
siteName: siteSeo.siteName,
})}
/>
<DramaDetailPage
detail={mapped.detail}
related={mapped.related}
relatedAside={<RelatedAside items={mapped.related} />}
adIcons={<AdIconsSlot variant="pc" />}
promo={<AdBannersSlot variant="pc" />}
adGrid={<AdIconsSlot variant="h5" />}
bannerAds={<AdBannersSlot variant="h5" />}
/>
</>
);
}
if (pageSegment.length === 1) {
const play = await loadPlayletPlay(decodeSegment(sort), pageSegment[0]);
if (!play) notFound();
const detailPath = dramaDetailHref(play.detail);
const episodePath = playHref(play.detail.id, play.episode, play.detail.slug);
const siteSeo = await getHomeBaseSEOCached();
const dramaTitle = play.detail.title?.trim() || "短剧";
return (
<>
<JsonLd
data={buildPlayletPlayJSONLD({
path: detailPath,
playPath: episodePath,
episode: play.episode,
title: dramaTitle,
description: play.detail.desc,
cover: play.detail.cover,
totalEpisode: play.detail.totalEpisode,
onlineAt: play.detail.onlineAt,
tagNames: play.detail.tagNames,
themeNames: play.detail.themeNames,
anchor: play.detail.anchor,
seoInfo: play.detail.seoInfo,
siteName: siteSeo.siteName,
videoUrl: play.src,
})}
/>
<DramaPlayPage
detail={play.detail}
episode={play.episode}
src={play.src}
related={play.related}
relatedAside={<RelatedAside items={play.related} />}
adIcons={<AdIconsSlot variant="pc" />}
promo={<AdBannersSlot variant="pc" />}
/>
</>
);
}
notFound();
}
export default function Page({ params }: { params: Params }) {
return (
<Suspense fallback={<PageLoading />}>
<ShortDramaRouteContent params={params} />
</Suspense>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { PageLoading } from "@/components/common/page-loading";
export default function Loading() {
return <PageLoading className="flex-1 bg-[#07060E]" />;
}
+179
View File
@@ -0,0 +1,179 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { JsonLd } from "@/components/seo/json-ld";
import { dramaHref } from "@/components/short-drama/data";
import { loadTagPage, tagCanonicalKey } from "@/components/tag/data";
import { parseTagKind, type TagContentKind } from "@/components/tag/href";
import { tagListHref } from "@/components/tag/route";
import { TagPage } from "@/components/tag/tag-page";
import { videoHref } from "@/components/video/video-card";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
type Params = Promise<{ slug?: string[] }>;
type SearchParams = Promise<{ page?: string | string[]; kind?: string | string[] }>;
function decodeSeg(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function first(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
function parsePageNum(raw: string | undefined): number | null {
if (raw == null) return 1;
const num = Number(raw);
if (!Number.isInteger(num) || num < 1) return null;
return num;
}
/**
* 路径约定:
* - /tag/{playlet|video}/{slug|id}
* - /tag/{playlet|video}/{slug|id}/{page}
* 兼容旧链(会 301 到 slug 优先路径):
* - /tag/{key}、/tag/{key}/{page}
* - /tag/{key}?kind=video&page=n
*/
function parseTagSlug(
slug: string[] | undefined,
queryKind?: string | string[],
queryPage?: string | string[],
): {
kind: TagContentKind | undefined;
key: string;
page: number;
legacy: boolean;
} | null {
const segs = (slug ?? []).map(decodeSeg).filter(Boolean);
const qKind = parseTagKind(queryKind);
const qPage = Number(first(queryPage));
if (!segs.length) return null;
const headKind = parseTagKind(segs[0]);
if (headKind) {
if (segs.length < 2 || segs.length > 3) return null;
const key = segs[1]?.trim();
if (!key) return null;
const page = parsePageNum(segs[2]);
if (page == null) return null;
return { kind: headKind, key, page, legacy: false };
}
// 旧路径 /tag/{key}[/page]
if (segs.length > 2) return null;
const key = segs[0]?.trim();
if (!key) return null;
const pathPage = parsePageNum(segs[1]);
if (pathPage == null) return null;
const page =
Number.isInteger(qPage) && qPage > 1 && segs.length === 1 ? Math.floor(qPage) : pathPage;
return { kind: qKind, key, page, legacy: true };
}
export async function generateMetadata({
params,
searchParams,
}: {
params: Params;
searchParams: SearchParams;
}): Promise<Metadata> {
const query = await searchParams;
const parsed = parseTagSlug((await params).slug, query.kind, query.page);
if (!parsed) {
return resolvePageMetadata({ seoInfo: { title: "标签", keyworld: ["海角社区", "标签"] } });
}
const { key, page, kind: preferredKind } = parsed;
try {
const data = await loadTagPage(key, 1, preferredKind);
const pathKey = tagCanonicalKey(data);
const unit = data.kind === "video" ? "视频" : "短剧";
return resolvePageMetadata({
seoInfo: {
title: `${data.name}标签`,
description: `浏览海角社区「${data.name}」标签相关${unit}。`,
keyworld: ["海角社区", "标签", data.name, unit],
},
page,
canonical: tagListHref(pathKey, page, data.kind),
});
} catch {
return resolvePageMetadata({
seoInfo: { title: key, keyworld: ["海角社区", "标签", key] },
page,
canonical: tagListHref(key, page, preferredKind ?? "playlet"),
});
}
}
export default async function Page({
params,
searchParams,
}: {
params: Params;
searchParams: SearchParams;
}) {
const query = await searchParams;
const parsed = parseTagSlug((await params).slug, query.kind, query.page);
if (!parsed) notFound();
const { key, page, kind: preferredKind, legacy } = parsed;
// 旧链接统一 301 到 /tag/{kind}/{slug|id}[/{page}]
if (legacy) {
const data = await loadTagPage(key, 1, preferredKind).catch(() => null);
const kind = data?.kind ?? preferredKind ?? "playlet";
const pathKey = data ? tagCanonicalKey(data) : key;
redirect(tagListHref(pathKey, page, kind));
}
const [data, siteSeo] = await Promise.all([
loadTagPage(key, page, preferredKind),
getHomeBaseSEOCached(),
]);
const pathKey = tagCanonicalKey(data);
// 规范到优先 slug:纯 id / name 访问且已有 slug 时跳转
if (pathKey && key !== pathKey) {
redirect(tagListHref(pathKey, page, data.kind));
}
// /tag/{kind}/{key}/1 → /tag/{kind}/{key}
if (page === 1 && (await params).slug?.length === 3 && preferredKind) {
redirect(tagListHref(pathKey, 1, preferredKind));
}
const unit = data.kind === "video" ? "视频" : "短剧";
const itemListUrls = data.items
.filter((item) => item.kind === "drama")
.slice(0, 24)
.map((item) => (data.kind === "video" ? videoHref(item.id, item.slug) : dramaHref(item.id, item.slug)));
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: tagListHref(pathKey, page, data.kind),
title: `${data.name}标签`,
description: `浏览海角社区「${data.name}」标签相关${unit}。`,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
[data.name, tagListHref(pathKey, 1, data.kind)],
],
itemListUrls,
siteName: siteSeo.siteName,
})}
/>
<TagPage data={data} />
</>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { JsonLd } from "@/components/seo/json-ld";
import { TermsPage } from "@/components/terms/terms-page";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
seoInfo: {
title: "用户协议",
description: "查看海角社区用户服务条款及平台使用规则。",
},
canonical: "/terms",
});
}
export default async function Page() {
const siteSeo = await getHomeBaseSEOCached();
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/terms",
title: "用户协议",
description: "查看海角社区用户服务条款及平台使用规则。",
type: "WebPage",
breadcrumbs: [
["首页", "/"],
["用户协议", "/terms"],
],
siteName: siteSeo.siteName,
})}
/>
<TermsPage />
</>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { PageLoading } from "@/components/common/page-loading";
export default function Loading() {
return <PageLoading className="flex-1 bg-[#07060E]" />;
}
+245
View File
@@ -0,0 +1,245 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { JsonLd } from "@/components/seo/json-ld";
import { dramaHref } from "@/components/short-drama/data";
import {
isTopicVideo,
loadTopicPage,
unwrapTopicListItem,
} from "@/components/topic/data";
import { TopicPage } from "@/components/topic/topic-page";
import {
dataTypeFromTopicKind,
parseTopicSortSegment,
parseTopicSortType,
topicKindFromDataType,
topicListHref,
topicSortLabel,
topicSortPath,
type TopicSortType,
} from "@/components/topic/route";
import { videoHref } from "@/components/video/video-card";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
type Params = Promise<{ slug?: string[] }>;
type SearchParams = Promise<{ dataType?: string | string[]; sortType?: string | string[] }>;
function decodeSegment(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function parsePageNumber(value: string): number | null {
const num = Number(value);
if (!Number.isInteger(num) || num < 1) return null;
return num;
}
type TopicPath =
| { dataType: 1 | 2; key: string; page: number; sortType: TopicSortType }
| { redirectTo: string };
/**
* 解析专题路径:
* - /topic/playlet|{video}/{key}/{sort}[/{page}]
* - 兼容 /topic/{kind}/{key}[/{page}]、旧 /topic/{key}?dataType=、?sortType=
*/
function parseTopicSlug(
slug: string[] | undefined,
searchDataType?: string | string[] | null,
searchSortType?: string | string[] | null,
): TopicPath | null {
const parts = (slug ?? []).map(decodeSegment).filter(Boolean);
const rawQuery = Array.isArray(searchDataType) ? searchDataType[0] : searchDataType;
const queryType = rawQuery === "2" || rawQuery === "video" ? 2 : rawQuery === "1" || rawQuery === "playlet" ? 1 : null;
const querySort = parseTopicSortType(searchSortType);
if (!parts.length) return null;
const kindType = dataTypeFromTopicKind(parts[0]);
if (kindType != null) {
const key = (parts[1] || "").trim();
if (!key) return null;
if (parts.length > 4) return null;
let sortType: TopicSortType = 1;
let page = 1;
let needsCanonical = false;
if (parts.length === 2) {
// /topic/kind/key → 默认编辑排序
needsCanonical = true;
} else if (parts.length === 3) {
const third = parts[2];
const asPage = parsePageNumber(third);
// 路径排序段用英文(兼容旧中文);纯数字优先当页码,避免 /topic/.../2 被误判为 sortType
if (asPage != null) {
page = asPage;
needsCanonical = true;
} else {
const asSort = parseTopicSortSegment(third);
if (asSort == null) return null;
sortType = asSort;
// 旧中文 / 数字段 → 英文规范 URL
if (third !== topicSortPath(asSort)) needsCanonical = true;
}
} else if (parts.length === 4) {
const asSort = parseTopicSortSegment(parts[2]);
const asPage = parsePageNumber(parts[3]);
if (asSort == null || asPage == null) return null;
sortType = asSort;
page = asPage;
if (parts[2] !== topicSortPath(asSort)) needsCanonical = true;
}
if (querySort != null && querySort !== sortType) {
return { redirectTo: topicListHref(key, { dataType: kindType, page, sortType: querySort }) };
}
if (querySort != null) {
// 去掉 ?sortType=
return { redirectTo: topicListHref(key, { dataType: kindType, page, sortType }) };
}
// /topic/1/xxx → /topic/playlet/xxx/...
if (parts[0] !== topicKindFromDataType(kindType)) {
return { redirectTo: topicListHref(key, { dataType: kindType, page, sortType }) };
}
if (page === 1 && parts.length === 4) {
return { redirectTo: topicListHref(key, { dataType: kindType, page: 1, sortType }) };
}
if (needsCanonical) {
return { redirectTo: topicListHref(key, { dataType: kindType, page, sortType }) };
}
return { dataType: kindType, key, page, sortType };
}
// 旧路径 /topic/{key}[/{page}|{sort}][/{page}]
const key = parts[0].trim();
if (!key) return null;
if (parts.length > 3) return null;
let sortType: TopicSortType = querySort ?? 1;
let page = 1;
if (parts.length === 2) {
const asSort = parseTopicSortSegment(parts[1]);
const asPage = parsePageNumber(parts[1]);
if (asSort != null) sortType = asSort;
else if (asPage != null) page = asPage;
else return null;
} else if (parts.length === 3) {
const asSort = parseTopicSortSegment(parts[1]);
const asPage = parsePageNumber(parts[2]);
if (asSort == null || asPage == null) return null;
sortType = asSort;
page = asPage;
}
const dataType = queryType ?? 1;
return { redirectTo: topicListHref(key, { dataType, page, sortType }) };
}
export async function generateMetadata({
params,
searchParams,
}: {
params: Params;
searchParams: SearchParams;
}): Promise<Metadata> {
const { slug } = await params;
const query = await searchParams;
const parsed = parseTopicSlug(slug, query.dataType, query.sortType);
if (!parsed || "redirectTo" in parsed) {
return resolvePageMetadata({ fallbackTitle: "专题" });
}
const canonical = topicListHref(parsed.key, {
dataType: parsed.dataType,
page: parsed.page,
sortType: parsed.sortType,
});
try {
const data = await loadTopicPage(parsed.key, parsed.dataType, 1, parsed.sortType);
const name = (data.name || "").trim() || "专题";
return resolvePageMetadata({
seoInfo: data.seoInfo ?? {
title: name,
description: (data.desc || "").trim() || `浏览海角社区专题「${name}」。`,
keyworld: ["海角社区", name],
},
page: parsed.page,
canonical,
});
} catch {
return resolvePageMetadata({ fallbackTitle: "专题", page: parsed.page, canonical });
}
}
export default async function Page({
params,
searchParams,
}: {
params: Params;
searchParams: SearchParams;
}) {
const { slug } = await params;
const query = await searchParams;
const parsed = parseTopicSlug(slug, query.dataType, query.sortType);
if (!parsed) notFound();
if ("redirectTo" in parsed) redirect(parsed.redirectTo);
const { dataType, key: topicKey, page, sortType } = parsed;
const [data, siteSeo] = await Promise.all([
loadTopicPage(topicKey, dataType, page, sortType),
getHomeBaseSEOCached(),
]);
const resolvedDataType: 1 | 2 = isTopicVideo(data.dataType) ? 2 : dataType;
const pathKey = (data.slug || "").trim() || (data.id != null ? String(data.id) : topicKey);
const name = (data.name || "").trim() || "专题";
const desc = (data.desc || "").trim();
if (resolvedDataType !== dataType) {
redirect(topicListHref(pathKey, { dataType: resolvedDataType, page, sortType }));
}
// 规范到 slug 路径(含排序段)
const canonical = topicListHref(pathKey, { dataType: resolvedDataType, page, sortType });
const currentPath = topicListHref(topicKey, { dataType: resolvedDataType, page, sortType });
if (canonical !== currentPath) {
redirect(canonical);
}
const itemListUrls = (data.list ?? [])
.map((item) => unwrapTopicListItem(item, resolvedDataType))
.filter((item): item is NonNullable<typeof item> => Boolean(item?.id))
.slice(0, 24)
.map((item) =>
resolvedDataType === 2
? videoHref(String(item.id), "slug" in item ? item.slug : undefined)
: dramaHref(item.id, "slug" in item ? item.slug : undefined),
);
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: topicListHref(pathKey, { dataType: resolvedDataType, page, sortType }),
title: `${name} · ${topicSortLabel(sortType)}`,
description: desc || `浏览海角社区专题「${name}」。`,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
[name, topicListHref(pathKey, { dataType: resolvedDataType, page: 1, sortType: 1 })],
],
itemListUrls,
siteName: siteSeo.siteName,
})}
/>
<TopicPage data={data} />
</>
);
}
+300
View File
@@ -0,0 +1,300 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { connection } from "next/server";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { sortLabels } from "@/components/short-drama/data";
import {
buildVideoList,
VIDEO_PAGE_SIZE,
videoSorts,
videoSortType,
type VideoListResult,
type VideoSort,
} from "@/components/video/data";
import {
parseVideoListPath,
parseVideoSortSegment,
videoListHref,
} from "@/components/video/route";
import { VideoPage } from "@/components/video/video-page";
import {
mapVideoInfo,
mapVideoRecommendList,
VIDEO_RECOMMEND_DISPLAY_COUNT,
type VideoPlayView,
} from "@/components/video-play/data";
import { VideoRelatedAside } from "@/components/video-play/related-aside";
import { VideoPlayPage } from "@/components/video-play/video-play-page";
import { JsonLd } from "@/components/seo/json-ld";
import {
getVideoCategoryListCached,
getVideoInfoCached,
getVideoListCached,
getVideoRecommendListCached,
} from "@/lib/api/video";
import type { ShortDramaItem } from "@/components/short-drama/data";
import type { VideoCategoryListItem } from "@/lib/api/types";
import { buildServerForwardHeaders } from "@/lib/api/client-headers";
import { primeRequestForwardHeaders } from "@/lib/api/request-context";
import {
buildVideoPlayJSONLD,
buildWebPageJSONLD,
getHomeBaseSEOCached,
mergeSEO,
resolveContentMetadata,
resolvePageMetadata,
} from "@/lib/seo";
import { videoHref } from "@/components/video/video-card";
import { AdBannersSlot, AdIconsSlot } from "@/components/common/ads/slots";
type Params = Promise<{ sort: string; page?: string[] }>;
export function generateStaticParams() {
return videoSorts.map((sort) => ({ sort }));
}
function decodeSegment(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function videoLookup(idOrSlug: string): { id?: string; slug?: string } {
if (/^\d+$/.test(idOrSlug)) return { id: idOrSlug };
return { slug: idOrSlug };
}
/** 热门推荐:GET /api/app/video/recommend/list(sortList=1 推荐) */
async function loadVideoRecommend(excludeId?: number | string | null): Promise<ShortDramaItem[]> {
try {
const data = await getVideoRecommendListCached({
pageNum: 1,
// 多取几条,排除当前视频后仍能凑满展示条数
pageSize: VIDEO_RECOMMEND_DISPLAY_COUNT + 4,
sortList: 1,
});
return mapVideoRecommendList(data, { excludeId, limit: VIDEO_RECOMMEND_DISPLAY_COUNT });
} catch (error) {
console.error("[video/recommend]", error);
return [];
}
}
async function loadVideoDetail(idOrSlug: string): Promise<VideoPlayView | null> {
try {
const data = await getVideoInfoCached(videoLookup(idOrSlug));
return mapVideoInfo(data);
} catch (error) {
console.error("[video/info]", error);
return null;
}
}
async function loadVideoDetailWithRecommend(idOrSlug: string): Promise<VideoPlayView | null> {
const mapped = await loadVideoDetail(idOrSlug);
if (!mapped) return null;
const related = await loadVideoRecommend(mapped.detail.id);
return { ...mapped, related: related.length ? related : mapped.related };
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { sort, page } = await params;
const sortKey = parseVideoSortSegment(sort);
if (sortKey) {
const sortText = sortLabels[sortKey];
const listPath = parseVideoListPath(page);
const pageNum = listPath?.page ?? 1;
const categorySlug = listPath?.categorySlug;
// 列表 TDK:有分类时用 category/list 的 seoInfo,缺字段再回退 home/base
const categories = await loadVideoCategories();
const category = categorySlug
? categories.find((item) => item.slug === categorySlug)
: undefined;
const categoryName = category?.name?.trim() || category?.title?.trim() || "";
return resolvePageMetadata({
seoInfo: category?.seoInfo,
fallbackTitle: categoryName ? `视频·${categoryName}·${sortText}` : `视频·${sortText}`,
fallbackDescription: categoryName
? `浏览海角社区视频「${categoryName}」分类,按${sortText}发现精彩内容。`
: `浏览海角社区视频列表,按${sortText}发现精彩内容。`,
fallbackKeywords: ["海角社区", "视频", categoryName, sortText].filter(Boolean),
page: pageNum,
canonical: videoListHref(sortKey, pageNum, categorySlug),
});
}
if (page?.length) return resolvePageMetadata({ fallbackTitle: "海角社区" });
// 视频播放页:TDK 用视频详情(video/info)
const mapped = await loadVideoDetail(decodeSegment(sort));
const detail = mapped?.detail;
if (!detail) return resolveContentMetadata({ fallbackTitle: "海角社区" });
const tagNames = (detail.tags ?? []).map((tag) => tag.name?.trim() || "").filter(Boolean);
return resolveContentMetadata({
fallbackTitle: detail.title,
fallbackDescription: detail.desc || (detail.title ? `在线观看《${detail.title}》` : undefined),
fallbackKeywords: ["海角社区", "视频", detail.title || "", ...tagNames].filter(Boolean),
ogType: "video.episode",
canonical: videoHref(String(detail.id), detail.slug),
});
}
async function loadVideoCategories(): Promise<VideoCategoryListItem[]> {
try {
const data = await getVideoCategoryListCached();
return data.list ?? [];
} catch (error) {
console.error("[video/category]", error);
return [];
}
}
async function loadVideoList(
sort: VideoSort,
page: number,
categorySlug?: string,
): Promise<VideoListResult> {
const categories = await loadVideoCategories();
const active = categorySlug
? categories.find((item) => item.slug === categorySlug)
: undefined;
const resolvedSlug = active?.slug;
const categoryId = active?.id;
try {
const data = await getVideoListCached({
pageNum: page,
pageSize: VIDEO_PAGE_SIZE,
sortType: videoSortType(sort),
categoryId,
});
return buildVideoList(data, sort, page, VIDEO_PAGE_SIZE, {
categories,
categorySlug: resolvedSlug,
});
} catch (error) {
console.error("[video]", error);
return buildVideoList(null, sort, page, VIDEO_PAGE_SIZE, {
categories,
categorySlug: resolvedSlug,
});
}
}
async function VideoRouteContent({ params }: { params: Params }) {
// 请求期渲染:fetch 超时等会触达当前时间,需排除在 prerender shell 外(已有 Suspense)
await connection();
try {
primeRequestForwardHeaders(await buildServerForwardHeaders());
} catch {
/* ignore */
}
const { sort, page: pageSegment } = await params;
const sortKey = parseVideoSortSegment(sort);
if (sortKey) {
const parsed = parseVideoListPath(pageSegment);
if (parsed == null) notFound();
// 旧中文排序段 → 英文规范 URL
if (decodeSegment(sort) !== sortKey) {
redirect(videoListHref(sortKey, parsed.page, parsed.categorySlug));
}
// 页码为 1 时去掉尾段 /1:/sort/1 → /sort,/sort/category/1 → /sort/category
if (parsed.page === 1) {
if (!parsed.categorySlug && pageSegment?.length === 1) {
redirect(videoListHref(sortKey, 1));
}
if (parsed.categorySlug && pageSegment?.length === 2) {
redirect(videoListHref(sortKey, 1, parsed.categorySlug));
}
}
const sortText = sortLabels[sortKey];
const list = await loadVideoList(sortKey, parsed.page, parsed.categorySlug);
const itemListUrls = list.items
.filter((item) => item.kind === "drama")
.slice(0, 24)
.map((item) => videoHref(item.id, item.slug));
const siteSeo = await getHomeBaseSEOCached();
const activeCategory = list.categorySlug
? list.categories.find((item) => item.slug === list.categorySlug)
: undefined;
const categoryName = activeCategory?.name?.trim() || activeCategory?.title?.trim() || "";
const listSeo = mergeSEO(activeCategory?.seoInfo, siteSeo.seo);
const listTitle =
listSeo.title ||
(categoryName ? `视频·${categoryName}·${sortText}` : `视频·${sortText}`);
const listDescription =
listSeo.description ||
(categoryName
? `浏览海角社区视频「${categoryName}」分类,按${sortText}发现精彩内容。`
: `浏览海角社区视频列表,按${sortText}发现精彩内容。`);
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: videoListHref(sortKey, parsed.page, parsed.categorySlug),
title: listTitle,
description: listDescription,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
["视频", videoListHref(sortKey, 1)],
],
itemListUrls,
siteName: siteSeo.siteName,
})}
/>
<VideoPage list={list} />
</>
);
}
if (pageSegment?.length) notFound();
const mapped = await loadVideoDetailWithRecommend(decodeSegment(sort));
if (!mapped) notFound();
const playPath = videoHref(String(mapped.detail.id), mapped.detail.slug);
const siteSeo = await getHomeBaseSEOCached();
return (
<>
<JsonLd
data={buildVideoPlayJSONLD({
path: playPath,
title: mapped.detail.title,
description: mapped.detail.desc,
cover: mapped.detail.coverImg || mapped.detail.video?.cover,
addedTime: mapped.detail.addedTime,
videoUrl: mapped.src,
durationSec: mapped.detail.video?.videoTime,
width: mapped.detail.video?.width,
height: mapped.detail.video?.height,
watchNum: mapped.detail.watchNum,
siteName: siteSeo.siteName,
})}
/>
<VideoPlayPage
detail={mapped.detail}
src={mapped.src}
related={mapped.related}
relatedAside={<VideoRelatedAside items={mapped.related} />}
adIcons={<AdIconsSlot variant="pc" labels={false} />}
promo={<AdBannersSlot variant="pc" />}
adGrid={<AdIconsSlot variant="h5" />}
bannerAds={<AdBannersSlot variant="h5" />}
/>
</>
);
}
export default function Page({ params }: { params: Params }) {
return (
<Suspense fallback={<PageLoading />}>
<VideoRouteContent params={params} />
</Suspense>
);
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { cache } from "react";
import { JsonLd } from "@/components/seo/json-ld";
import { WayHomePage } from "@/components/way-home/way-home-page";
import { getHomeBaseCached } from "@/lib/api/home";
import type { AnnouncementInfo, ConfigInfo } from "@/lib/api/types";
import { buildWebPageJSONLD, resolvePageMetadata } from "@/lib/seo";
const loadWayHomeBase = cache(async (): Promise<{
announcement?: AnnouncementInfo;
config?: ConfigInfo;
}> => {
try {
const base = await getHomeBaseCached();
return { announcement: base.announcement, config: base.config };
} catch (error) {
console.warn("[way-home] home/base failed", error);
return {};
}
});
export async function generateMetadata(): Promise<Metadata> {
const { config } = await loadWayHomeBase();
const siteName = config?.webSiteName?.trim() || config?.dhSiteName?.trim() || "海角社区";
return resolvePageMetadata({
siteName,
seoInfo: {
title: `回家的路 - ${siteName}官方最新地址发布页`,
description: `${siteName}官方最新地址发布页,实时同步国内可用线路、永久入口与防失联联系方式。`,
keyworld: [siteName, "回家的路", "最新地址", "永久入口", "备用地址"],
},
canonical: "/way-home",
});
}
export default async function Page() {
const { announcement, config } = await loadWayHomeBase();
const siteName = config?.webSiteName?.trim() || config?.dhSiteName?.trim() || "海角社区";
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/way-home",
title: `回家的路 - ${siteName}官方最新地址发布页`,
description: `${siteName}官方最新地址发布页,实时同步国内可用线路、永久入口与防失联联系方式。`,
type: "WebPage",
breadcrumbs: [
["首页", "/"],
["回家的路", "/way-home"],
],
siteName,
})}
/>
<WayHomePage announcement={announcement} config={config} />
</>
);
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { PageLoading } from "@/components/common/page-loading";
import { JsonLd } from "@/components/seo/json-ld";
import { WelfarePage } from "@/components/welfare/welfare-page";
import { buildWelfareCategories, type WelfareCategory } from "@/components/welfare/data";
import { getAppListCached } from "@/lib/api/applist";
import { buildWebPageJSONLD, getHomeBaseSEOCached, resolvePageMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
seoInfo: {
title: "福利应用",
description: "海角社区福利应用与精选推荐。",
keyworld: ["海角社区", "福利应用"],
},
canonical: "/welfare",
});
}
async function WelfareContent() {
let categories: WelfareCategory[] = [];
try {
const data = await getAppListCached(0);
categories = buildWelfareCategories(data);
} catch (error) {
console.error("[welfare]", error);
}
const siteSeo = await getHomeBaseSEOCached();
return (
<>
<JsonLd
data={buildWebPageJSONLD({
path: "/welfare",
title: "福利应用",
description: "海角社区福利应用与精选推荐。",
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
["福利应用", "/welfare"],
],
siteName: siteSeo.siteName,
})}
/>
<WelfarePage categories={categories} />
</>
);
}
export default function Page() {
return (
<Suspense fallback={<PageLoading />}>
<WelfareContent />
</Suspense>
);
}