@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user