整体UI调整

This commit is contained in:
谢宇宁
2026-10-26 19:51:59 +07:00
parent c8895f407c
commit eeb9986923
112 changed files with 1507 additions and 4255 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ pnpm dev:test # test.env
| `config/test.env` | 测试构建 / 测试服 |
| `config/production.env` | 正式构建 / 正式服 |
关键变量示例:`API_PROXY_TARGET` / `NEXT_PUBLIC_API_BASE_URL`、`PORT`、`NEXT_PUBLIC_SITE_URL`、`NEXT_PUBLIC_GTAG_ID`、`NEXT_PUBLIC_TRACKER_APP_ID`。
关键变量示例:`API_PROXY_TARGET` / `NEXT_PUBLIC_API_BASE_URL`、`PORT`、`NEXT_PUBLIC_SITE_URL`。
## 打包与部署
+2 -4
View File
@@ -2,9 +2,7 @@ APP_ENV=development
# 浏览器走同源 /api/*(配合 next.config rewrites)
NEXT_PUBLIC_API_BASE_URL=
# 上游地址:rewrites + 服务端直连
API_PROXY_TARGET=https://mdcom-wz.80sygtm.com
API_PROXY_TARGET=https://webhghlapi.mfcg.me
PORT=26122
NEXT_PUBLIC_TRACKER_APP_ID=JHA-1046
NEXT_PUBLIC_GTAG_ID=G-748CPNEVP5
NEXT_PUBLIC_SITE_URL=https://haijiaosq.net
NEXT_PUBLIC_SITE_URL=https://huangguohl.com
NEXT_PUBLIC_APP_VERSION=1.0.0
+2 -4
View File
@@ -1,8 +1,6 @@
APP_ENV=production
NEXT_PUBLIC_API_BASE_URL=
API_PROXY_TARGET=http://139.162.44.29:26119
API_PROXY_TARGET=https://webhghlapi.mfcg.me
PORT=3000
NEXT_PUBLIC_TRACKER_APP_ID=JHA-1046
NEXT_PUBLIC_GTAG_ID=G-748CPNEVP5
NEXT_PUBLIC_SITE_URL=https://denv08sep7brv.cloudfront.net
NEXT_PUBLIC_SITE_URL=https://huangguohl.com
NEXT_PUBLIC_APP_VERSION=1.0.0
+2 -4
View File
@@ -1,8 +1,6 @@
APP_ENV=test
NEXT_PUBLIC_API_BASE_URL=
API_PROXY_TARGET=https://mdcom-wz.80sygtm.com
API_PROXY_TARGET=https://webhghlapi.mfcg.me
PORT=26122
NEXT_PUBLIC_TRACKER_APP_ID=JHA-1046
NEXT_PUBLIC_GTAG_ID=G-748CPNEVP5
NEXT_PUBLIC_SITE_URL=https://haijiaosq.net
NEXT_PUBLIC_SITE_URL=https://huangguohl.com
NEXT_PUBLIC_APP_VERSION=1.0.0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 117 KiB

+1 -18
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 434 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 164 KiB

-6
View File
@@ -1,6 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.1631 4L17.9639 7.80081L7.80081 17.9639L4.00357 17.9603L4 14.1631L14.1631 4Z" stroke="white" stroke-width="1.5" stroke-linecap="square"/>
<path d="M14.7239 10.207L11.7377 7.22084" stroke="white" stroke-width="1.5" stroke-linecap="square"/>
<path d="M17.4989 20.3018H17.5089" stroke="#E5365C" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M17.4219 17.4603C17.8468 17.4591 18.259 17.3278 18.5925 17.0874C18.926 16.847 19.1615 16.5115 19.2613 16.1345C19.3611 15.7575 19.3195 15.3609 19.1431 15.0081C18.9667 14.6552 18.6657 14.3666 18.2882 14.1884C17.9111 14.0121 17.4798 13.9574 17.0643 14.0333C16.6489 14.1092 16.2737 14.3111 16 14.6063" stroke="#E5365C" stroke-width="1.38889" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 873 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18 5.18754L12.7463 0L0 12.5857L0.00468579 17.7687H0.0103087V17.9991L17.4049 18V16.612H6.42984L18 5.18754Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 328 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.33337 8.01067C1.33337 4.49873 4.14004 1.33398 8.01337 1.33398C11.8 1.33398 14.6667 4.43864 14.6667 7.99064C14.6667 12.1101 11.3067 14.6673 8.00004 14.6673C6.90671 14.6673 5.69337 14.3735 4.72004 13.7993C4.38004 13.5924 4.09337 13.4388 3.72671 13.559L2.38004 13.9596C2.04004 14.0664 1.73337 13.7993 1.83337 13.4388L2.28004 11.9432C2.35337 11.7363 2.34004 11.5159 2.23337 11.3423C1.66004 10.2874 1.33337 9.13235 1.33337 8.01067ZM7.13337 8.01067C7.13337 8.48471 7.51337 8.86528 7.98671 8.87196C8.46004 8.87196 8.84004 8.48471 8.84004 8.01734C8.84004 7.5433 8.46004 7.16273 7.98671 7.16273C7.52004 7.15605 7.13337 7.5433 7.13337 8.01067ZM10.2067 8.01734C10.2067 8.48471 10.5867 8.87196 11.06 8.87196C11.5334 8.87196 11.9134 8.48471 11.9134 8.01734C11.9134 7.5433 11.5334 7.16273 11.06 7.16273C10.5867 7.16273 10.2067 7.5433 10.2067 8.01734ZM4.91337 8.87196C4.44671 8.87196 4.06004 8.48471 4.06004 8.01734C4.06004 7.5433 4.44004 7.16273 4.91337 7.16273C5.38671 7.16273 5.76671 7.5433 5.76671 8.01734C5.76671 8.48471 5.38671 8.86528 4.91337 8.87196Z" fill="#EBE5F2"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long
+1 -34
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 35 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+2 -2
View File
@@ -8,7 +8,7 @@ export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
seoInfo: {
title: "关于我们",
description: "了解海角社区的品牌、产品与服务。",
description: "了解黄果黑料的品牌、产品与服务。",
},
canonical: "/about",
});
@@ -22,7 +22,7 @@ export default async function Page() {
data={buildWebPageJSONLD({
path: "/about",
title: "关于我们",
description: "了解海角社区的品牌、产品与服务。",
description: "了解黄果黑料的品牌、产品与服务。",
type: "AboutPage",
breadcrumbs: [
["首页", "/"],
-31
View File
@@ -1,31 +0,0 @@
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} />
</>
);
}
-58
View File
@@ -1,58 +0,0 @@
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>
);
}
+4 -27
View File
@@ -5,7 +5,6 @@ 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";
@@ -40,20 +39,17 @@ const pageScaleScript = `(function () {
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: "海角社区",
default: "黄果黑料",
template: "%s",
},
description: "发现好故事,开启追剧时光",
applicationName: "海角社区",
applicationName: "黄果黑料",
verification: {
google: "0Bby47n1AEIB3W8wtLZ-oFQ3l8xRqRT78oA0JVRJ5pM",
yandex: "69b55a6aa9ee9a5f",
@@ -64,8 +60,8 @@ export async function generateMetadata(): Promise<Metadata> {
openGraph: {
type: "website",
locale: "zh_CN",
siteName: "海角社区",
images: [{ url: OG_DEFAULT_IMAGE, alt: "海角社区" }],
siteName: "黄果黑料",
images: [{ url: OG_DEFAULT_IMAGE, alt: "黄果黑料" }],
},
twitter: {
card: "summary_large_image",
@@ -93,29 +89,10 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
// 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>
+3 -3
View File
@@ -30,8 +30,8 @@ export async function generateMetadata({ params }: { params: Params }): Promise<
return resolvePageMetadata({
seoInfo: {
title: data.title,
description: data.subtitle || `浏览海角社区「${data.title}」更多精选内容。`,
keyworld: ["海角社区", data.title],
description: data.subtitle || `浏览黄果黑料「${data.title}」更多精选内容。`,
keyworld: ["黄果黑料", data.title],
},
page: pageNum,
canonical: moreListHref(pathKey, pageNum),
@@ -72,7 +72,7 @@ export default async function Page({ params }: { params: Params }) {
data={buildWebPageJSONLD({
path: moreListHref(pathKey, page),
title: data.title,
description: data.subtitle || `浏览海角社区「${data.title}」更多精选内容。`,
description: data.subtitle || `浏览黄果黑料「${data.title}」更多精选内容。`,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
+1 -1
View File
@@ -9,7 +9,7 @@ import { moreListHref } from "@/components/more/route";
/** 无 id 的空壳页;正式内容在 /more/{id},本页不索引 */
export const metadata: Metadata = {
title: "更多内容 - 海角社区",
title: "更多内容 - 黄果黑料",
robots: { index: false, follow: false },
};
+6 -6
View File
@@ -4,7 +4,7 @@ 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";
import { buildWebPageJSONLD, mergeSEO, resolvePageMetadata, SITE_NAME } from "@/lib/seo";
async function loadNavBase(): Promise<{
config?: ConfigInfo;
@@ -31,17 +31,17 @@ export async function generateMetadata(): Promise<Metadata> {
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[],
siteName: SITE_NAME,
fallbackTitle: `${SITE_NAME} · 官方导航`,
fallbackDescription: `${SITE_NAME}官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。`,
fallbackKeywords: [SITE_NAME, "官方导航", "最新地址"],
canonical: "/nav",
});
}
export default async function Page() {
const { config, bottonTranslations } = await loadNavBase();
const siteName = config?.dhSiteName?.trim() || config?.webSiteName?.trim() || "海角社区";
const siteName = SITE_NAME;
const seo = mergeSEO(
{ title: config?.dhTitle, description: config?.dhDescription, keyworld: config?.dhKeyworld },
{ title: config?.webTitle, description: config?.webDescription, keyworld: config?.webKeyworld },
+1 -1
View File
@@ -5,7 +5,7 @@ import { GoBackButton } from "@/components/not-found/go-back-button";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
export const metadata: Metadata = {
title: "页面不存在 - 海角社区",
title: "页面不存在 - 黄果黑料",
robots: { index: false, follow: false },
};
+5 -5
View File
@@ -48,9 +48,9 @@ export async function generateMetadata(): Promise<Metadata> {
// seoInfo → home/base 公共 web* → 代码兜底
return resolvePageMetadata({
seoInfo: home?.seoInfo,
fallbackTitle: "海角社区 - 热门短剧在线看",
fallbackDescription: "海角社区汇集热门短剧、最近上新、排行榜与精选专题推荐,支持高清在线播放。",
fallbackKeywords: ["海角社区", "短剧", "在线短剧", "热门短剧"],
fallbackTitle: "黄果黑料 - 热门短剧在线看",
fallbackDescription: "黄果黑料汇集热门短剧、最近上新、排行榜与精选专题推荐,支持高清在线播放。",
fallbackKeywords: ["黄果黑料", "短剧", "在线短剧", "热门短剧"],
canonical: "/",
});
}
@@ -64,9 +64,9 @@ export default async function Home() {
: null;
const seo = mergeSEO(home?.seoInfo, siteSeo.seo);
const homeTitle = seo.title || "海角社区 - 热门短剧在线看";
const homeTitle = seo.title || "黄果黑料 - 热门短剧在线看";
const homeDescription =
seo.description || "海角社区汇集热门短剧、最近上新、排行榜与精选专题推荐,支持高清在线播放。";
seo.description || "黄果黑料汇集热门短剧、最近上新、排行榜与精选专题推荐,支持高清在线播放。";
const itemListUrls =
home?.homeBannerData
?.filter((item): item is PlayletData & { id: number } => Boolean(item?.id))
+2 -2
View File
@@ -8,7 +8,7 @@ export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
seoInfo: {
title: "隐私政策",
description: "查看海角社区隐私政策与个人信息保护说明。",
description: "查看黄果黑料隐私政策与个人信息保护说明。",
},
canonical: "/privacy",
});
@@ -22,7 +22,7 @@ export default async function Page() {
data={buildWebPageJSONLD({
path: "/privacy",
title: "隐私政策",
description: "查看海角社区隐私政策与个人信息保护说明。",
description: "查看黄果黑料隐私政策与个人信息保护说明。",
type: "WebPage",
breadcrumbs: [
["首页", "/"],
+3 -3
View File
@@ -39,8 +39,8 @@ export async function generateMetadata({ params }: { params: Params }): Promise<
return resolvePageMetadata({
seoInfo: {
title: `${tabText}排行榜`,
description: `查看海角社区实时更新的${tabText}排行榜,浏览热播内容与精选推荐。`,
keyworld: ["海角社区", "排行榜", tabText],
description: `查看黄果黑料实时更新的${tabText}排行榜,浏览热播内容与精选推荐。`,
keyworld: ["黄果黑料", "排行榜", tabText],
},
page: pageNum,
canonical: rankingHref(tab, pageNum),
@@ -100,7 +100,7 @@ async function RankingContent({ params }: { params: Params }) {
data={buildWebPageJSONLD({
path: rankingHref(tab, page),
title: `${tabText}排行榜`,
description: `查看海角社区实时更新的${tabText}排行榜,浏览热播内容与精选推荐。`,
description: `查看黄果黑料实时更新的${tabText}排行榜,浏览热播内容与精选推荐。`,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
+2 -22
View File
@@ -1,37 +1,17 @@
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) */
/** 基础 robots:私有页 / 搜索 disallow,并声明 sitemap(取本站域名) */
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());
const origin = configured ? getSiteOrigin() : await getRequestOrigin();
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/profile",
"/history",
"/favorites",
"/likes",
"/settings",
"/feedback",
// 前缀匹配:/search、/search/短剧/... 均不抓
"/search",
],
+2 -2
View File
@@ -27,8 +27,8 @@ export async function generateMetadata({ params }: { params: Params }): Promise<
return resolvePageMetadata({
seoInfo: {
title: `搜索「${parsed.query}」`,
description: `在海角社区搜索「${parsed.query}」的${parsed.type}结果。`,
keyworld: ["海角社区", "搜索", parsed.query, parsed.type],
description: `在黄果黑料搜索「${parsed.query}」的${parsed.type}结果。`,
keyworld: ["黄果黑料", "搜索", parsed.query, parsed.type],
},
page: parsed.page,
canonical: searchListHref(parsed.query, parsed.type, parsed.page),
+2 -2
View File
@@ -17,8 +17,8 @@ function first(value: string | string[] | undefined) {
export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
fallbackTitle: "搜索",
fallbackDescription: "搜索海角社区短剧与视频。",
fallbackKeywords: ["海角社区", "搜索"],
fallbackDescription: "搜索黄果黑料短剧与视频。",
fallbackKeywords: ["黄果黑料", "搜索"],
canonical: "/search",
// 搜索入口不收录;robots.txt 同步 Disallow /search
robots: { index: false, follow: false },
+41 -21
View File
@@ -13,12 +13,12 @@ import {
playHref,
RECOMMEND_DISPLAY_COUNT,
resolvePlayURL,
totalEpisodeOf,
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 {
@@ -39,6 +39,7 @@ import {
import { ShortDramaPage } from "@/components/short-drama/short-drama-page";
import {
getPlayletChapterInfoCached,
getPlayletGuessListCached,
getPlayletInfoCached,
getPlayletListCached,
getPlayletRecommendListCached,
@@ -52,6 +53,7 @@ import {
buildPlayletDetailJSONLD,
buildPlayletPlayJSONLD,
buildWebPageJSONLD,
clipText,
getHomeBaseSEOCached,
mergeSEO,
resolveContentMetadata,
@@ -80,7 +82,10 @@ function playletLookup(idOrSlug: string): { id?: string; slug?: string } {
return { slug: idOrSlug };
}
/** 更多好剧:GET /api/app/playlet/recommend/list(sortList=1 推荐) */
/**
* 同类短剧推荐:先取 GET /api/app/playlet/recommend/list(sortList=1 推荐);
* 后台未配置推荐时为空,回退「猜你喜欢」(按当前短剧取相似)。
*/
async function loadPlayletRecommend(excludeId?: number | string | null): Promise<RelatedDrama[]> {
try {
const data = await getPlayletRecommendListCached({
@@ -88,9 +93,20 @@ async function loadPlayletRecommend(excludeId?: number | string | null): Promise
pageSize: RECOMMEND_PAGE_SIZE,
sortList: 1,
});
return mapPlayletRecommendList(data, { excludeId, limit: RECOMMEND_DISPLAY_COUNT });
const list = mapPlayletRecommendList(data, { excludeId, limit: RECOMMEND_DISPLAY_COUNT });
if (list.length) return list;
} catch (error) {
console.error("[short-drama/recommend]", error);
}
try {
const guess = await getPlayletGuessListCached({
id: excludeId ?? undefined,
pageNum: 1,
pageSize: RECOMMEND_PAGE_SIZE,
});
return mapPlayletRecommendList(guess, { excludeId, limit: RECOMMEND_DISPLAY_COUNT });
} catch (error) {
console.error("[short-drama/guess]", error);
return [];
}
}
@@ -140,9 +156,9 @@ export async function generateMetadata({ params }: { params: Params }): Promise<
seoInfo: theme?.seoInfo,
fallbackTitle: themeName ? `短剧·${themeName}·${sortText}` : `短剧·${sortText}`,
fallbackDescription: themeName
? `浏览海角社区短剧「${themeName}」题材,按${sortText}发现精彩作品。`
: `浏览海角社区短剧列表,按${sortText}发现精彩作品。`,
fallbackKeywords: ["海角社区", "短剧", themeName, sortText].filter(Boolean),
? `浏览黄果黑料短剧「${themeName}」题材,按${sortText}发现精彩作品。`
: `浏览黄果黑料短剧列表,按${sortText}发现精彩作品。`,
fallbackKeywords: ["黄果黑料", "短剧", themeName, sortText].filter(Boolean),
page: pageNum,
canonical: shortDramaListHref(sortKey, pageNum, themeSlug),
});
@@ -151,20 +167,23 @@ export async function generateMetadata({ params }: { params: Params }): Promise<
// 播放页 title:短剧标题 + 第几集 + 网页标题(站点名由 withSiteSuffix 追加)
if (page?.length === 1) {
const play = await loadPlayletPlay(decodeSegment(sort), page[0]);
if (!play) return resolveContentMetadata({ fallbackTitle: "海角社区" });
if (!play) return resolveContentMetadata({ fallbackTitle: "黄果黑料" });
const dramaTitle = play.detail.title?.trim() || "短剧";
const playTitle = `${dramaTitle} 第${play.episode}集`;
const { title: _seoTitle, ...playSeoRest } = play.detail.seoInfo ?? {};
// 播放页不用后台 title / description:每集都要带集数,避免同剧所有分集 TDK 完全相同
const { title: _seoTitle, description: seoDescription, ...playSeoRest } = play.detail.seoInfo ?? {};
void _seoTitle;
return resolveContentMetadata({
seoInfo: play.detail.seoInfo ? playSeoRest : null,
fallbackTitle: playTitle,
fallbackDescription:
play.detail.desc || `在线观看《${dramaTitle}》第${play.episode}集`,
fallbackDescription: [
`《${dramaTitle}》第${play.episode}集(共${totalEpisodeOf(play.detail)}集)`,
clipText(seoDescription || play.detail.desc, 70) || "按集收看,分集目录与同类短剧推荐见本页。",
].join(":"),
fallbackKeywords: [
"海角社区",
"黄果黑料",
"短剧",
"在线播放",
"分集",
dramaTitle,
playTitle,
...(play.detail.tagNames ?? []),
@@ -173,16 +192,19 @@ export async function generateMetadata({ params }: { params: Params }): Promise<
canonical: playHref(play.detail.id, play.episode, play.detail.slug),
});
}
if (page?.length) return resolvePageMetadata({ fallbackTitle: "海角社区" });
if (page?.length) return resolvePageMetadata({ fallbackTitle: "黄果黑料" });
// 详情页:TDK 用短剧详情(playlet/info)
const detail = await loadPlayletDetail(decodeSegment(sort));
if (!detail) return resolveContentMetadata({ fallbackTitle: "海角社区" });
if (!detail) return resolveContentMetadata({ fallbackTitle: "黄果黑料" });
return resolveContentMetadata({
seoInfo: detail.seoInfo,
fallbackTitle: detail.title,
fallbackDescription: detail.desc || `在线观看《${detail.title}》,查看选集、剧情信息与相关推荐。`,
fallbackKeywords: ["海角社区", "短剧", detail.title || "", ...(detail.tagNames ?? [])].filter(
fallbackTitle: detail.title ? `${detail.title}(全${totalEpisodeOf(detail)}集)` : undefined,
fallbackDescription: [
`《${detail.title || "短剧"}》全${totalEpisodeOf(detail)}集${detail.themeNames?.[0] ? `·${detail.themeNames[0]}` : ""}`,
clipText(detail.desc, 90) || "剧情介绍、分集目录与同类短剧推荐。",
].join("|"),
fallbackKeywords: ["黄果黑料", "短剧", detail.title || "", ...(detail.tagNames ?? [])].filter(
Boolean,
),
canonical: dramaDetailHref(detail),
@@ -317,8 +339,8 @@ async function ShortDramaRouteContent({ params }: { params: Params }) {
const listDescription =
listSeo.description ||
(themeName
? `浏览海角社区短剧「${themeName}」题材,按${sortText}发现精彩作品。`
: `浏览海角社区短剧列表,按${sortText}发现精彩作品。`);
? `浏览黄果黑料短剧「${themeName}」题材,按${sortText}发现精彩作品。`
: `浏览黄果黑料短剧列表,按${sortText}发现精彩作品。`);
return (
<>
<JsonLd
@@ -365,7 +387,6 @@ async function ShortDramaRouteContent({ params }: { params: Params }) {
<DramaDetailPage
detail={mapped.detail}
related={mapped.related}
relatedAside={<RelatedAside items={mapped.related} />}
adIcons={<AdIconsSlot variant="pc" />}
promo={<AdBannersSlot variant="pc" />}
adGrid={<AdIconsSlot variant="h5" />}
@@ -407,7 +428,6 @@ async function ShortDramaRouteContent({ params }: { params: Params }) {
episode={play.episode}
src={play.src}
related={play.related}
relatedAside={<RelatedAside items={play.related} />}
adIcons={<AdIconsSlot variant="pc" />}
promo={<AdBannersSlot variant="pc" />}
/>
+5 -5
View File
@@ -87,7 +87,7 @@ export async function generateMetadata({
const query = await searchParams;
const parsed = parseTagSlug((await params).slug, query.kind, query.page);
if (!parsed) {
return resolvePageMetadata({ seoInfo: { title: "标签", keyworld: ["海角社区", "标签"] } });
return resolvePageMetadata({ seoInfo: { title: "标签", keyworld: ["黄果黑料", "标签"] } });
}
const { key, page, kind: preferredKind } = parsed;
@@ -98,15 +98,15 @@ export async function generateMetadata({
return resolvePageMetadata({
seoInfo: {
title: `${data.name}标签`,
description: `浏览海角社区「${data.name}」标签相关${unit}。`,
keyworld: ["海角社区", "标签", data.name, unit],
description: `浏览黄果黑料「${data.name}」标签相关${unit}。`,
keyworld: ["黄果黑料", "标签", data.name, unit],
},
page,
canonical: tagListHref(pathKey, page, data.kind),
});
} catch {
return resolvePageMetadata({
seoInfo: { title: key, keyworld: ["海角社区", "标签", key] },
seoInfo: { title: key, keyworld: ["黄果黑料", "标签", key] },
page,
canonical: tagListHref(key, page, preferredKind ?? "playlet"),
});
@@ -163,7 +163,7 @@ export default async function Page({
data={buildWebPageJSONLD({
path: tagListHref(pathKey, page, data.kind),
title: `${data.name}标签`,
description: `浏览海角社区「${data.name}」标签相关${unit}。`,
description: `浏览黄果黑料「${data.name}」标签相关${unit}。`,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
+2 -2
View File
@@ -8,7 +8,7 @@ export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
seoInfo: {
title: "用户协议",
description: "查看海角社区用户服务条款及平台使用规则。",
description: "查看黄果黑料用户服务条款及平台使用规则。",
},
canonical: "/terms",
});
@@ -22,7 +22,7 @@ export default async function Page() {
data={buildWebPageJSONLD({
path: "/terms",
title: "用户协议",
description: "查看海角社区用户服务条款及平台使用规则。",
description: "查看黄果黑料用户服务条款及平台使用规则。",
type: "WebPage",
breadcrumbs: [
["首页", "/"],
+3 -3
View File
@@ -166,8 +166,8 @@ export async function generateMetadata({
return resolvePageMetadata({
seoInfo: data.seoInfo ?? {
title: name,
description: (data.desc || "").trim() || `浏览海角社区专题「${name}」。`,
keyworld: ["海角社区", name],
description: (data.desc || "").trim() || `浏览黄果黑料专题「${name}」。`,
keyworld: ["黄果黑料", name],
},
page: parsed.page,
canonical,
@@ -229,7 +229,7 @@ export default async function Page({
data={buildWebPageJSONLD({
path: topicListHref(pathKey, { dataType: resolvedDataType, page, sortType }),
title: `${name} · ${topicSortLabel(sortType)}`,
description: desc || `浏览海角社区专题「${name}」。`,
description: desc || `浏览黄果黑料专题「${name}」。`,
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
+37 -12
View File
@@ -7,6 +7,7 @@ import { PageLoading } from "@/components/common/page-loading";
import { sortLabels } from "@/components/short-drama/data";
import {
buildVideoList,
mapVideoListItem,
VIDEO_PAGE_SIZE,
videoSorts,
videoSortType,
@@ -25,7 +26,6 @@ import {
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 {
@@ -41,6 +41,7 @@ import { primeRequestForwardHeaders } from "@/lib/api/request-context";
import {
buildVideoPlayJSONLD,
buildWebPageJSONLD,
clipText,
getHomeBaseSEOCached,
mergeSEO,
resolveContentMetadata,
@@ -98,7 +99,28 @@ async function loadVideoDetailWithRecommend(idOrSlug: string): Promise<VideoPlay
const mapped = await loadVideoDetail(idOrSlug);
if (!mapped) return null;
const related = await loadVideoRecommend(mapped.detail.id);
return { ...mapped, related: related.length ? related : mapped.related };
if (related.length) return { ...mapped, related };
if (mapped.related.length) return mapped;
return { ...mapped, related: await loadHotVideoFallback(mapped.detail.id) };
}
/** 推荐与 hotVideoList 都为空时:取视频库热度列表兜底,排除当前视频 */
async function loadHotVideoFallback(excludeId: number | string): Promise<ShortDramaItem[]> {
try {
const data = await getVideoListCached({
pageNum: 1,
pageSize: VIDEO_RECOMMEND_DISPLAY_COUNT + 4,
sortType: videoSortType("hot"),
});
return (data.list ?? [])
.filter((item) => item.type !== 2 && item.id && Number(item.id) !== Number(excludeId))
.map(mapVideoListItem)
.filter((item): item is ShortDramaItem => Boolean(item))
.slice(0, VIDEO_RECOMMEND_DISPLAY_COUNT);
} catch (error) {
console.error("[video/hot-fallback]", error);
return [];
}
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
@@ -119,23 +141,27 @@ export async function generateMetadata({ params }: { params: Params }): Promise<
seoInfo: category?.seoInfo,
fallbackTitle: categoryName ? `视频·${categoryName}·${sortText}` : `视频·${sortText}`,
fallbackDescription: categoryName
? `浏览海角社区视频「${categoryName}」分类,按${sortText}发现精彩内容。`
: `浏览海角社区视频列表,按${sortText}发现精彩内容。`,
fallbackKeywords: ["海角社区", "视频", categoryName, sortText].filter(Boolean),
? `浏览黄果黑料视频「${categoryName}」分类,按${sortText}发现精彩内容。`
: `浏览黄果黑料视频列表,按${sortText}发现精彩内容。`,
fallbackKeywords: ["黄果黑料", "视频", categoryName, sortText].filter(Boolean),
page: pageNum,
canonical: videoListHref(sortKey, pageNum, categorySlug),
});
}
if (page?.length) return resolvePageMetadata({ fallbackTitle: "海角社区" });
if (page?.length) return resolvePageMetadata({ fallbackTitle: "黄果黑料" });
// 视频播放页:TDK 用视频详情(video/info)
const mapped = await loadVideoDetail(decodeSegment(sort));
const detail = mapped?.detail;
if (!detail) return resolveContentMetadata({ fallbackTitle: "海角社区" });
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),
// 描述带发布日期与标签,避免与其它站点同文案
fallbackDescription: [
`视频《${detail.title || "未命名"}》${/^\d{4}-\d{2}-\d{2}/.test(detail.addedTime || "") ? `·${(detail.addedTime || "").slice(0, 10)}发布` : ""}${tagNames.length ? `·${tagNames.slice(0, 3).join("/")}` : ""}`,
clipText(detail.desc, 90) || "视频介绍与相关视频推荐。",
].join("|"),
fallbackKeywords: ["黄果黑料", "视频库", detail.title || "", ...tagNames].filter(Boolean),
ogType: "video.episode",
canonical: videoHref(String(detail.id), detail.slug),
});
@@ -231,8 +257,8 @@ async function VideoRouteContent({ params }: { params: Params }) {
const listDescription =
listSeo.description ||
(categoryName
? `浏览海角社区视频「${categoryName}」分类,按${sortText}发现精彩内容。`
: `浏览海角社区视频列表,按${sortText}发现精彩内容。`);
? `浏览黄果黑料视频「${categoryName}」分类,按${sortText}发现精彩内容。`
: `浏览黄果黑料视频列表,按${sortText}发现精彩内容。`);
return (
<>
<JsonLd
@@ -281,7 +307,6 @@ async function VideoRouteContent({ params }: { params: Params }) {
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" />}
+8 -12
View File
@@ -4,16 +4,13 @@ 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";
import type { AnnouncementInfo } from "@/lib/api/types";
import { buildWebPageJSONLD, resolvePageMetadata, SITE_NAME } from "@/lib/seo";
const loadWayHomeBase = cache(async (): Promise<{
announcement?: AnnouncementInfo;
config?: ConfigInfo;
}> => {
const loadWayHomeBase = cache(async (): Promise<{ announcement?: AnnouncementInfo }> => {
try {
const base = await getHomeBaseCached();
return { announcement: base.announcement, config: base.config };
return { announcement: base.announcement };
} catch (error) {
console.warn("[way-home] home/base failed", error);
return {};
@@ -21,8 +18,7 @@ const loadWayHomeBase = cache(async (): Promise<{
});
export async function generateMetadata(): Promise<Metadata> {
const { config } = await loadWayHomeBase();
const siteName = config?.webSiteName?.trim() || config?.dhSiteName?.trim() || "海角社区";
const siteName = SITE_NAME;
return resolvePageMetadata({
siteName,
seoInfo: {
@@ -35,8 +31,8 @@ export async function generateMetadata(): Promise<Metadata> {
}
export default async function Page() {
const { announcement, config } = await loadWayHomeBase();
const siteName = config?.webSiteName?.trim() || config?.dhSiteName?.trim() || "海角社区";
const { announcement } = await loadWayHomeBase();
const siteName = SITE_NAME;
return (
<>
<JsonLd
@@ -52,7 +48,7 @@ export default async function Page() {
siteName,
})}
/>
<WayHomePage announcement={announcement} config={config} />
<WayHomePage announcement={announcement} />
</>
);
}
+3 -3
View File
@@ -12,8 +12,8 @@ export async function generateMetadata(): Promise<Metadata> {
return resolvePageMetadata({
seoInfo: {
title: "福利应用",
description: "海角社区福利应用与精选推荐。",
keyworld: ["海角社区", "福利应用"],
description: "黄果黑料福利应用与精选推荐。",
keyworld: ["黄果黑料", "福利应用"],
},
canonical: "/welfare",
});
@@ -35,7 +35,7 @@ async function WelfareContent() {
data={buildWebPageJSONLD({
path: "/welfare",
title: "福利应用",
description: "海角社区福利应用与精选推荐。",
description: "黄果黑料福利应用与精选推荐。",
type: "CollectionPage",
breadcrumbs: [
["首页", "/"],
+1 -2
View File
@@ -18,8 +18,7 @@ const sections: { title: string; paragraphs: string[]; slogan?: boolean }[] = [
{
title: "联系我们",
paragraphs: [
"商务合作:business@qimengshorts.com",
"用户反馈:support@qimengshorts.com",
"官方TG:@jbd125",
"服务时间:工作日 10:00–18:00",
],
},
-52
View File
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore
import { getAgeGateOpen, subscribeAgeGate } from "@/components/age-gate/age-gate-dialog";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { adTrackProps } from "@/components/common/ads/pick";
import { useChromeless } from "@/components/layout/route-visibility";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
@@ -200,34 +199,6 @@ export function AdDialogHost({
}, [index, showNext]);
const ad = index >= 0 && index < ads.length ? ads[index] : null;
const adId = ad ? String(ad.advertiseCode || ad.id || "") : "";
const trackAttrs = ad
? adTrackProps(ad, {
slotKey: "dialog_ad",
slotName: "弹窗广告位",
adType: "interstitial",
})
: {};
useEffect(() => {
if (!ad?.images || !adId) return;
document.dispatchEvent(
new CustomEvent("dialog-ad:track", {
detail: {
eventType: "show",
element: Object.assign(document.createElement("div"), {
dataset: {
advertisingKey: "dialog_ad",
advertisingName: "弹窗广告",
advertisingId: adId,
dialogAdId: adId,
},
}),
},
}),
);
}, [ad, adId]);
if (!ad?.images) return null;
const picture = (
@@ -252,10 +223,6 @@ export function AdDialogHost({
aria-label={ad.title || "广告"}
className="my-auto flex flex-col items-center gap-9 lg:gap-6"
onClick={(event) => event.target === event.currentTarget && showNext()}
data-advertising-key="dialog_ad"
data-advertising-name="弹窗广告"
data-advertising-id={adId}
data-dialog-ad-id={adId}
>
{ad.link ? (
<a
@@ -263,24 +230,6 @@ export function AdDialogHost({
target="_blank"
rel="noopener noreferrer sponsored"
className="block"
{...trackAttrs}
onClick={() => {
document.dispatchEvent(
new CustomEvent("dialog-ad:track", {
detail: {
eventType: "click",
element: Object.assign(document.createElement("div"), {
dataset: {
advertisingKey: "dialog_ad",
advertisingName: "弹窗广告",
advertisingId: adId,
dialogAdId: adId,
},
}),
},
}),
);
}}
>
{picture}
</a>
@@ -290,7 +239,6 @@ export function AdDialogHost({
<button
type="button"
aria-label="关闭广告"
data-dialog-ad-close=""
onClick={showNext}
className="size-9 cursor-pointer"
>
+19 -52
View File
@@ -7,6 +7,7 @@ import { getAdOpen, hasAdShown, subscribeAd } from "@/components/ad/ad-dialog";
import { getAgeGateOpen, subscribeAgeGate } from "@/components/age-gate/age-gate-dialog";
import { useChromeless } from "@/components/layout/route-visibility";
import type { AnnouncementInfo, ConfigInfo } from "@/lib/api/types";
import { OFFICIAL_TG_URL, SITE_DOMAIN } from "@/lib/seo/metadata";
/** 对齐 FOYA latestAddrPop(1041/1038):倒计时结束后才允许关闭,默认 3 秒 */
const CLOSE_DELAY_SECONDS = 3;
@@ -17,47 +18,26 @@ export type AddressDialogInfo = {
permanent: string;
latest: string;
telegram: string;
email: string;
mirrors: string[];
publishPage: string;
};
/** 从 home/base 的 announcement / config 组装地址弹窗文案(不改字段名映射,只做展示拼装) */
/**
* 组装地址弹窗文案:地址固定本站域名、联系方式固定官方 TG,不取接口字段
* (保留参数兼容调用方)。
*/
export function buildAddressDialogInfo(
announcement?: AnnouncementInfo | null,
config?: ConfigInfo | null,
_announcement?: AnnouncementInfo | null,
_config?: ConfigInfo | null,
): AddressDialogInfo | null {
const permanent = (announcement?.foreverUrlAddr || config?.foreverUrlAddr || "").trim();
const latest = (announcement?.newUrlAddr || config?.newUrlAddr || "").trim();
const telegram = (config?.tgGroup || config?.telegramAdd || config?.telegram || "").trim();
const email = (config?.gmailAddr || "").trim();
const mirrors = [
announcement?.newUrlAddr1,
announcement?.newUrlAddr2,
config?.newUrlAddr1,
config?.newUrlAddr2,
...(config?.useIngAddress ?? []),
]
.map((item) => item?.trim() || "")
.filter(Boolean)
.filter((item, index, list) => list.indexOf(item) === index);
const publishPage = (
announcement?.translationAddr ||
config?.translationAddr ||
config?.webAddr ||
"/way-home"
).trim();
if (!permanent && !latest && !mirrors.length && !telegram && !email) return null;
void _announcement;
void _config;
return {
permanent: permanent || latest,
latest: latest || permanent,
telegram,
email,
mirrors,
publishPage,
permanent: SITE_DOMAIN,
latest: SITE_DOMAIN,
telegram: OFFICIAL_TG_URL,
mirrors: [],
publishPage: "/way-home",
};
}
@@ -208,28 +188,17 @@ function AddressCard({
{info.latest ? <p>最新地址: {info.latest}</p> : null}
{info.telegram ? (
<p>
TG交流群:{" "}
官方TG:{" "}
<a
href={info.telegram}
target="_blank"
rel="noopener noreferrer"
className="text-[#FF2D55] hover:underline lg:font-medium"
>
点击加入官方TG群!
点击联系官方TG!
</a>
</p>
) : null}
{info.email ? (
<>
<p>
官方邮箱:{" "}
<a href={`mailto:${info.email}`} className="hover:underline">
{info.email}
</a>
</p>
<p className="text-sm/5">(发送任意邮件,自动回复最新地址)</p>
</>
) : null}
{info.mirrors.length > 0 ? (
<>
<p>
@@ -301,10 +270,8 @@ function saveAddressImage(info: AddressDialogInfo) {
...(info.permanent ? [[{ text: `永久地址: ${info.permanent}` }]] : []),
...(info.latest ? [[{ text: `最新地址: ${info.latest}` }]] : []),
...(info.telegram
? [[{ text: "TG交流群: " }, { text: info.telegram, color: ACCENT }]]
? [[{ text: "官方TG: " }, { text: "@jbd125", color: ACCENT }]]
: []),
...(info.email ? [[{ text: `官方邮箱: ${info.email}` }]] : []),
...(info.email ? [[{ text: "(发送任意邮件,自动回复最新地址)" }]] : []),
...(info.publishPage
? [[{ text: "地址发布页: " }, { text: info.publishPage, color: ACCENT }]]
: []),
@@ -353,7 +320,7 @@ function saveAddressImage(info: AddressDialogInfo) {
ctx.fillStyle = "#FFFFFF";
ctx.font = '500 20px "PingFang SC", "Microsoft YaHei", sans-serif';
ctx.fillText("海角社区 · 最新地址", padding, padding + titleHeight / 2);
ctx.fillText("黄果黑料 · 最新地址", padding, padding + titleHeight / 2);
ctx.font = font;
let y = padding + titleHeight + 18 + lineHeight / 2;
@@ -368,7 +335,7 @@ function saveAddressImage(info: AddressDialogInfo) {
}
const link = document.createElement("a");
link.download = "海角社区地址.png";
link.download = "黄果黑料地址.png";
link.href = canvas.toDataURL("image/png");
link.click();
}
@@ -1,223 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef, type ReactNode } from "react";
import { resolveChannel } from "@/lib/analytics/channel";
import {
externalAd,
inferPage,
initializeTracker,
navigation,
pageClick,
pageView,
setPage,
track,
tracker,
type TrackerBoot,
} from "@/components/analytics/tracker";
const SDK_SRC = "/js/web-sdk-v1.2.0.js?v=1";
function loadScript(src: string): Promise<void> {
return new Promise((resolve) => {
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
if (existing) {
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => resolve();
document.head.append(script);
});
}
function bootThirdParty() {
const google = (process.env.NEXT_PUBLIC_GTAG_ID || "").trim();
const yandexRaw = (process.env.NEXT_PUBLIC_YANDEX_METRIKA_ID || "").trim();
const yandex = yandexRaw ? Number(yandexRaw) : 0;
// layout 已注入 GA4 时勿重复加载
if (
google &&
!document.querySelector(`script[src*="googletagmanager.com/gtag/js?id=${google}"]`)
) {
window.dataLayer ||= [];
window.gtag ||= function gtag(...args: unknown[]) {
window.dataLayer!.push(args);
};
window.gtag("js", new Date());
window.gtag("config", google);
void loadScript(`https://www.googletagmanager.com/gtag/js?id=${google}`);
}
if (yandex) {
window.ym ||= Object.assign(
function ym(...args: unknown[]) {
(window.ym!.a ||= []).push(args);
},
{ a: [] as unknown[][], l: Date.now() },
);
window.ym(yandex, "init", {
ssr: true,
webvisor: true,
clickmap: true,
ecommerce: "dataLayer",
referrer: document.referrer,
url: location.href,
accurateTrackBounce: true,
trackLinks: true,
});
void loadScript(`https://mc.yandex.ru/metrika/tag.js?id=${yandex}`);
}
}
export function TrackerProvider({
boot: explicitBoot,
children,
}: {
boot?: TrackerBoot;
children?: ReactNode;
}) {
const pathname = usePathname();
const readyRef = useRef(false);
const lastPathRef = useRef("");
useEffect(() => {
let disposed = false;
const channel = resolveChannel();
const inferred = inferPage(pathname || location.pathname);
const boot: TrackerBoot = {
appId: explicitBoot?.appId || process.env.NEXT_PUBLIC_TRACKER_APP_ID || "JHA-1046",
encryptedConfig:
explicitBoot?.encryptedConfig ||
process.env.NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG ||
"",
appVersion: explicitBoot?.appVersion || "1.0.0",
pageKey: explicitBoot?.pageKey || inferred.key,
pageName: explicitBoot?.pageName || inferred.name,
userType: explicitBoot?.userType || "normal",
uid: explicitBoot?.uid || "",
channel: explicitBoot?.channel || channel,
};
void loadScript(SDK_SRC)
.then(() => initializeTracker(boot))
.then(() => {
if (disposed) return;
readyRef.current = true;
lastPathRef.current = pathname || location.pathname;
pageView();
bootThirdParty();
});
const click = (event: MouseEvent) => {
if (!(event.target instanceof Element)) return;
const target = event.target;
if (
!target.closest(
"[data-ad-close],[data-dialog-ad-close],[data-download-close],[data-login-close]",
)
) {
pageClick(event);
}
const anchor = target.closest<HTMLAnchorElement>(
"a[data-navigation-key],a[data-nav-key]",
);
if (anchor) navigation(anchor);
const ad = target.closest<HTMLElement>('[data-track-ad="external"][data-ad-id]');
if (ad) externalAd(ad, "click");
};
document.addEventListener("click", click, true);
const observer =
typeof IntersectionObserver === "undefined"
? null
: new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
externalAd(entry.target as HTMLElement, "show");
observer?.unobserve(entry.target);
}
}
},
{ threshold: 0.1 },
);
const observed = new WeakSet<Element>();
const observe = () =>
document
.querySelectorAll('[data-track-ad="external"][data-ad-id]:not([data-ad-clone])')
.forEach((node) => {
if (!observed.has(node)) {
observed.add(node);
if (observer) observer.observe(node);
else externalAd(node as HTMLElement, "show");
}
});
observe();
const mutations = new MutationObserver(observe);
mutations.observe(document.body, { childList: true, subtree: true });
const external = (event: Event) => {
const detail = (event as CustomEvent<{ element: HTMLElement; eventType: string }>).detail;
if (detail?.element) externalAd(detail.element, detail.eventType);
};
const advertising = (event: Event) => {
const detail = (event as CustomEvent<{ element: HTMLElement; eventType: string }>).detail;
const node = detail?.element;
if (!node) return;
void track("advertising", {
event_type: detail.eventType,
advertising_key: node.dataset.advertisingKey || "dialog_ad",
advertising_name: node.dataset.advertisingName || "弹窗广告",
advertising_id: node.dataset.advertisingId || node.dataset.dialogAdId || "",
});
};
document.addEventListener("external-ad:track", external);
document.addEventListener("dialog-ad:track", advertising);
const flush = () => {
void tracker.flush();
};
window.addEventListener("pagehide", flush);
return () => {
disposed = true;
observer?.disconnect();
mutations.disconnect();
document.removeEventListener("click", click, true);
document.removeEventListener("external-ad:track", external);
document.removeEventListener("dialog-ad:track", advertising);
window.removeEventListener("pagehide", flush);
};
// 仅随 boot 关键字段变化重建;pathname 由下方 effect 处理
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
explicitBoot?.appId,
explicitBoot?.encryptedConfig,
explicitBoot?.uid,
explicitBoot?.channel,
explicitBoot?.userType,
]);
useEffect(() => {
if (!readyRef.current) return;
const path = pathname || "";
if (!path || path === lastPathRef.current) return;
lastPathRef.current = path;
const next = inferPage(path);
setPage(next, true);
}, [pathname]);
return children;
}
export function useTracker() {
return tracker;
}
export default TrackerProvider;
-36
View File
@@ -1,36 +0,0 @@
import { connection } from "next/server";
import { getHomeBaseCached } from "@/lib/api/home";
import { TrackerProvider } from "./TrackerProvider";
/**
* 服务端拉取 reportSdkConfig,客户端初始化 Web SDK。
* 须放在 <Suspense> 内,并用 connection() 排除在 prerender shell 外。
*/
export async function TrackerHost() {
await connection();
let encryptedConfig =
process.env.NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG?.trim() ||
process.env.TRACKER_ENCRYPTED_CONFIG?.trim() ||
"";
try {
const base = await getHomeBaseCached();
// 接口字段在 config.reportSdkConfig
const fromApi = base.config?.reportSdkConfig?.trim();
if (fromApi) encryptedConfig = fromApi;
} catch {
// 接口失败时用环境变量兜底
}
return (
<TrackerProvider
boot={{
appId: process.env.NEXT_PUBLIC_TRACKER_APP_ID || "JHA-1046",
encryptedConfig,
appVersion: "1.0.0",
}}
/>
);
}
-570
View File
@@ -1,570 +0,0 @@
/** 数据上报字段(对齐 Web SDK / jha-1043 tracker) */
export type Fields = Record<string, unknown>;
export interface TrackerBoot {
appId?: string;
encryptedConfig?: unknown;
appVersion?: string;
uid?: string;
channel?: string;
pageKey?: string;
pageName?: string;
userType?: string;
}
interface WebSDK {
init(options: Fields): unknown;
track: Record<string, (payload: Fields) => unknown>;
getCommonFields?(keys: string[]): Promise<Fields>;
flush?(): unknown;
setUid?(uid: string): void;
setChannel?(channel: string): void;
setContext?(fields: Fields): void;
}
declare global {
interface Window {
WebSDK?: WebSDK;
__TRACKER_BOOT__?: TrackerBoot;
__TRACKER__?: typeof tracker;
tracker?: typeof tracker;
__trackerCommonFields?: Record<string, string>;
__CHANNEL_CODE__?: string;
dataLayer?: unknown[];
gtag?: (...args: unknown[]) => void;
ym?: ((...args: unknown[]) => void) & { a?: unknown[][]; l?: number };
}
}
const str = (value: unknown, fallback = "") =>
String(value ?? "").trim() || fallback;
const number = (value: unknown, fallback = 0) =>
Number.isFinite(Number(value)) ? Number(value) : fallback;
const commonKeys = [
"sid",
"device_id",
"device",
"device_fingerprint",
"fp_version",
];
const serverKeys = [
"app_id",
"uid",
"channel",
"sid",
"client_ts",
"device",
"device_id",
"device_brand",
"device_model",
"device_fingerprint",
"fp_version",
"user_agent",
"sdk_version",
"app_version",
];
let activeBoot: TrackerBoot = {};
let finishReady: () => void;
const ready = new Promise<void>((resolve) => {
finishReady = resolve;
});
let initializing = false;
let common: Record<string, string> = Object.fromEntries(
commonKeys.map((key) => [key, ""]),
);
let commonReady: Promise<void> = Promise.resolve();
let page = { key: "internal_other", name: "站内其他页面" };
let userType = "normal";
let drama: Fields | null = null;
const impressions = new Set<string>();
export function normalizeUID(value: unknown): string {
const uid = str(value);
return uid.length >= 4 &&
uid.length <= 32 &&
/^[A-Za-z0-9_]+$/.test(uid) &&
!["u_1001", "stress_user", "u_lab"].includes(uid.toLowerCase())
? uid
: "";
}
export function normalizeChannel(value: unknown): string {
const channel = str(value);
return channel.length <= 50 &&
/^[A-Za-z0-9_.-]+$/.test(channel) &&
!["0", "self", "natural", "official", "channel", "unknown", "-"].includes(
channel.toLowerCase(),
)
? channel
: "organic";
}
/** 按路径推断 page_key / page_name(对齐 jha-1043 tracker_page.go,适配本站路由) */
export function inferPage(path: string) {
const raw = (path || "/").split("?")[0] || "/";
const p = raw.length > 1 && raw.endsWith("/") ? raw.slice(0, -1) : raw;
if (p === "/" || p === "") return { key: "home", name: "首页" };
// 短剧:/short-drama/{sort} 列表;/short-drama/{slug} 详情;/short-drama/{slug}/{ep} 播放
if (/^\/short-drama(?:\/|$)/.test(p)) {
const parts = p.split("/").filter(Boolean); // short-drama, ...
if (parts.length >= 3 && /^\d+$/.test(parts[2] || "")) {
return { key: "play", name: "短剧播放页" };
}
// 排序段(hot/latest 及旧中文别名)视为列表,其余视为详情
const sortKeys = new Set(["hot", "latest", "热度优先", "最近更新"]);
if (parts.length === 2 && sortKeys.has(decodeURIComponent(parts[1] || ""))) {
return { key: "list", name: "短剧列表" };
}
if (parts.length === 1) return { key: "list", name: "短剧列表" };
return { key: "detail", name: "短剧详情" };
}
// 视频:列表 / 播放
if (/^\/video(?:\/|$)/.test(p)) {
const parts = p.split("/").filter(Boolean);
const sortKeys = new Set(["hot", "latest", "热度优先", "最近更新"]);
if (parts.length === 2 && sortKeys.has(decodeURIComponent(parts[1] || ""))) {
return { key: "list", name: "视频列表" };
}
if (parts.length === 1) return { key: "list", name: "视频列表" };
return { key: "play", name: "视频播放页" };
}
const rules: Array<[RegExp, string, string]> = [
[/^\/ranking(?:\/|$)/, "rank", "排行榜"],
[/^\/topic(?:\/|$)/, "topic", "专题详情"],
[/^\/more(?:\/|$)/, "topic", "专题详情"],
[/^\/tag(?:\/|$)/, "tag_list", "标签详情"],
[/^\/search(?:\/|$)/, "search", "搜索结果页"],
[/^\/history(?:\/|$)/, "history", "观看历史"],
[/^\/favorites(?:\/|$)/, "collect", "我的收藏"],
[/^\/likes(?:\/|$)/, "likes", "我的点赞"],
[/^\/profile(?:\/|$)/, "user_center", "个人中心"],
[/^\/settings\/profile(?:\/|$)/, "edit_profile", "编辑资料"],
[/^\/settings(?:\/|$)/, "setting", "设置"],
[/^\/way-home(?:\/|$)/, "home_road", "回家的路"],
[/^\/welfare(?:\/|$)/, "application", "应用推荐"],
[/^\/nav(?:\/|$)/, "nav", "官方导航"],
[/^\/about(?:\/|$)/, "about_us", "关于我们"],
[/^\/terms(?:\/|$)/, "user_agreement", "用户协议"],
[/^\/privacy(?:\/|$)/, "privacy_policy", "隐私政策"],
[/^\/help(?:\/|$)/, "help_center", "帮助中心"],
[/^\/feedback(?:\/|$)/, "feedback", "问题反馈"],
[/^\/richtext(?:\/|$)/, "rich_text", "富文本页"],
[/^\/collection(?:\/|$)/, "collect", "合集"],
];
const hit = rules.find(([re]) => re.test(p));
return hit
? { key: hit[1], name: hit[2] }
: { key: "internal_other", name: "站内其他页面" };
}
/** SPA 路由切换时更新当前页并上报 page_view */
export function setPage(next: { key: string; name: string }, reportView = true) {
page = { key: str(next.key, page.key), name: str(next.name, page.name) };
if (reportView) pageView();
}
export function initializeTracker(boot: TrackerBoot): Promise<void> {
const inferred = inferPage(location.pathname);
page = {
key: str(boot.pageKey, inferred.key),
name: str(boot.pageName, inferred.name),
};
userType = boot.userType === "vip" ? "vip" : "normal";
activeBoot = boot;
window.__TRACKER_BOOT__ = boot;
window.__TRACKER__ = tracker;
window.tracker = tracker;
if (!initializing) {
initializing = true;
const resolve = () => finishReady();
let attempts = 0;
const timer = setInterval(() => {
if (!window.WebSDK?.track && ++attempts < 100) return;
clearInterval(timer);
if (!window.WebSDK) {
resolve();
return;
}
Promise.resolve()
.then(() =>
window.WebSDK?.init({
appId: str(boot.appId),
uid: normalizeUID(boot.uid),
channel: normalizeChannel(boot.channel || window.__CHANNEL_CODE__),
encryptedConfig: str(boot.encryptedConfig),
appVersion: str(boot.appVersion, "1.0.0"),
disableAutoEvents: ["app_page_view", "page_click"],
}),
)
.catch(() => undefined)
.then(() => {
// Core events need only SDK initialization; device fingerprint loading must not stall them.
commonReady = (async () => {
try {
const fields = await window.WebSDK?.getCommonFields?.(
commonKeys.slice(),
);
common = Object.fromEntries(
commonKeys.map((key) => [key, str(fields?.[key])]),
);
} catch {
/* Empty common fields preserve the original degraded SDK contract. */
}
window.__trackerCommonFields = common;
window.dispatchEvent(
new CustomEvent("tracker:common-fields", { detail: common }),
);
})();
window.dispatchEvent(
new CustomEvent("tracker:ready", { detail: page }),
);
resolve();
});
}, 50);
}
return ready;
}
export async function track(method: string, payload: Fields): Promise<unknown> {
await ready;
try {
return await window.WebSDK?.track[method]?.(payload);
} catch {
return { sent: false, ok: false, reason: "exception" };
}
}
export function pageView(extra: Fields = {}) {
let referrer = { key: "direct", name: "直接访问" };
try {
const saved = sessionStorage.getItem("__tracker_last_page");
if (saved) referrer = JSON.parse(saved);
else if (document.referrer) {
const url = new URL(document.referrer);
referrer =
url.host === location.host
? inferPage(url.pathname)
: { key: "external", name: url.host };
}
} catch {
/* Storage is optional. */
}
const navigation = performance.getEntriesByType("navigation")[0] as
| PerformanceNavigationTiming
| undefined;
void track("appPageView", {
user_type: userType,
page_key: page.key,
page_name: page.name,
referrer_page_key: referrer.key,
referrer_page_name: referrer.name,
current_page_key: page.key,
current_page_name: page.name,
page_load_time: Math.max(
0,
Math.round(
navigation?.loadEventEnd ||
navigation?.domContentLoadedEventEnd ||
performance.now(),
),
),
recommend_trace_id: "",
...extra,
});
try {
sessionStorage.setItem("__tracker_last_page", JSON.stringify(page));
} catch {
/* Storage is optional. */
}
}
export function navigation(anchor: HTMLAnchorElement) {
const href = anchor.getAttribute("href");
if (!href || /^(#|javascript:)/i.test(href)) return;
let key = str(anchor.dataset.navigationKey || anchor.dataset.navKey);
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key)) {
const url = new URL(href, location.href);
const target = inferPage(url.pathname);
key =
url.origin !== location.origin
? "nav_external"
: target.key !== "internal_other"
? "nav_" + target.key
: "nav_" +
(url.pathname.split("/").filter(Boolean)[0] || "home")
.replace(/[^a-z0-9]+/gi, "_")
.slice(0, 48);
}
void track("navigation", {
navigation_key: key.toLowerCase(),
navigation_name: str(
anchor.dataset.navigationName || anchor.dataset.navName,
anchor.getAttribute("aria-label") ||
anchor.textContent?.replace(/\s+/g, " ") ||
key,
).slice(0, 255),
});
}
export function pageClick(event: MouseEvent) {
void track("pageClick", {
page_key: page.key,
page_name: page.name,
click_page_x: Math.trunc(event.clientX),
click_page_y: Math.trunc(event.clientY),
click_x_percent: innerWidth
? Math.round(
Math.max(0, Math.min(100, (event.clientX / innerWidth) * 100)),
)
: 0,
click_y_percent: innerHeight
? Math.round(
Math.max(0, Math.min(100, (event.clientY / innerHeight) * 100)),
)
: 0,
screen_width: screen.width,
screen_height: screen.height,
recommend_trace_id: "",
});
}
export function externalAd(node: HTMLElement, type: string) {
if (!["show", "click"].includes(type) || !node.dataset.adId) return;
const slots = Array.from(
document.querySelectorAll(
'[data-track-ad="external"][data-ad-id]:not([data-ad-clone])',
),
);
const slot = node.hasAttribute("data-ad-clone")
? slots.find(
(slot) => slot.getAttribute("data-ad-id") === node.dataset.adId,
)
: node;
const position = Math.max(1, slots.indexOf(slot as Element) + 1);
const interstitial = node.dataset.adType === "interstitial";
const payload = {
page_key: page.key,
page_name: page.name,
ad_slot_key: str(
node.dataset.adSlotKey,
`${page.key}_${interstitial ? "popup" : "content_banner"}_${position}`,
),
ad_slot_name: str(
node.dataset.adSlotName,
page.name + (interstitial ? "弹窗广告位" : `内容横幅第${position}位`),
),
ad_id: node.dataset.adId,
creative_id: str(node.dataset.creativeId),
ad_type: str(node.dataset.adType, "banner"),
};
const key = payload.ad_slot_key + "|" + payload.ad_id;
if (!impressions.has(key)) {
impressions.add(key);
void track("adImpression", payload);
}
if (type === "click" && node.dataset.adClickable === "1")
void track("adClick", payload);
}
export function setDramaContext(value: Fields | null) {
drama = value;
}
const behaviorNames: Record<string, string> = {
ep_view: "展示",
ep_play: "播放",
ep_pause: "暂停",
ep_forward: "快进",
ep_rewind: "快退",
ep_complete: "播放完成",
ep_share: "分享",
ep_fail: "播放失败",
};
const videoBehaviorNames: Record<string, string> = {
video_view: "展示",
video_play: "播放",
video_pause: "暂停",
video_forward: "快进",
video_rewind: "快退",
video_complete: "播放完成",
video_fail: "播放失败",
};
let videoCtx: Fields | null = null;
export function setVideoContext(value: Fields | null) {
videoCtx = value;
}
export function videoEvent(
behavior: string,
media?: HTMLVideoElement | null,
options: { played?: number; forceProgress?: number } = {},
) {
if (!videoCtx) return Promise.resolve();
const duration =
media && Number.isFinite(media.duration)
? Math.round(media.duration)
: number(videoCtx.video_duration);
const current = Math.round(media?.currentTime || 0);
return track("videoEvent", {
...videoCtx,
media_id: str(videoCtx.media_id, str(videoCtx.video_id)),
video_duration: Math.max(0, duration),
play_duration: Math.max(0, Math.round(options.played || 0)),
play_progress:
options.forceProgress ??
(behavior === "video_complete"
? 100
: duration > 0
? Math.round(Math.min(100, (current / duration) * 100))
: 0),
play_speed: Math.max(1, Math.round((media?.playbackRate || 1) * 100)),
video_behavior_key: behavior,
video_behavior_name: videoBehaviorNames[behavior] || behavior,
});
}
export function dramaEvent(
behavior: string,
video?: HTMLVideoElement | null,
options: { played?: number; forceProgress?: number } = {},
) {
if (!drama) return Promise.resolve();
const duration =
video && Number.isFinite(video.duration)
? Math.round(video.duration)
: number(drama.drama_duration);
const current = Math.round(video?.currentTime || 0);
return track("dramaEvent", {
...drama,
drama_duration: Math.max(0, duration),
play_duration: Math.max(0, Math.round(options.played || 0)),
play_progress:
options.forceProgress ??
(behavior === "ep_complete"
? 100
: duration > 0
? Math.round(Math.min(100, (current / duration) * 100))
: 0),
play_speed: Math.max(1, Math.round((video?.playbackRate || 1) * 100)),
play_behavior_key: behavior,
play_behavior_name: behaviorNames[behavior] || behavior,
});
}
export function dramaStatusChange(
episode: number,
videoID: string | number,
override?: string,
) {
if (!drama) return Promise.resolve();
const previous = number(drama.episode, 1);
const behavior =
override ||
(episode === previous + 1
? "ep_next"
: episode === previous - 1
? "ep_previous"
: "ep_switch");
if (!override && episode === previous && str(videoID) === str(drama.video_id))
return Promise.resolve();
const names: Record<string, string> = {
ep_auto_play: "自动播放",
ep_previous: "上一集",
ep_next: "下一集",
ep_switch: "切换剧集",
ep_unlock: "解锁",
};
return track("dramaStatusChange", {
media_id: drama.media_id,
drama_id: drama.drama_id,
drama_title: drama.drama_title,
episode: Math.max(1, episode),
video_id: str(videoID, str(drama.video_id)),
pre_episode: previous,
drama_behavior_key: behavior,
drama_behavior_name: names[behavior] || behavior,
});
}
/** Full per-request SDK snapshot for server-side drama events; header compatibility remains five fields. */
export async function serverContext(keys = serverKeys): Promise<Fields> {
await ready;
let fields: Fields = {};
try {
fields = (await window.WebSDK?.getCommonFields?.(serverKeys.slice())) || {};
} catch {
/* Cached device identifiers remain usable. */
}
const defaults: Fields = {
...common,
app_id: str(activeBoot.appId),
uid: normalizeUID(activeBoot.uid),
channel: normalizeChannel(activeBoot.channel || window.__CHANNEL_CODE__),
client_ts: Math.floor(Date.now() / 1000),
device_brand: "",
device_model: "",
user_agent: navigator.userAgent,
sdk_version: "1.2.0",
app_version: str(activeBoot.appVersion, "1.0.0"),
};
return Object.fromEntries(
keys.map((key) => [
key,
key === "client_ts"
? Math.floor(number(fields[key], number(defaults[key])))
: str(fields[key] ?? defaults[key]),
]),
);
}
export const tracker = {
init: initializeTracker,
pageView,
setPage,
inferPage,
dramaEvent,
dramaStatusChange,
dramaUnlock: () =>
dramaStatusChange(
number(drama?.episode, 1),
str(drama?.video_id),
"ep_unlock",
),
setDramaContext,
videoEvent,
setVideoContext,
getVideoContext: () => (videoCtx ? { ...videoCtx } : null),
externalAd,
advertising: (payload: Fields) => track("advertising", payload),
keywordSearch: (payload: Fields) => track("keywordSearch", payload),
keywordClick: (payload: Fields) => track("keywordClick", payload),
getDramaContext: () => ({ ...drama }),
getCommonFieldsSync: () => common,
getCommonFields: serverContext,
serverContext,
getCommonField: async (key: string) => {
await ready;
await commonReady;
return common[key] || "";
},
encodeSDKCommonHeader: (fields = common) =>
btoa(
unescape(
encodeURIComponent(
JSON.stringify(
Object.fromEntries(
commonKeys.map((key) => [key, str(fields[key])]),
),
),
),
),
),
getSDKCommonHeader: async () => {
await ready;
await commonReady;
return tracker.encodeSDKCommonHeader();
},
flush: async () => {
try {
await window.WebSDK?.flush?.();
} catch {
/* Analytics must never interrupt playback. */
}
},
setUid: (uid: unknown) => {
activeBoot.uid = normalizeUID(uid);
window.WebSDK?.setUid?.(activeBoot.uid);
},
setChannel: (channel: unknown) => {
activeBoot.channel = normalizeChannel(channel);
window.WebSDK?.setChannel?.(activeBoot.channel);
},
setContext: (context: Fields) => window.WebSDK?.setContext?.(context),
};
@@ -1,168 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import {
dramaEvent,
setDramaContext,
setVideoContext,
tracker,
videoEvent,
type Fields,
} from "@/components/analytics/tracker";
export function useDramaContext(context: Fields | null) {
const viewed = useRef("");
useEffect(() => {
setDramaContext(context);
const key = context ? `${String(context.drama_id)}:${String(context.video_id)}` : "";
if (context && viewed.current !== key) {
viewed.current = key;
void dramaEvent("ep_view");
}
return () => setDramaContext(null);
}, [context]);
}
export function useVideoContext(context: Fields | null) {
const viewed = useRef("");
useEffect(() => {
setVideoContext(context);
const key = context ? String(context.video_id) : "";
if (context && viewed.current !== key) {
viewed.current = key;
void videoEvent("video_view");
}
return () => setVideoContext(null);
}, [context]);
}
const reporters = new WeakMap<HTMLVideoElement, (behavior: string) => void>();
export function reportPlaybackAction(behavior: string, video?: HTMLVideoElement | null) {
const report = video && reporters.get(video);
if (report) report(behavior);
else void dramaEvent(behavior, video);
}
/** 墙钟播放时长(排除缓冲/seek),对齐原 SDK */
export function bindPlaybackAnalytics(
video: HTMLVideoElement,
enabled: boolean,
kind: "drama" | "video" = "drama",
): () => void {
if (!enabled) return () => undefined;
let started = 0;
let elapsed = 0;
let position = 0;
let seekFrom: number | null = null;
const settle = () => {
if (started) elapsed += Math.max(0, Date.now() - started);
started = 0;
};
const begin = () => {
if (!started) started = Date.now();
};
const mapBehavior = (behavior: string) => {
if (kind === "drama") return behavior;
const map: Record<string, string> = {
ep_play: "video_play",
ep_pause: "video_pause",
ep_forward: "video_forward",
ep_rewind: "video_rewind",
ep_complete: "video_complete",
ep_fail: "video_fail",
};
return map[behavior] || behavior;
};
const emit = (behavior: string, keep = false, forceProgress?: number) => {
const played = (elapsed + (started ? Math.max(0, Date.now() - started) : 0)) / 1000;
const key = mapBehavior(behavior);
if (kind === "video") void videoEvent(key, video, { played, forceProgress });
else void dramaEvent(key, video, { played, forceProgress });
if (!keep) {
elapsed = 0;
if (started) started = Date.now();
}
};
reporters.set(video, emit);
const events: Record<string, () => void> = {
play: () => emit("ep_play"),
playing: begin,
waiting: settle,
pause: () => {
const pending = started || elapsed > 0;
settle();
if (!video.ended && !video.seeking && pending) emit("ep_pause");
},
timeupdate: () => {
if (!video.seeking) position = video.currentTime;
},
seeking: () => {
if (seekFrom == null) {
settle();
seekFrom = position;
}
},
seeked: () => {
if (seekFrom == null) return;
const from = seekFrom;
seekFrom = null;
position = video.currentTime;
if (Math.abs(position - from) >= 0.5) emit(position > from ? "ep_forward" : "ep_rewind");
if (!video.paused && !video.ended) begin();
},
ended: () => {
settle();
emit("ep_complete", true);
emit("ep_play", false, 100);
},
error: () => {
settle();
emit("ep_fail");
},
};
Object.entries(events).forEach(([name, handler]) => video.addEventListener(name, handler));
const report = () => {
if (elapsed + (started ? Date.now() - started : 0) >= 500) {
settle();
emit("ep_play");
void tracker.flush();
setTimeout(() => void tracker.flush(), 50);
setTimeout(() => void tracker.flush(), 200);
}
};
const visibility = () => {
if (document.visibilityState === "hidden") report();
if (!video.paused && !video.ended) begin();
};
document.addEventListener("visibilitychange", visibility);
window.addEventListener("pagehide", report);
window.addEventListener("beforeunload", report);
return () => {
reporters.delete(video);
const pending = started || elapsed;
settle();
if (pending && !video.ended) emit("ep_pause");
Object.entries(events).forEach(([name, handler]) => video.removeEventListener(name, handler));
document.removeEventListener("visibilitychange", visibility);
window.removeEventListener("pagehide", report);
window.removeEventListener("beforeunload", report);
};
}
/** 从 xgplayer 实例拿到内部 video 并绑定埋点 */
export function bindPlayerAnalytics(
player: { media?: HTMLVideoElement | null; video?: HTMLVideoElement | null } | null | undefined,
kind: "drama" | "video" = "drama",
): () => void {
const video = player?.media || player?.video || null;
if (!video) return () => undefined;
return bindPlaybackAnalytics(video, true, kind);
}
@@ -3,7 +3,6 @@
import { useEffect, useState } from "react";
import { ensureGuestSession } from "@/lib/auth/session";
import { tracker } from "@/lib/tracker";
/**
* 仅在挂载后异步拉游客会话,不参与 SSR / 首屏阻塞。
@@ -19,8 +18,6 @@ export function GuestAuthBootstrap() {
void ensureGuestSession()
.then((user) => {
if (cancelled || !user) return;
// 埋点 uid:SDK 要求非空时长度 ≥ 4
tracker.setUid(String(user.userId || ""));
if (process.env.NODE_ENV === "development") {
console.info("[guest-auth]", {
userId: user.userId,
+2 -2
View File
@@ -3,7 +3,7 @@
import Link from "next/link";
import { useCallback, useEffect, useId, useState, useSyncExternalStore, type FormEvent, type ReactNode } from "react";
import { useMockUser } from "@/components/profile/use-mock-user";
import { useMockUser } from "@/components/auth/use-mock-user";
import {
ACCOUNT_RULE_HINT,
CREDENTIAL_MAX_LEN,
@@ -156,7 +156,7 @@ export function LoginDialogHost() {
onSubmit={submit}
className="flex w-[311px] flex-col items-center gap-4 rounded-3xl bg-[#0E0C17] px-[18px] py-6 lg:w-[456px] lg:gap-3.5 lg:bg-[#0A0915]/90 lg:px-8 lg:py-7 lg:shadow-[0_20px_50px_rgba(0,0,0,0.35)] lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-white/13"
>
<img src="/logo.svg" alt="海角社区" className="h-[33px] w-auto lg:h-12" />
<img src="/logo.svg" alt="黄果黑料" className="h-[33px] w-auto lg:h-12" />
{isLogin ? (
<div className="flex gap-4 self-stretch lg:hidden" role="tablist">
+1 -11
View File
@@ -2,7 +2,7 @@ import { DecryptedImage } from "@/components/common/decrypted-image";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { adHref, adTrackProps, pickBannerAds } from "./pick";
import { adHref, pickBannerAds } from "./pick";
type AdBannersProps = {
variant?: "pc" | "h5";
@@ -39,11 +39,6 @@ export function AdBanners({
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className="block h-10 overflow-hidden rounded-xl"
{...adTrackProps(ad, {
slotKey: `content_banner_${index + 1}`,
slotName: `内容横幅第${index + 1}位`,
adType: "banner",
})}
>
<DecryptedImage
src={ad.images || null}
@@ -74,11 +69,6 @@ export function AdBanners({
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className="block h-[97px] overflow-hidden rounded-xl"
{...adTrackProps(ad, {
slotKey: `content_banner_${index + 1}`,
slotName: `内容横幅第${index + 1}位`,
adType: "banner",
})}
>
<DecryptedImage
src={ad.images || null}
+1 -6
View File
@@ -3,7 +3,7 @@ import { AdIconMarquee } from "@/components/common/ad-icon-marquee";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { adContentKey, adHref, adTrackProps, pickIconAds } from "./pick";
import { adContentKey, adHref, pickIconAds } from "./pick";
type AdIconsProps = {
variant?: "pc" | "h5";
@@ -54,11 +54,6 @@ export function AdIcons({
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className={iconClass}
{...adTrackProps(ad, {
slotKey: `icon_grid_${index + 1}`,
slotName: `九宫格第${index + 1}位`,
adType: "icon",
})}
>
<DecryptedImage
src={ad.images || null}
-18
View File
@@ -36,21 +36,3 @@ export function adHref(link?: string, fallback = "#download") {
export function adContentKey(list: AdvertiseInfo[]) {
return list.map((item) => item.id || item.title || item.images || "").join("|") || "mock";
}
/** 外链广告埋点属性(对齐 hkdj / jha-1043 data-track-ad) */
export function adTrackProps(
ad: AdvertiseInfo,
options?: { slotKey?: string; slotName?: string; adType?: string },
) {
const adId = String(ad.advertiseCode || ad.id || "").trim();
if (!adId) return {};
return {
"data-track-ad": "external" as const,
"data-ad-id": adId,
"data-ad-clickable": "1",
"data-ad-type": options?.adType || "banner",
"data-ad-slot-key": options?.slotKey || ad.advertiseLocationCode || undefined,
"data-ad-slot-name": options?.slotName || ad.advertiseLocationName || ad.title || undefined,
"data-creative-id": String(ad.id || ""),
};
}
+73
View File
@@ -0,0 +1,73 @@
import type { BadgeTone } from "@/components/home/data";
/**
* 黄果黑料卡片 / 详情页公用视觉:琥珀色强调(区别于其它导航站的品红主题)。
* 卡片不做整块底色,封面 + 正文排版;hover 只给封面描边、标题变色。
*/
export const HL_ACCENT = "#FFB020";
export const HL_COVER =
"relative block overflow-hidden rounded-md bg-[#17161D] outline outline-1 -outline-offset-1 outline-white/5 transition-[outline-color] duration-300 group-hover:outline-[#FFB020]/80";
export const HL_COVER_IMG =
"absolute inset-0 h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.04]";
export const HL_TITLE_LINK = "transition-colors duration-200 hover:text-[#FFB020] group-hover:text-[#FFB020]";
const badgeText: Record<BadgeTone, string> = {
hot: "热播",
fresh: "新上架",
original: "独家",
};
/** 封面左上角琥珀角标(文字,不用图片角标) */
export function HlBadge({ tone, small = false }: { tone?: BadgeTone | null; small?: boolean }) {
if (!tone) return null;
return (
<span
className={`absolute top-0 left-0 z-[1] rounded-br-md bg-[#FFB020] font-semibold text-[#1A1206] ${
small ? "px-1.5 py-0.5 text-[10px] leading-[14px]" : "px-2 py-0.5 text-xs leading-[18px]"
}`}
>
{badgeText[tone]}
</span>
);
}
/** 接口时间 → YYYY-MM-DD;无效返回空串。优先直接截取,避免服务端 / 浏览器时区不同导致水合不一致 */
export function formatDay(raw?: string | null) {
const text = raw?.trim();
if (!text) return "";
const head = /^\d{4}-\d{2}-\d{2}/.exec(text);
if (head) return head[0];
const date = new Date(/^\d+$/.test(text) ? Number(text) : text);
if (Number.isNaN(date.getTime())) return "";
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
}
/** 秒 → m:ss / h:mm:ss;无效返回空串 */
export function formatDuration(seconds?: number | null) {
const total = Math.floor(Number(seconds) || 0);
if (total <= 0) return "";
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (value: number) => String(value).padStart(2, "0");
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
/** 卡片元信息里的小眼睛图标 */
export function EyeIcon({ className = "size-3.5" }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" className={className} aria-hidden="true">
<path
d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8Z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
/>
<circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.3" />
</svg>
);
}
+6 -6
View File
@@ -165,11 +165,11 @@ function DownloadPanel({ onClose }: { onClose: () => void }) {
<section
role="dialog"
aria-modal="true"
aria-label="下载海角社区 App"
aria-label="下载黄果黑料 App"
className="flex w-80 flex-col items-center gap-[18px] rounded-[18px] bg-[#121118] p-6 text-center shadow-[0_14px_32px_rgba(0,0,0,0.45)]"
>
<img src="/logo.svg" alt="海角社区" className="h-[65px] w-auto" />
<p className="text-sm leading-5 text-[#BEB9CB]">扫码下载「海角社区 App」</p>
<img src="/logo.svg" alt="黄果黑料" className="h-[65px] w-auto" />
<p className="text-sm leading-5 text-[#BEB9CB]">扫码下载「黄果黑料 App」</p>
<p className="text-xs leading-[18px] text-[#8E889C]">海量短剧,随时畅看</p>
{link ? <QrBox link={link} className="size-[180px] rounded-[10px] p-2" /> : null}
<button
@@ -181,7 +181,7 @@ function DownloadPanel({ onClose }: { onClose: () => void }) {
{copied ? "下载链接已复制" : link || "暂无下载地址"}
</button>
<p className="bg-[linear-gradient(54deg,#FF5693_0%,#B421D5_58%,#76DFF2_100%)] bg-clip-text text-lg leading-[25px] font-bold tracking-[0.2em] text-transparent">
海角社区
黄果黑料
</p>
</section>
<CloseCircle onClick={onClose} />
@@ -194,10 +194,10 @@ function DownloadPanel({ onClose }: { onClose: () => void }) {
className="relative my-auto hidden h-[500px] w-[760px] flex-col overflow-hidden rounded-3xl bg-[linear-gradient(90deg,#2B1233_0%,#101F41_100%)] shadow-[0_24px_50px_rgba(0,0,0,0.55)] outline outline-1 -outline-offset-1 outline-white/14 lg:flex"
>
<header className="flex h-[92px] shrink-0 items-start gap-[17px] border-b border-white/12 px-7 pt-[22px]">
<img src="/logo.svg" alt="海角社区" className="h-[58px] w-32" />
<img src="/logo.svg" alt="黄果黑料" className="h-[58px] w-32" />
<div className="flex flex-col gap-2">
<h2 id="download-dialog-title" className="text-2xl leading-[30px] font-semibold text-white">
下载海角社区 App
下载黄果黑料 App
</h2>
<p className="text-sm leading-5 text-[#A49DB4]">扫码安装,继续追剧不丢进度</p>
</div>
+2 -11
View File
@@ -90,8 +90,8 @@ export function getEpisodeState(episode: number, detail: DramaDetail): EpisodeSt
/** 猜你喜欢侧栏展示条数(详情 / 播放) */
export const RELATED_DISPLAY_COUNT = 8;
/** 更多好剧推荐条数(三列网格 3×3) */
export const RECOMMEND_DISPLAY_COUNT = 9;
/** 同类短剧推荐条数(PC 6 列 × 2 行) */
export const RECOMMEND_DISPLAY_COUNT = 12;
/** 猜你喜欢:过滤广告后保留接口原字段 */
export function mapPlayletGuessList(data: PlayletGuessListResp | null | undefined): RelatedDrama[] {
@@ -198,15 +198,6 @@ export function mapPlayletInfo(data: QueryPlayletInfoResp | null | undefined): D
};
}
export function getEpisodeRanges(total: number, size: number): EpisodeRange[] {
const count = Math.max(1, Math.floor(total / size));
return Array.from({ length: count }, (_, index) => {
const start = index * size + 1;
const end = index === count - 1 ? total : start + size - 1;
return { label: `${start}–${end}`, start, end };
});
}
export function getEpisodeChunks(total: number, size: number): EpisodeRange[] {
return Array.from({ length: Math.ceil(total / size) }, (_, index) => {
const start = index * size + 1;
+117 -220
View File
@@ -1,36 +1,41 @@
"use client";
import { toast } from "@heroui/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { BackButton } from "@/components/common/back-button";
import { cardTagCanLink, pickPlayletCardTags } from "@/components/common/card-tags";
import { pickPlayletCardTags } from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { openDownloadDialog } from "@/components/download/download-dialog";
import { FloatingActions } from "@/components/home/floating-actions";
import { tagPageHref } from "@/components/tag/href";
import { EpisodeDirectory } from "@/components/media-page/episode-directory";
import {
DramaRecommend,
HlAction,
HlPrimaryLink,
InfoList,
SectionTitle,
TagLinks,
tagLinkItems,
} from "@/components/media-page/parts";
import { PageBreadcrumb } from "@/components/seo/page-breadcrumb";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import { favoritePlaylet, getPlayletInfo, likePlaylet } from "@/lib/api/playlet-actions";
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { CommentPanel, playletCommentTarget } from "@/components/common/comments";
import { PageBreadcrumb } from "@/components/seo/page-breadcrumb";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import {
formatPlays,
playHref,
totalEpisodeOf,
updateStatusLabel,
yearFromOnlineAt,
type DramaDetail,
type RelatedDrama,
} from "./data";
import { EpisodePanel, H5EpisodeStrip, H5MorePlaylets } from "./episodes";
import { RelatedAside } from "./related-aside";
async function ensurePlayletAuthToken() {
let token = getAuthToken();
@@ -50,49 +55,12 @@ function resolvePlayletLikeFavorite(data: Awaited<ReturnType<typeof getPlayletIn
return {
isLike: Boolean(data?.isLike ?? info?.isLike),
isFavorite: Boolean(data?.isFavorite ?? info?.isFavorite),
displayCountLike: Number(info?.displayCountLike) || 0,
countCollect: Number(info?.countCollect) || 0,
};
}
type ActionProps = {
icon: string;
label: string;
active?: boolean;
onClick?: () => void;
href?: string;
};
function DetailAction({ icon, label, active = false, onClick, href }: ActionProps) {
const className = `flex items-center gap-2.5 text-base leading-[22px] transition-opacity hover:opacity-85 ${
active ? "text-[#F12B9E]" : "text-white/90"
}`;
const content: ReactNode = (
<>
<span
className={`flex size-9 items-center justify-center rounded-full ${active ? "bg-[#F12B9E]" : "bg-[#F12B9E]/10"}`}
>
<img src={icon} alt="" className="max-h-[21px] max-w-[22px]" />
</span>
{label}
</>
);
return href ? (
<Link href={href} className={className}>
{content}
</Link>
) : (
<button type="button" aria-pressed={active} onClick={onClick} className={`cursor-pointer ${className}`}>
{content}
</button>
);
}
export function DramaDetailPage({
detail,
related = [],
relatedAside,
promo,
adIcons,
adGrid,
@@ -100,8 +68,6 @@ export function DramaDetailPage({
}: {
detail: DramaDetail;
related?: RelatedDrama[];
/** 服务端渲染的热门推荐侧栏 */
relatedAside?: ReactNode;
/** 服务端渲染的 PC banner */
promo?: ReactNode;
/** 服务端渲染的 PC icon */
@@ -115,10 +81,11 @@ export function DramaDetailPage({
const [liking, setLiking] = useState(false);
const [collected, setCollected] = useState(Boolean(detail.isFavorite));
const [collecting, setCollecting] = useState(false);
const router = useRouter();
const tags = pickPlayletCardTags(detail);
const tags = tagLinkItems(pickPlayletCardTags(detail), "playlet");
const totalEpisode = totalEpisodeOf(detail);
const playEpisode = (episode: number) => router.push(playHref(detail.id, episode, detail.slug));
const firstHref = playHref(detail.id, 1, detail.slug);
const title = detail.title || "短剧";
const desc = detail.desc?.trim() || "";
const likeFavoriteKey = `${detail.id}:${detail.isLike}:${detail.isFavorite}`;
const [likeFavoriteSync, setLikeFavoriteSync] = useState(likeFavoriteKey);
@@ -211,205 +178,135 @@ export function DramaDetailPage({
}
};
return (
<main data-component="drama-detail-page" className="relative flex-1 bg-[#0E0E0E] lg:bg-[#060611]">
<div className="pointer-events-none absolute inset-x-0 top-0 h-[544px] overflow-hidden lg:hidden" aria-hidden="true">
<DecryptedImage src={detail.cover} fallback={IMG_PLACEHOLDER} alt="" priority className="size-full object-cover" />
<div className="absolute inset-0 bg-black/80" />
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.1)_0%,rgba(102,102,102,0)_40%,rgba(14,14,14,0.55)_70%,#0E0E0E_100%)]" />
</div>
<BackButton fallback="/short-drama/hot" className="absolute top-3 left-4 z-10 lg:hidden" />
const crumbs = [
{ name: "首页", href: "/" },
{ name: "短剧库", href: "/short-drama/hot" },
{ name: title },
];
const infoRows = [
{ label: "集数", value: `全 ${totalEpisode} 集` },
{ label: "状态", value: updateStatusLabel(detail.updateStatus) },
{ label: "上线", value: yearFromOnlineAt(detail.onlineAt) },
{ label: "题材", value: detail.themeNames?.slice(0, 3).join(" / ") },
{ label: "主演", value: detail.anchor?.trim() },
{ label: "热度", value: formatPlays(detail.countBrowse ?? detail.heatValue) },
];
const actions = (compact?: boolean) => (
<>
<HlAction kind="like" label={liked ? "已点赞" : "点赞"} active={liked} onClick={liking ? undefined : toggleLike} compact={compact} />
<HlAction
kind="collect"
label={collected ? "已收藏" : "收藏"}
active={collected}
onClick={collecting ? undefined : toggleCollect}
compact={compact}
/>
<HlAction kind="share" label="分享" onClick={share} compact={compact} />
</>
);
return (
<main data-component="hl-drama-detail" className="relative flex-1 bg-[#0C0B10]">
<SeoBreadcrumb items={crumbs} />
{/* PC */}
<div
data-pc-scale
className="relative hidden flex-col gap-8 pt-8 pb-12 lg:mx-auto lg:flex lg:w-[1700px] lg:[zoom:var(--page-scale,1)]"
className="relative hidden flex-col gap-10 pt-8 pb-14 lg:mx-auto lg:flex lg:w-[1700px] lg:[zoom:var(--page-scale,1)]"
>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "短剧详情" },
]}
/>
<PageBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "短剧详情" },
]}
/>
<PageBreadcrumb items={crumbs} />
<section className="flex h-[544px] gap-10 overflow-hidden rounded-[20px] bg-[rgba(14,13,29,0.92)] p-8 outline outline-1 -outline-offset-1 outline-[rgba(59,56,92,0.1)]">
<DecryptedImage
src={detail.cover}
fallback={IMG_PLACEHOLDER}
alt={detail.title}
priority
className="h-[480px] w-[360px] shrink-0 rounded-xl object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-[18px]">
<div className="flex gap-[9px] text-xs leading-[18px]">
<span className="flex h-[26px] items-center rounded-[13px] bg-[#F12B9E] px-[11px] text-white">全站免费</span>
{tags.map((tag) =>
cardTagCanLink(tag) ? (
<Link
key={tag.slug ?? tag.id ?? tag.name}
href={tagPageHref(tag, "playlet")}
className="flex h-[26px] items-center rounded-[13px] bg-[#171624] px-[11px] text-[#9E96AD] hover:text-white"
>
{tag.name}
</Link>
) : (
<span
key={tag.name}
className="flex h-[26px] items-center rounded-[13px] bg-[#171624] px-[11px] text-[#9E96AD]"
>
{tag.name}
</span>
),
)}
</div>
<p className="text-[40px] leading-[47px] text-white">{detail.title}</p>
<p className="text-sm whitespace-pre text-[#9E96AD]">
{yearFromOnlineAt(detail.onlineAt)} {detail.themeNames?.[0] || "地区未知"} 共 {totalEpisode} 集
</p>
<p className="line-clamp-6 text-sm leading-[22px] text-[#9E96AD]">简介:{detail.desc}</p>
<div className="flex h-[52px] items-center gap-6">
<Link
href={playHref(detail.id, 1, detail.slug)}
className="flex h-[45px] w-36 items-center justify-center rounded-[23px] bg-[#F12B9E] text-base leading-[22px] font-semibold text-white transition-colors hover:bg-[#F23DA8]"
>
立即播放
</Link>
<div className="flex w-[490px] items-center justify-between">
<DetailAction
icon="/images/icon_like.svg"
label={liked ? "已点赞" : "点赞"}
active={liked}
onClick={liking ? undefined : toggleLike}
/>
<span className="h-[19px] w-0.5 bg-white/10" />
<DetailAction
icon="/images/icon_collect.svg"
label={collected ? "已收藏" : "收藏"}
active={collected}
onClick={collecting ? undefined : toggleCollect}
/>
<span className="h-[19px] w-0.5 bg-white/10" />
<DetailAction icon="/images/icon_share.svg" label="分享" onClick={share} />
<span className="h-[19px] w-0.5 bg-white/10" />
<DetailAction icon="/images/icon_feedback.svg" label="反馈" href="/feedback" />
</div>
</div>
<div className="flex h-[88px] shrink-0 items-center justify-between rounded-xl bg-[linear-gradient(90deg,#301438_0%,#171A40_100%)] px-5">
<div className="flex flex-col">
<p className="text-sm leading-5 text-white">App 内继续看,不丢进度</p>
<p className="text-xs leading-[18px] text-[#9E96AD]">同步观看记录、收藏、金币余额和更新提醒。</p>
</div>
<article className="grid grid-cols-[minmax(0,1fr)_340px] gap-14 border-t-2 border-[#FFB020] pt-10">
<div className="flex min-w-0 flex-col gap-6">
<p className="text-sm leading-5 tracking-[0.2em] text-[#FFB020]">短剧 · {updateStatusLabel(detail.updateStatus)}</p>
<h1 className="text-[44px] leading-[56px] font-bold text-white">{title}</h1>
<InfoList rows={infoRows} />
<TagLinks tags={tags} />
{desc ? (
<section className="flex flex-col gap-3">
<SectionTitle>剧情介绍</SectionTitle>
<p className="max-w-[1100px] text-[15px] leading-7 whitespace-pre-line text-[#B5B3BE]">{desc}</p>
</section>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-3">
<HlPrimaryLink href={firstHref}>播放第 1 集</HlPrimaryLink>
{actions()}
<button
type="button"
onClick={() => openDownloadDialog()}
className="cursor-pointer h-[38px] w-[111px] rounded-[19px] bg-[linear-gradient(90deg,#F22BA1_0%,#4A59FF_100%)] text-base leading-[22px] text-white transition-opacity hover:opacity-90"
className="ml-2 cursor-pointer text-sm text-[#8E8C99] underline-offset-4 hover:text-[#FFB020] hover:underline"
>
下载
下载 APP 离线观看
</button>
</div>
</div>
</section>
<figure className="flex flex-col gap-3">
<DecryptedImage
src={detail.cover}
fallback={IMG_PLACEHOLDER}
alt={`${title}海报`}
priority
className="aspect-[2/3] w-full rounded-md object-cover"
/>
<figcaption className="text-center text-xs leading-[18px] text-[#6F6C7B]">《{title}》海报</figcaption>
</figure>
</article>
{adIcons ?? <AdIcons variant="pc" />}
<EpisodePanel detail={detail} current={1} onSelect={playEpisode} />
<EpisodeDirectory detail={detail} variant="panel" />
<div className="flex items-start gap-6">
<CommentPanel target={playletCommentTarget(detail.id)} />
{relatedAside ?? <RelatedAside items={related} />}
</div>
<DramaRecommend items={related} />
{promo ?? <AdBanners variant="pc" />}
</div>
<div className="relative flex flex-col gap-[18px] px-4 pt-[60px] pb-8 lg:hidden">
<section className="flex items-center gap-3">
<DecryptedImage
src={detail.cover}
fallback={IMG_PLACEHOLDER}
alt={detail.title}
className="h-[106px] w-[76px] shrink-0 rounded-md object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<h1 className="line-clamp-2 text-base leading-[22px] font-medium text-white">{detail.title}</h1>
<p className="flex flex-wrap items-start gap-1.5 text-xs leading-[18px] text-white/35">
<span>全{totalEpisode}集</span>
<span aria-hidden="true">·</span>
<span>{formatPlays(detail.countBrowse ?? detail.heatValue)}</span>
</p>
<div className="flex flex-wrap items-center gap-1.5 text-xs leading-[18px] text-white/90">
{tags.slice(0, 3).map((tag) =>
cardTagCanLink(tag) ? (
<Link
key={tag.slug ?? tag.id ?? tag.name}
href={tagPageHref(tag, "playlet")}
className="rounded-[3px] bg-white/10 px-1.5 py-0.5"
>
#{tag.name}
</Link>
) : (
<span key={tag.name} className="rounded-[3px] bg-white/10 px-1.5 py-0.5">
#{tag.name}
</span>
),
)}
{detail.anchor ? (
<span className="rounded-[3px] bg-white/10 px-1.5 py-0.5">演员·{detail.anchor}</span>
) : null}
{/* H5 */}
<div className="relative flex flex-col gap-6 px-4 pt-3 pb-10 lg:hidden">
<div className="flex h-9 items-center">
<BackButton fallback="/short-drama/hot" />
</div>
<article className="flex flex-col gap-4">
<div className="flex gap-4">
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
<p className="text-[11px] leading-4 tracking-[0.15em] text-[#FFB020]">短剧 · {updateStatusLabel(detail.updateStatus)}</p>
<h1 className="line-clamp-3 text-xl leading-7 font-bold text-white">{title}</h1>
<InfoList rows={infoRows.slice(0, 4)} compact />
</div>
<DecryptedImage
src={detail.cover}
fallback={IMG_PLACEHOLDER}
alt={`${title}海报`}
priority
className="aspect-[2/3] w-[104px] shrink-0 self-start rounded-md object-cover"
/>
</div>
</section>
{detail.desc?.trim() ? (
<section className="flex w-full flex-col gap-3">
<h2 className="text-base leading-[22px] font-medium text-white">简介</h2>
<p className="text-xs leading-[18px] text-white/65">{detail.desc.trim()}</p>
</section>
) : null}
<TagLinks tags={tags} compact />
<div className="flex flex-col gap-2">
<HlPrimaryLink href={firstHref} compact className="w-full">
播放第 1 集
</HlPrimaryLink>
<div className="grid grid-cols-3 gap-2">{actions(true)}</div>
</div>
{desc ? (
<section className="flex flex-col gap-2">
<SectionTitle compact>剧情介绍</SectionTitle>
<p className="text-[13px] leading-[22px] whitespace-pre-line text-[#A9A7B3]">{desc}</p>
</section>
) : null}
</article>
{adGrid ?? <AdIcons variant="h5" />}
<H5EpisodeStrip detail={detail} onSelect={playEpisode} />
<EpisodeDirectory detail={detail} variant="h5" />
<H5MorePlaylets items={related} />
<DramaRecommend items={related} compact />
{bannerAds ?? <AdBanners variant="h5" />}
</div>
<div
data-component="h5-bottom-bar"
data-h5-bottom-bar
className="fixed inset-x-0 bottom-0 z-40 flex h-[68px] items-center justify-center gap-3 bg-[#010101] px-4 pb-[env(safe-area-inset-bottom)] lg:hidden"
>
<button
type="button"
aria-pressed={collected}
disabled={collecting}
onClick={toggleCollect}
className={`cursor-pointer flex h-11 w-[108px] items-center justify-center gap-3 rounded-full bg-white text-base leading-[22px] font-medium disabled:opacity-70 ${
collected ? "text-[#F12B9E]" : "text-[#3D3D3D]"
}`}
>
<span
className={`size-[18px] [mask:url(/images/icon_collect.svg)_center/contain_no-repeat] ${
collected ? "bg-[#F12B9E]" : "bg-[#A3A3B8]"
}`}
/>
{collected ? "已收藏" : "收藏"}
</button>
<Link
href={playHref(detail.id, 1, detail.slug)}
className="flex h-11 w-[223px] items-center justify-center rounded-full bg-[#F12B9E] text-base leading-[22px] font-medium text-white"
>
开始播放
</Link>
</div>
<div className="max-lg:hidden">
<FloatingActions />
</div>
+65 -139
View File
@@ -2,36 +2,31 @@
import { toast } from "@heroui/react";
import Link from "next/link";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { useDramaContext } from "@/components/analytics/use-playback-analytics";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
CommentPanel,
playletCommentTarget,
type CommentTarget,
} from "@/components/common/comments";
import { pickPlayletCardTags } from "@/components/common/card-tags";
import {
dramaDetailHref,
getEpisodeState,
playHref,
totalEpisodeOf,
updateStatusLabel,
type DramaDetail,
type RelatedDrama,
} from "@/components/drama-detail/data";
import { RelatedAside } from "@/components/drama-detail/related-aside";
import { FloatingActions } from "@/components/home/floating-actions";
import { EpisodeDirectory } from "@/components/media-page/episode-directory";
import { DramaRecommend, HlAction, TagLinks, tagLinkItems } from "@/components/media-page/parts";
import { PageBreadcrumb } from "@/components/seo/page-breadcrumb";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import { favoritePlaylet, getPlayletInfo, likePlaylet } from "@/lib/api/playlet-actions";
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { H5Play } from "./h5-play";
import { PcPlayer } from "./pc-player";
import { PlayEpisodePanel } from "./play-episode-panel";
import { useReportPlayletHistory } from "./use-report-playlet-history";
const pcFrame = "mx-auto w-[1700px] [zoom:var(--page-scale,1)]";
const pcScaleProps = { "data-pc-scale": true as const };
@@ -67,46 +62,11 @@ function initialCountCollect(detail: DramaDetail) {
return Number(detail.countCollect) || 0;
}
type PillProps = {
icon: string;
label: string;
active?: boolean;
onClick?: () => void;
href?: string;
};
export function ActionPill({ icon, label, active = false, onClick, href }: PillProps) {
const className = `inline-flex h-[52px] w-auto shrink-0 items-center justify-center gap-2.5 rounded-[26px] bg-[rgba(31,23,31,0.55)] px-4 text-base leading-[22px] whitespace-nowrap outline outline-1 -outline-offset-1 outline-[rgba(255,227,196,0.28)] transition-colors hover:bg-[rgba(31,23,31,0.85)] ${
active ? "text-[#F12B9E]" : "text-white/90"
}`;
const content = (
<>
<span
className={`flex size-9 shrink-0 items-center justify-center rounded-full ${active ? "bg-[#F12B9E]" : "bg-[#F12B9E]/10"}`}
>
<img src={icon} alt="" className="max-h-[21px] max-w-[22px]" />
</span>
{label}
</>
);
return href ? (
<Link href={href} className={className}>
{content}
</Link>
) : (
<button type="button" aria-pressed={active} onClick={onClick} className={`cursor-pointer ${className}`}>
{content}
</button>
);
}
export function DramaPlayPage({
detail,
episode,
src,
related = [],
relatedAside,
adIcons,
promo,
}: {
@@ -114,7 +74,6 @@ export function DramaPlayPage({
episode: number;
src: string;
related?: RelatedDrama[];
relatedAside?: ReactNode;
adIcons?: ReactNode;
promo?: ReactNode;
}) {
@@ -127,24 +86,11 @@ export function DramaPlayPage({
const [expanded, setExpanded] = useState(false);
const locked = getEpisodeState(episode, detail) === "locked" || !src;
const playSrc = locked ? "" : src;
const episodeLabel = String(episode).padStart(2, "0");
const chapter = detail.chapterList?.find((item) => Number(item.currentEpisode) === episode);
const tagName = detail.tagNames?.[0] || detail.themeNames?.[0] || "";
const dramaTrackContext = useMemo(
() => ({
media_id: String(detail.id || ""),
drama_id: String(detail.id || ""),
drama_title: detail.title || "未命名短剧",
episode: Math.max(1, episode),
video_id: String(chapter?.id || detail.id || ""),
drama_duration: Math.max(0, Math.round(Number(chapter?.mediaTime) || 0)),
recommend_trace_id: "",
drama_tag_key: tagName ? "tag" : "default",
drama_tag_name: tagName || "默认标签",
}),
[detail.id, detail.title, episode, chapter?.id, chapter?.mediaTime, tagName],
);
useDramaContext(dramaTrackContext);
const title = detail.title || "短剧";
const totalEpisode = totalEpisodeOf(detail);
const prevHref = episode > 1 ? playHref(detail.id, episode - 1, detail.slug) : null;
const nextHref = episode < totalEpisode ? playHref(detail.id, episode + 1, detail.slug) : null;
const tags = tagLinkItems(pickPlayletCardTags(detail), "playlet");
const likeFavoriteKey = `${detail.id}:${detail.isLike}:${detail.isFavorite}:${detail.countCollect}:${detail.displayCountLike}`;
const [likeFavoriteSync, setLikeFavoriteSync] = useState(likeFavoriteKey);
@@ -268,107 +214,89 @@ export function DramaPlayPage({
}
};
const chapterId = detail.chapterList?.find((item) => Number(item.currentEpisode) === episode)?.id;
/** 与短剧详情共用短剧维度评论,不按单集拆分 */
const commentTarget: CommentTarget | null = playletCommentTarget(detail.id);
const reportWatchHistory = useReportPlayletHistory(Number(detail.id), Number(chapterId) || undefined);
const crumbs = [
{ name: "首页", href: "/" },
{ name: "短剧库", href: "/short-drama/hot" },
{ name: title, href: dramaDetailHref(detail) },
{ name: `第${episode}集` },
];
return (
<main data-component="drama-play-page" className="relative flex-1 bg-[#0B0709]">
{!expanded ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 hidden h-[1200px] overflow-hidden [mask-image:linear-gradient(180deg,#000_70%,transparent_100%)] lg:block"
>
<DecryptedImage
src={detail.cover}
fallback={IMG_PLACEHOLDER}
alt=""
className="size-full scale-110 object-cover opacity-70 blur-[12px]"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(9,5,9,0.32)_0%,rgba(9,5,9,0.56)_55%,rgba(6,5,9,0.94)_100%)]" />
</div>
) : null}
<main data-component="hl-drama-play" className="relative flex-1 bg-[#0C0B10]">
<SeoBreadcrumb items={crumbs} />
<div
{...(expanded ? {} : pcScaleProps)}
className={`relative hidden flex-col gap-6 pb-8 lg:flex ${expanded ? "" : `pt-6 ${pcFrame}`}`}
className={`relative hidden flex-col gap-8 pb-14 lg:flex ${expanded ? "" : `pt-6 ${pcFrame}`}`}
>
{!expanded ? (
<>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "短剧", href: dramaDetailHref(detail) },
{ name: `第${episode}集` },
]}
/>
<PageBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "短剧", href: dramaDetailHref(detail) },
{ name: `第${episode}集` },
]}
/>
</>
) : null}
{!expanded ? <PageBreadcrumb items={crumbs} /> : null}
<div className={expanded ? "contents" : "flex flex-col gap-4"}>
<div className={expanded ? "contents" : "flex flex-col gap-8"}>
<div className={expanded ? "contents" : "flex h-[648px] gap-5"}>
<PcPlayer
key={`${episode}-${playSrc}`}
src={playSrc}
poster={detail.cover || ""}
title={`${detail.title || ""} 第${episode}集`}
title={`${title} 第${episode}集`}
locked={locked}
episode={episode}
dramaId={detail.slug?.trim() || String(detail.id)}
dramaTitle={detail.title || ""}
dramaTitle={title}
layout={expanded ? "theater" : "inline"}
onToggleExpand={toggleExpanded}
onPlaySuccess={reportWatchHistory}
/>
{!expanded ? <PlayEpisodePanel detail={detail} current={episode} /> : null}
{!expanded ? <EpisodeDirectory detail={detail} current={episode} variant="side" /> : null}
</div>
<div
{...(expanded ? pcScaleProps : {})}
className={expanded ? `flex flex-col gap-4 ${pcFrame}` : "contents"}
className={expanded ? `flex flex-col gap-8 ${pcFrame}` : "contents"}
>
{expanded ? <PlayEpisodePanel detail={detail} current={episode} wide /> : null}
{adIcons ?? <AdIcons variant="pc" />}
<section className="flex h-[140px] items-center justify-between rounded-xl bg-[rgba(19,15,22,0.9)] px-6 py-5 outline outline-1 -outline-offset-1 outline-[rgba(199,145,89,0.7)]">
<div className="flex min-w-0 flex-col gap-1.5">
<p className="text-sm leading-5 text-[#F12B9E]">第 {episode} 集</p>
<p className="truncate text-[28px] leading-[34px] font-semibold text-white">{detail.title}</p>
<p className="text-sm leading-5 text-[#9991AB]">
{detail.title} - 第{episodeLabel}集
<article className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-10 gap-y-5 border-t-2 border-[#FFB020] pt-8">
<div className="flex min-w-0 flex-col gap-3">
<p className="text-sm leading-5 tracking-[0.2em] text-[#FFB020]">
第 {episode} / {totalEpisode} 集 · {updateStatusLabel(detail.updateStatus)}
</p>
<h1 className="text-4xl leading-[48px] font-bold text-white">
{title}
<span className="ml-3 text-[#FFB020]">第{episode}集</span>
</h1>
<TagLinks tags={tags} />
{detail.desc?.trim() ? (
<p className="line-clamp-3 max-w-[1100px] text-[15px] leading-7 text-[#B5B3BE]">{detail.desc.trim()}</p>
) : null}
</div>
<div className="flex shrink-0 gap-3">
<ActionPill
icon="/images/icon_like.svg"
label={liked ? "已点赞" : "点赞"}
active={liked}
onClick={toggleLike}
/>
<ActionPill
icon="/images/icon_collect.svg"
<div className="flex shrink-0 gap-3 pt-7">
<HlAction kind="like" label={liked ? "已点赞" : "点赞"} active={liked} onClick={toggleLike} />
<HlAction
kind="collect"
label={collected ? "已收藏" : "收藏"}
active={collected}
onClick={collecting ? undefined : toggleCollect}
/>
<ActionPill icon="/images/icon_share.svg" label="分享" onClick={share} />
<ActionPill icon="/images/icon_feedback.svg" label="反馈" href="/feedback" />
<HlAction kind="share" label="分享" onClick={share} />
</div>
</section>
<nav aria-label="分集导航" className="col-span-2 flex items-center gap-3 text-sm">
{prevHref ? (
<Link href={prevHref} className="rounded-md border border-white/12 px-4 py-2 text-[#D8D6DF] hover:border-[#FFB020] hover:text-[#FFB020]">
‹ 上一集:第{episode - 1}集
</Link>
) : null}
{nextHref ? (
<Link href={nextHref} className="rounded-md border border-white/12 px-4 py-2 text-[#D8D6DF] hover:border-[#FFB020] hover:text-[#FFB020]">
下一集:第{episode + 1}集 ›
</Link>
) : null}
<Link href={dramaDetailHref(detail)} className="ml-auto text-[#8E8C99] hover:text-[#FFB020]">
《{title}》剧情介绍与全部分集 ›
</Link>
</nav>
</article>
<div className="flex items-start gap-6">
<CommentPanel target={commentTarget} />
{relatedAside ?? <RelatedAside items={related} />}
</div>
{expanded ? <EpisodeDirectory detail={detail} current={episode} variant="panel" /> : null}
{adIcons ?? <AdIcons variant="pc" />}
<DramaRecommend items={related} />
{promo ?? <AdBanners variant="pc" />}
</div>
@@ -389,8 +317,6 @@ export function DramaPlayPage({
onLike={toggleLike}
onCollect={toggleCollect}
onShare={share}
commentTarget={commentTarget}
onPlaySuccess={reportWatchHistory}
/>
<div className="max-lg:hidden">
+80 -314
View File
@@ -5,25 +5,24 @@ import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { BackButton } from "@/components/common/back-button";
import { H5CommentSheet, type CommentTarget } from "@/components/common/comments";
import { pickPlayletCardTags } from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
dramaDetailHref,
getEpisodeState,
playHref,
totalEpisodeOf,
updateStatusLabel,
type DramaDetail,
type RelatedDrama,
} from "@/components/drama-detail/data";
import { MorePlayletsGrid } from "@/components/drama-detail/episodes";
import { openAppDownload } from "@/components/download/download-dialog";
import { EpisodeDirectory } from "@/components/media-page/episode-directory";
import { DramaRecommend, HlAction, TagLinks, tagLinkItems } from "@/components/media-page/parts";
import { playbackRates, XgPlayer, type XgPlayerInstance } from "@/components/player/xg-player";
import { CheckIcon, ChevronIcon } from "./icons";
import { useEpisodeRange } from "./play-episode-panel";
type Sheet = "episodes" | "speed" | "comments" | null;
type Sheet = "speed" | null;
/** 播完自动下一集倒计时(秒) */
const AUTO_NEXT_SECONDS = 3;
@@ -64,7 +63,6 @@ type H5PlayProps = {
onLike: () => void;
onCollect: () => void;
onShare: () => void;
commentTarget: CommentTarget | null;
onPlaySuccess?: () => void;
};
@@ -75,7 +73,7 @@ function LockedNotice() {
<button
type="button"
onClick={openAppDownload}
className="cursor-pointer inline-flex h-9 items-center justify-center rounded-full bg-[#F12B9E] px-6 text-sm font-medium text-white"
className="cursor-pointer inline-flex h-9 items-center justify-center rounded-md bg-[#FFB020] px-6 text-sm font-semibold text-[#1A1206]"
>
下载APP观看完整剧集
</button>
@@ -140,147 +138,6 @@ function SheetHead({ title, onClose, children }: { title: ReactNode; onClose: ()
);
}
function EpisodeSheet({
detail,
episode,
collected,
related = [],
onCollect,
onClose,
}: Pick<H5PlayProps, "detail" | "episode" | "collected" | "related" | "onCollect"> & {
onClose: () => void;
}) {
const { ranges, rangeIndex, setRangeIndex, episodes } = useEpisodeRange(detail, episode);
const [tab, setTab] = useState<"episodes" | "more">("episodes");
return (
<div className="flex max-h-[75vh] flex-col gap-3 px-4 pt-2.5 pb-[18px]">
<span className="mx-auto h-[5px] w-9 shrink-0 rounded-full bg-white/20" />
<div className="flex items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<DecryptedImage
src={detail.cover}
alt=""
className="h-[75px] w-[54px] shrink-0 rounded-[3px] object-cover"
/>
<div className="flex min-w-0 flex-col gap-2">
<Link href={dramaDetailHref(detail)} className="flex items-center gap-2 text-sm leading-5 text-white">
<span className="truncate">{detail.title}</span>
<ChevronIcon direction="right" className="size-3 shrink-0" />
</Link>
<p className="text-xs leading-[18px] text-white/35">
{updateStatusLabel(detail.updateStatus)} 共{totalEpisodeOf(detail)}集
</p>
</div>
</div>
<button
type="button"
aria-pressed={collected}
onClick={onCollect}
className={`cursor-pointer shrink-0 rounded-full px-3 py-1 text-[10px] leading-4 ${collected ? "bg-white/5 text-[#F12B9E]" : "bg-[#F12B9E] text-white"}`}
>
{collected ? "已收藏" : "收藏"}
</button>
</div>
<div className="flex shrink-0 gap-6 overflow-x-auto text-sm leading-5 [scrollbar-width:none]">
{ranges.map((range, index) => (
<button
key={range.label}
type="button"
aria-pressed={tab === "episodes" && rangeIndex === index}
onClick={() => {
setTab("episodes");
setRangeIndex(index);
}}
className={`cursor-pointer shrink-0 ${tab === "episodes" && rangeIndex === index ? "text-white" : "text-white/35"}`}
>
{range.start}-{range.end}
</button>
))}
<button
type="button"
aria-pressed={tab === "more"}
onClick={() => setTab("more")}
className={`cursor-pointer shrink-0 ${tab === "more" ? "text-white" : "text-white/35"}`}
>
更多好剧
</button>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain">
{tab === "episodes" ? (
<div className="grid grid-cols-5 gap-x-[11px] gap-y-2">
{episodes.map((item) => {
const active = item === episode;
const locked = getEpisodeState(item, detail) === "locked";
if (active) {
return (
<Link
key={item}
href={playHref(detail.id, item, detail.slug)}
replace
onClick={onClose}
aria-current="true"
className="relative flex h-[58px] items-center justify-center overflow-hidden rounded-md bg-[rgba(241,43,158,0.1)] text-base leading-[22px] text-[#F12B9E] outline outline-1 -outline-offset-1 outline-[#F12B9E]"
>
{item}
<img src="/images/playing.svg" alt="" className="absolute top-0 right-0 size-5 p-[3px]" />
{!locked ? (
<span className="absolute top-0 left-0 flex h-[14px] w-[25px] items-center justify-center rounded-sm bg-[linear-gradient(136deg,#74FFED_0%,#10B9A2_100%)] text-[8px] leading-3 text-[#002D27]">
免费
</span>
) : null}
</Link>
);
}
if (!locked) {
return (
<Link
key={item}
href={playHref(detail.id, item, detail.slug)}
replace
onClick={onClose}
className="relative flex h-[58px] items-center justify-center overflow-hidden rounded-md bg-[#3A3D53] text-base leading-[22px] text-white"
>
{item}
<span className="absolute top-0 right-0 flex h-[14px] w-[25px] items-center justify-center rounded-sm bg-[linear-gradient(136deg,#74FFED_0%,#10B9A2_100%)] text-[8px] leading-3 text-[#002D27]">
免费
</span>
</Link>
);
}
return (
<Link
key={item}
href={playHref(detail.id, item, detail.slug)}
replace
onClick={onClose}
aria-label={`第${item}集(未解锁)`}
className="relative flex h-[58px] items-center justify-center gap-0.5 overflow-hidden rounded-md bg-white/10 text-base leading-[22px] text-[#828186] outline outline-1 -outline-offset-1 outline-white/5"
>
{item}
<svg viewBox="0 0 18 18" fill="none" className="size-3.5" aria-hidden="true">
<path
d="M5.29 7.65V5.25a3.71 3.71 0 0 1 7.42 0v2.4"
stroke="currentColor"
strokeWidth="1.35"
strokeLinecap="round"
/>
<rect x="3.75" y="7.5" width="10.5" height="8.25" rx="1.5" stroke="currentColor" strokeWidth="1.35" />
</svg>
</Link>
);
})}
</div>
) : (
<MorePlayletsGrid items={related} />
)}
</div>
</div>
);
}
export function H5Play({
detail,
episode,
@@ -294,14 +151,12 @@ export function H5Play({
onLike,
onCollect,
onShare,
commentTarget,
onPlaySuccess,
}: H5PlayProps) {
const router = useRouter();
const playerRef = useRef<XgPlayerInstance | null>(null);
const [rate, setRate] = useState(1);
const [sheet, setSheet] = useState<Sheet>(null);
const [cleared, setCleared] = useState(false);
const [introOpen, setIntroOpen] = useState(false);
/** null=未进入自动下一集;数字=剩余秒数 */
const [autoNextSeconds, setAutoNextSeconds] = useState<number | null>(null);
@@ -309,10 +164,14 @@ export function H5Play({
const [shouldAutoplay] = useState(consumeAutoplayFlag);
const closeSheet = () => setSheet(null);
const title = detail.title || "短剧";
const totalEpisode = totalEpisodeOf(detail);
const nextEpisode = episode + 1;
const hasNext = nextEpisode <= totalEpisode;
const nextHref = hasNext ? playHref(detail.id, nextEpisode, detail.slug) : null;
const prevHref = episode > 1 ? playHref(detail.id, episode - 1, detail.slug) : null;
const tags = tagLinkItems(pickPlayletCardTags(detail), "playlet");
const desc = detail.desc?.trim() || "";
useEffect(() => {
if (!sheet) return;
@@ -357,47 +216,11 @@ export function H5Play({
const likeCount = displayCountLike ?? (Number(detail.displayCountLike) || 0);
const collectCount = countCollect ?? (Number(detail.countCollect) || 0);
/** 已点赞 #F52C56 · 已收藏 #F68902 · 文案始终 #DCDCDC */
const sideItems = [
{
label: formatCount(likeCount),
icon: "/images/icon_like.svg",
active: liked,
activeColor: "#F52C56",
onClick: onLike,
},
{
label: "评论",
icon: "/images/comment.svg",
active: false,
activeColor: "#FFFFFF",
onClick: () => setSheet("comments"),
},
{
label: formatCount(collectCount),
icon: "/images/icon_collect.svg",
active: collected,
activeColor: "#F68902",
onClick: onCollect,
},
{
label: "分享",
icon: "/images/share_h5.svg",
active: false,
activeColor: "#FFFFFF",
onClick: onShare,
},
];
return (
<div data-component="drama-h5-play" className="fixed inset-0 z-30 flex flex-col bg-[#0E0E0E] lg:hidden">
<div className="relative min-h-0 flex-1 overflow-hidden">
<div data-component="hl-drama-play-h5" className="flex flex-col bg-[#0C0B10] lg:hidden">
<div className="sticky top-0 z-30 aspect-[3/4] max-h-[72vh] w-full overflow-hidden bg-black">
{locked ? (
<DecryptedImage
src={detail.cover}
alt={detail.title}
className="absolute inset-0 size-full object-cover"
/>
<DecryptedImage src={detail.cover} alt={`${title} 第${episode}集`} className="absolute inset-0 size-full object-cover" />
) : (
<XgPlayer
src={src}
@@ -410,146 +233,93 @@ export function H5Play({
}}
onPlaySuccess={onPlaySuccess}
onEnded={handleEnded}
analyticsKind="drama"
clickToPlay={!shouldAutoplay}
className="absolute inset-0"
options={{ ignores: ["playbackrate", "cssfullscreen"] }}
/>
)}
<div className="pointer-events-none absolute inset-x-0 top-0 h-[min(300px,40%)] bg-[linear-gradient(180deg,#000_0%,rgba(0,0,0,0)_100%)]" />
{!cleared ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[min(212px,30%)] bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,rgba(0,0,0,0.75)_100%)]" />
) : null}
<div className="absolute inset-x-0 top-0 z-20 flex items-center gap-1 px-4 pt-[calc(12px+env(safe-area-inset-top))] pb-3">
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-[linear-gradient(180deg,rgba(0,0,0,0.7)_0%,rgba(0,0,0,0)_100%)]" />
<div className="absolute inset-x-0 top-0 z-20 flex items-center gap-2 px-3 pt-[calc(8px+env(safe-area-inset-top))] pb-2">
<BackButton fallback={dramaDetailHref(detail)} />
<p className="min-w-0 flex-1 truncate text-lg leading-6 font-medium text-white">第{episode}集</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setSheet("speed")}
className="cursor-pointer flex items-center gap-1 text-xs leading-[18px] font-medium text-white"
>
<img src="/images/speed.svg" alt="" className="size-[18px]" />
{rate === 1 ? "1.0" : rate}x
</button>
<Link href="/feedback" aria-label="意见反馈" className="size-6 shrink-0">
<img src="/images/feedback.svg" alt="" className="size-full" />
</Link>
</div>
<p className="min-w-0 flex-1 truncate text-sm leading-5 text-white/90">
第 {episode} / {totalEpisode} 集
</p>
<button
type="button"
onClick={() => setSheet("speed")}
className="cursor-pointer flex items-center gap-1 rounded bg-black/40 px-2 py-1 text-xs leading-[18px] font-medium text-white"
>
<img src="/images/speed.svg" alt="" className="size-4" />
{rate === 1 ? "1.0" : rate}x
</button>
</div>
{locked ? <LockedNotice /> : null}
{!cleared ? (
<>
<div className="pointer-events-none absolute right-4 bottom-[120px] z-10 inline-flex flex-col items-center gap-[18px]">
<div className="flex flex-col items-center gap-2.5">
{sideItems.map((item) => (
<button
key={item.icon}
type="button"
aria-pressed={item.active}
onClick={item.onClick}
className="pointer-events-auto cursor-pointer flex flex-col items-center text-center text-xs leading-[18px] font-medium text-[#DCDCDC]"
>
<span className="relative size-[30px] overflow-hidden">
<span
aria-hidden="true"
className="absolute inset-0"
style={{
background: item.active ? item.activeColor : "#FFFFFF",
mask: `url(${item.icon}) center / 24px no-repeat`,
WebkitMask: `url(${item.icon}) center / 24px no-repeat`,
}}
/>
</span>
{item.label}
</button>
))}
</div>
</div>
<div className="pointer-events-none absolute bottom-[68px] left-4 z-10 flex w-[min(278px,calc(100%-90px))] flex-col gap-2.5">
<button
type="button"
aria-label="选集"
onClick={() => setSheet("episodes")}
className="pointer-events-auto flex max-w-full cursor-pointer items-center gap-[5px] text-left text-xs leading-[18px] font-medium text-white"
>
<h1 className="min-w-0 truncate">{detail.title}</h1>
<ChevronIcon direction="right" className="size-[18px] shrink-0" />
</button>
<div className="flex items-end gap-1.5">
<p
className={`max-w-[248px] text-xs leading-[18px] text-white/65 ${introOpen ? "pointer-events-auto max-h-40 overflow-y-auto" : "line-clamp-2"}`}
>
{detail.desc}
</p>
<button
type="button"
onClick={() => setIntroOpen((open) => !open)}
className="pointer-events-auto cursor-pointer shrink-0 text-xs leading-[18px] text-white"
>
{introOpen ? "收起" : "展开"}
</button>
</div>
</div>
</>
) : null}
{autoNextSeconds != null && hasNext ? (
<button
type="button"
onClick={goNextEpisode}
className="absolute inset-x-4 bottom-3 z-20 flex cursor-pointer items-center justify-between rounded-md bg-white/10 px-3 py-1.5 text-sm leading-5"
className="absolute inset-x-3 bottom-3 z-20 flex cursor-pointer items-center justify-between rounded-md bg-black/60 px-3 py-2 text-sm leading-5"
>
<span className="text-white">自动播放下一集</span>
<span className="text-[#FFAB6B]">倒计时{autoNextSeconds}s</span>
<span className="text-white">即将播放第 {nextEpisode} 集</span>
<span className="text-[#FFB020]">{autoNextSeconds}s</span>
</button>
) : null}
</div>
<div className="flex min-h-[58px] shrink-0 items-center gap-2.5 bg-[#1A1A1A] px-4 pt-1.5 pb-[calc(6px+env(safe-area-inset-bottom))]">
<button
type="button"
onClick={() => setSheet("episodes")}
className="cursor-pointer flex min-w-0 flex-1 items-center justify-between rounded-[3px] bg-white/5 px-3 py-1.5 text-xs leading-[18px] text-white"
>
<span className="truncate">选集·全{totalEpisode}集·免费观看</span>
<ChevronIcon direction="up" className="size-6 shrink-0" />
</button>
{cleared ? (
<button
type="button"
aria-label="评论"
onClick={() => setSheet("comments")}
className="cursor-pointer rounded-[3px] bg-white/5 px-3 py-1.5 text-white"
>
<img src="/images/comment.svg" alt="" className="size-6" />
</button>
) : null}
<button
type="button"
aria-label={cleared ? "退出清屏" : "清屏"}
aria-pressed={cleared}
onClick={() => setCleared((value) => !value)}
className="cursor-pointer text-white"
>
<img src={cleared ? "/images/unscreen.svg" : "/images/screen.svg"} alt="" className="size-6" />
</button>
</div>
<div className="flex flex-col gap-6 px-4 pt-4 pb-10">
<article className="flex flex-col gap-3">
<p className="text-[11px] leading-4 tracking-[0.15em] text-[#FFB020]">
短剧 · {updateStatusLabel(detail.updateStatus)} · 共{totalEpisode}集
</p>
<h1 className="text-lg leading-7 font-bold text-white">
{title}
<span className="ml-2 text-[#FFB020]">第{episode}集</span>
</h1>
<TagLinks tags={tags} compact />
{desc ? (
<div className="flex items-end gap-1.5">
<p className={`min-w-0 flex-1 text-[13px] leading-[22px] text-[#A9A7B3] ${introOpen ? "" : "line-clamp-2"}`}>{desc}</p>
<button
type="button"
onClick={() => setIntroOpen((open) => !open)}
className="cursor-pointer shrink-0 text-xs leading-[22px] text-[#FFB020]"
>
{introOpen ? "收起" : "展开"}
</button>
</div>
) : null}
<div className="flex items-center gap-2">
<HlAction kind="like" label={formatCount(likeCount)} active={liked} onClick={onLike} compact />
<HlAction kind="collect" label={formatCount(collectCount)} active={collected} onClick={onCollect} compact />
<HlAction kind="share" label="分享" onClick={onShare} compact />
</div>
<nav aria-label="分集导航" className="grid grid-cols-2 gap-2 text-xs leading-[18px]">
{prevHref ? (
<Link href={prevHref} className="flex items-center gap-1 rounded-md border border-white/12 px-3 py-2 text-[#D8D6DF]">
<ChevronIcon direction="left" className="size-3.5" />
上一集 · 第{episode - 1}集
</Link>
) : (
<span />
)}
{nextHref ? (
<Link
href={nextHref}
className="flex items-center justify-end gap-1 rounded-md border border-white/12 px-3 py-2 text-[#D8D6DF]"
>
下一集 · 第{nextEpisode}集
<ChevronIcon direction="right" className="size-3.5" />
</Link>
) : null}
</nav>
</article>
<BottomSheet open={sheet === "episodes"} onClose={closeSheet} label="选集">
<EpisodeSheet
detail={detail}
episode={episode}
collected={collected}
related={related}
onCollect={onCollect}
onClose={closeSheet}
/>
</BottomSheet>
<EpisodeDirectory detail={detail} current={episode} variant="h5" />
<DramaRecommend items={related} compact />
</div>
<BottomSheet open={sheet === "speed"} onClose={closeSheet} label="倍速" className="gap-3 px-4 py-[18px]">
<SheetHead title="倍速" onClose={closeSheet} />
@@ -565,17 +335,13 @@ export function H5Play({
setRate(item);
closeSheet();
}}
className={`cursor-pointer flex h-12 items-center justify-between text-base leading-[22px] ${item === rate ? "text-[#F12B9E]" : "text-white"}`}
className={`cursor-pointer flex h-12 items-center justify-between text-base leading-[22px] ${item === rate ? "text-[#FFB020]" : "text-white"}`}
>
{item}x{item === rate ? <CheckIcon /> : null}
</button>
))}
</div>
</BottomSheet>
<BottomSheet open={sheet === "comments"} onClose={closeSheet} label="评论">
<H5CommentSheet onClose={closeSheet} target={commentTarget} />
</BottomSheet>
</div>
);
}
-3
View File
@@ -177,7 +177,6 @@ type PcPlayerProps = PlayerProps & {
layout: PlayerLayout;
onToggleExpand?: () => void;
onPlaySuccess?: () => void;
analyticsKind?: "drama" | "video";
};
export function PcPlayer({
@@ -191,7 +190,6 @@ export function PcPlayer({
layout,
onToggleExpand,
onPlaySuccess,
analyticsKind = "drama",
}: PcPlayerProps) {
const playerRef = useRef<XgPlayerInstance | null>(null);
const toggleRef = useRef(onToggleExpand);
@@ -245,7 +243,6 @@ export function PcPlayer({
playerRef={playerRef}
onReady={onReady}
onPlaySuccess={onPlaySuccess}
analyticsKind={analyticsKind}
clickToPlay
className="absolute inset-0"
options={{ cssFullscreen: Boolean(onToggleExpand) }}
-449
View File
@@ -1,449 +0,0 @@
"use client";
import { Label, ListBox, Select } from "@heroui/react";
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { uploadImage } from "@/lib/api/auth";
import { ApiError } from "@/lib/api/client";
import { submitQuestionnaire } from "@/lib/api/feedback";
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
import { ensureGuestSession, getAuthToken, getAuthUser } from "@/lib/auth/session";
import {
detectDeviceType,
detectNetworkType,
deviceTypes,
frequencies,
networkTypes,
operators,
provinces,
questionTypes,
} from "./feedback-options";
const MAX_FILES = 3;
const MAX_SIZE = 5 * 1024 * 1024;
const ACCEPT_TYPES = new Set(["image/png", "image/jpeg", "image/jpg"]);
const labelClass =
"text-xs leading-[18px] font-bold text-[#FAF7FF] lg:text-sm lg:leading-5 lg:font-semibold lg:text-[#F5F2FC]";
const controlClass =
"w-full rounded-xl bg-[#1C1726] px-3 text-xs leading-[18px] text-[#FAF7FF] outline-none placeholder:text-[#9E94AD] focus:outline focus:outline-1 focus:outline-[#F12B9E]/60 lg:bg-[#13121C] lg:px-4 lg:text-sm lg:leading-5 lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-[#2E293D] lg:placeholder:text-[#948CA8] lg:focus:outline-[#F12B9E]/60";
type PreviewFile = { id: string; file: File; url: string };
function isAcceptedImage(file: File) {
if (ACCEPT_TYPES.has(file.type)) return true;
return /\.(png|jpe?g)$/i.test(file.name || "");
}
function FieldSelect({
label,
placeholder,
value,
options,
onChange,
}: {
label: string;
placeholder: string;
value: string | null;
options: readonly string[];
onChange: (value: string | null) => void;
}) {
return (
<Select
aria-label={label}
className="flex w-full flex-col gap-3 lg:gap-2"
placeholder={placeholder}
value={value}
onChange={(next) => onChange(next == null ? null : String(next))}
>
{/* Label 必须放在 Select 内,否则 React Aria 会报缺少 aria-label */}
<Label className={labelClass}>{label}</Label>
<Select.Trigger className={`${controlClass} flex h-11 items-center justify-between gap-2 pr-3 lg:h-[50px] lg:pr-4`}>
<Select.Value className="min-w-0 flex-1 truncate text-left data-[placeholder=true]:text-[#9E94AD] lg:data-[placeholder=true]:text-[#948CA8]" />
<Select.Indicator className="size-4 shrink-0 text-[#948CA8]" />
</Select.Trigger>
<Select.Popover className="max-h-64 overflow-auto rounded-xl bg-[#1C1726] outline outline-1 -outline-offset-1 outline-[#2E293D] lg:bg-[#13121C]">
<ListBox aria-label={label} className="p-1">
{options.map((item) => (
<ListBox.Item
key={item}
id={item}
textValue={item}
className="cursor-pointer rounded-lg px-3 py-2.5 text-sm leading-5 text-[#FAF7FF] outline-none data-[focused=true]:bg-[#F12B9E]/20 data-[selected=true]:text-[#F12B9E]"
>
{item}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
);
}
export function FeedbackForm() {
const [questionType, setQuestionType] = useState<string | null>(null);
const [frequency, setFrequency] = useState<string | null>("偶尔出现");
const [deviceType, setDeviceType] = useState<string | null>(null);
const [networkType, setNetworkType] = useState<string | null>(null);
const [operator, setOperator] = useState<string | null>(null);
const [province, setProvince] = useState<string | null>(null);
const [city, setCity] = useState("");
const [deviceModel, setDeviceModel] = useState("");
const [description, setDescription] = useState("");
const [contact, setContact] = useState("");
const [files, setFiles] = useState<PreviewFile[]>([]);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<{ tone: "error" | "success"; text: string } | null>(null);
useEffect(() => {
setDeviceType(detectDeviceType());
setNetworkType(detectNetworkType());
}, []);
const pickFiles = (event: ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(event.target.files ?? []);
event.target.value = "";
if (!picked.length) return;
const remain = MAX_FILES - files.length;
if (remain <= 0) {
setMessage({ tone: "error", text: `最多上传 ${MAX_FILES} 张图片` });
return;
}
const next: PreviewFile[] = [];
let skippedType = 0;
let skippedSize = 0;
let skippedLimit = 0;
for (let i = 0; i < picked.length; i++) {
if (next.length >= remain) {
skippedLimit += picked.length - i;
break;
}
const file = picked[i];
if (!isAcceptedImage(file)) {
skippedType += 1;
continue;
}
if (file.size > MAX_SIZE) {
skippedSize += 1;
continue;
}
next.push({
id: `${Date.now()}-${i}-${file.name}`,
file,
url: URL.createObjectURL(file),
});
}
if (skippedType) setMessage({ tone: "error", text: "仅支持 PNG / JPG 图片" });
else if (skippedSize) setMessage({ tone: "error", text: "单张图片不能超过 5MB" });
else if (skippedLimit) setMessage({ tone: "error", text: `最多上传 ${MAX_FILES} 张图片` });
else setMessage(null);
if (next.length) setFiles((current) => [...current, ...next]);
};
const removeFile = (id: string) => {
setFiles((current) => {
const target = current.find((item) => item.id === id);
if (target) URL.revokeObjectURL(target.url);
return current.filter((item) => item.id !== id);
});
};
const resetForm = () => {
files.forEach((item) => URL.revokeObjectURL(item.url));
setQuestionType(null);
setFrequency("偶尔出现");
setDeviceType(detectDeviceType());
setNetworkType(detectNetworkType());
setOperator(null);
setProvince(null);
setCity("");
setDeviceModel("");
setDescription("");
setContact("");
setFiles([]);
};
const submit = async (event: FormEvent) => {
event.preventDefault();
if (submitting) return;
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
if (!token) {
setMessage({ tone: "error", text: "请先登录后再提交反馈" });
openLoginDialog();
return;
}
if (!questionType) {
setMessage({ tone: "error", text: "请选择问题类型" });
return;
}
if (!frequency) {
setMessage({ tone: "error", text: "请选择出现频率" });
return;
}
if (!deviceType) {
setMessage({ tone: "error", text: "请选择设备类型" });
return;
}
if (!networkType) {
setMessage({ tone: "error", text: "请选择网络类型" });
return;
}
if (!operator) {
setMessage({ tone: "error", text: "请选择运营商" });
return;
}
if (!province) {
setMessage({ tone: "error", text: "请选择所在省份" });
return;
}
const content = description.trim();
if (!content) {
setMessage({ tone: "error", text: "请填写问题描述" });
return;
}
if ([...content].length > 500) {
setMessage({ tone: "error", text: "问题描述最多 500 字" });
return;
}
if (files.length > MAX_FILES) {
setMessage({ tone: "error", text: `最多上传 ${MAX_FILES} 张图片` });
return;
}
for (const item of files) {
if (item.file.size > MAX_SIZE) {
setMessage({ tone: "error", text: "单张图片不能超过 5MB" });
return;
}
}
setSubmitting(true);
setMessage(null);
try {
const attachments: { fileType: string; url: string }[] = [];
for (const item of files) {
const uploaded = await uploadImage(item.file, token);
const url = (uploaded?.sourceNameFs || uploaded?.url || "").trim();
if (!url) throw new ApiError("图片上传失败", 500);
attachments.push({ fileType: "img", url });
}
const contactValue = contact.trim();
const cityValue = city.trim();
const modelValue = deviceModel.trim();
const userId = Number(getAuthUser()?.userId) || 0;
await submitQuestionnaire(
{
description: content,
deviceType,
frequency,
networkType,
operator,
province,
questionType,
...(cityValue ? { city: cityValue } : {}),
...(contactValue ? { contact: contactValue } : {}),
...(modelValue ? { deviceModel: modelValue } : {}),
...(attachments.length ? { attachments } : {}),
...(userId > 0 ? { userId } : {}),
},
token,
);
resetForm();
setMessage({ tone: "success", text: "提交成功,感谢您的反馈!" });
} catch (error) {
if (handleUnauthorized(error, { message: "请先登录后再提交反馈" })) {
setMessage({ tone: "error", text: "请先登录后再提交反馈" });
} else {
setMessage({
tone: "error",
text: error instanceof Error ? error.message : "提交失败",
});
}
} finally {
setSubmitting(false);
}
};
return (
<form data-component="feedback-form"
onSubmit={submit}
className="flex flex-col gap-3 rounded-2xl bg-[#13111C] px-4 py-3.5 lg:flex-1 lg:gap-[18px] lg:bg-[#0E0D17] lg:p-[22px] lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-[#2E293D]"
>
<h2 className="text-base leading-[22px] font-bold text-[#FAF7FF] lg:text-[22px] lg:leading-7 lg:font-semibold lg:text-[#F5F2FC]">
提交反馈
</h2>
<p className="text-xs leading-[18px] text-[#9E94AD] lg:text-sm lg:leading-5 lg:text-[#948CA8]">
我们会认真阅读每一条反馈,并尽快改进产品体验。
</p>
<FieldSelect
label="问题类型"
placeholder="请选择问题类型"
value={questionType}
options={questionTypes}
onChange={(value) => {
setQuestionType(value);
setMessage(null);
}}
/>
<div className="grid gap-3 lg:grid-cols-2 lg:gap-4">
<FieldSelect
label="出现频率"
placeholder="请选择出现频率"
value={frequency}
options={frequencies}
onChange={setFrequency}
/>
<FieldSelect
label="设备类型"
placeholder="请选择设备类型"
value={deviceType}
options={deviceTypes}
onChange={setDeviceType}
/>
<FieldSelect
label="网络类型"
placeholder="请选择网络类型"
value={networkType}
options={networkTypes}
onChange={setNetworkType}
/>
<FieldSelect
label="运营商"
placeholder="请选择运营商"
value={operator}
options={operators}
onChange={setOperator}
/>
<FieldSelect
label="所在省份"
placeholder="请选择所在省份"
value={province}
options={provinces}
onChange={setProvince}
/>
<label className="flex flex-col gap-3 lg:gap-2">
<span className={labelClass}>所在城市(选填)</span>
<input
value={city}
onChange={(event) => setCity(event.target.value)}
maxLength={40}
placeholder="如:深圳"
className={`${controlClass} h-11 lg:h-[50px]`}
/>
</label>
</div>
<label className="flex flex-col gap-3 lg:gap-2">
<span className={labelClass}>设备型号(选填)</span>
<input
value={deviceModel}
onChange={(event) => setDeviceModel(event.target.value)}
maxLength={80}
placeholder="如:iPhone 15 / Pixel 8"
className={`${controlClass} h-11 lg:h-[50px]`}
/>
</label>
<label className="flex flex-col gap-3 lg:gap-2">
<span className={labelClass}>问题描述</span>
<textarea
value={description}
onChange={(event) => {
setDescription(event.target.value);
setMessage(null);
}}
maxLength={500}
placeholder="请尽量详细描述问题、出现步骤和期望结果"
className={`${controlClass} h-[72px] resize-none py-3 lg:h-[120px] lg:py-3.5`}
/>
</label>
<label className="flex flex-col gap-3 lg:gap-2">
<span className={labelClass}>联系方式(选填)</span>
<input
value={contact}
onChange={(event) => setContact(event.target.value)}
maxLength={100}
placeholder="邮箱或其他联系方式"
className={`${controlClass} h-11 lg:h-[50px]`}
/>
</label>
{files.length ? (
<ul className="flex flex-wrap gap-2">
{files.map((item) => (
<li key={item.id} className="relative size-16 overflow-hidden rounded-lg lg:size-20">
<img src={item.url} alt={item.file.name} className="size-full object-cover" />
<button
type="button"
aria-label="移除图片"
onClick={() => removeFile(item.id)}
className="cursor-pointer absolute top-0.5 right-0.5 flex size-5 items-center justify-center rounded-full bg-black/70 text-xs text-white"
>
×
</button>
</li>
))}
</ul>
) : null}
<label
className={`flex h-[46px] cursor-pointer items-center gap-2 rounded-xl bg-[#1C1726] px-3 lg:h-[72px] lg:gap-2.5 lg:bg-[#13121C] lg:px-4 lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-[#2E293D] ${
files.length >= MAX_FILES ? "pointer-events-none opacity-50" : ""
}`}
>
<span className="text-lg leading-6 text-[#F12B9E] lg:w-5 lg:text-xl lg:leading-[26px] lg:font-semibold">+</span>
<span className="truncate text-xs leading-[18px] text-[#9E94AD] lg:text-sm lg:leading-5 lg:text-[#948CA8]">
{files.length
? `已选 ${files.length}/${MAX_FILES} 张,还可继续添加`
: `添加问题截图,最多 ${MAX_FILES} 张,单张不超过 5MB`}
</span>
<input
type="file"
accept="image/png,image/jpeg"
multiple
disabled={files.length >= MAX_FILES}
onChange={pickFiles}
className="sr-only"
/>
</label>
{message ? (
<p className={`text-xs leading-[18px] ${message.tone === "error" ? "text-[#FF5A7A]" : "text-[#4ADE80]"}`}>
{message.text}
</p>
) : null}
<button
type="submit"
disabled={submitting}
className="cursor-pointer h-[46px] w-full rounded-xl bg-[#F12B9E] text-sm leading-5 font-bold text-[#FAF7FF] transition-opacity hover:opacity-90 disabled:opacity-60 lg:w-40 lg:rounded-[23px] lg:font-semibold lg:text-[#F5F2FC]"
>
{submitting ? "提交中..." : "提交反馈"}
</button>
</form>
);
}
@@ -1,81 +0,0 @@
/** 与 swagger QuestionnaireSubmitReq 枚举文案保持一致 */
export const questionTypes = [
"播放卡顿/缓冲",
"无法播放",
"画质模糊",
"音画不同步",
"加载失败",
"闪退/崩溃",
"登录异常",
"其他问题",
] as const;
export const frequencies = ["每次都会", "经常出现", "偶尔出现", "仅现一次"] as const;
export const deviceTypes = ["iPhone", "Android", "iPad", "其他"] as const;
export const networkTypes = ["WI-FI", "5G", "4G", "其他"] as const;
export const operators = ["电信", "联通", "移动", "广电"] as const;
export const provinces = [
"北京",
"天津",
"河北",
"山西",
"内蒙古",
"辽宁",
"吉林",
"黑龙江",
"上海",
"江苏",
"浙江",
"安徽",
"福建",
"江西",
"山东",
"河南",
"湖北",
"湖南",
"广东",
"广西",
"海南",
"重庆",
"四川",
"贵州",
"云南",
"西藏",
"陕西",
"甘肃",
"青海",
"宁夏",
"新疆",
"香港",
"澳门",
"台湾",
"海外",
] as const;
export function detectDeviceType(): (typeof deviceTypes)[number] {
if (typeof navigator === "undefined") return "其他";
const ua = navigator.userAgent;
if (/iPad/i.test(ua)) return "iPad";
if (/iPhone/i.test(ua)) return "iPhone";
if (/Android/i.test(ua)) return "Android";
return "其他";
}
export function detectNetworkType(): (typeof networkTypes)[number] {
if (typeof navigator === "undefined") return "其他";
const connection = (
navigator as Navigator & {
connection?: { type?: string; effectiveType?: string };
}
).connection;
const type = (connection?.type || "").toLowerCase();
const effective = (connection?.effectiveType || "").toLowerCase();
if (type === "wifi" || effective.includes("wifi")) return "WI-FI";
if (effective === "5g" || (type === "cellular" && effective.includes("5g"))) return "5G";
if (effective === "4g" || effective === "3g" || type === "cellular") return "4G";
return "WI-FI";
}
-38
View File
@@ -1,38 +0,0 @@
import { FeedbackForm } from "@/components/feedback/feedback-form";
import { InfoPageShell } from "@/components/info/info-page-layout";
const notes = [
{ title: "处理时间", text: "一般问题将在 1–3 个工作日内查看。" },
{ title: "紧急问题", text: "账号安全或付费异常,请在描述中注明。" },
];
export function FeedbackPage({ gmailAddr }: { gmailAddr?: string | null }) {
const email = gmailAddr?.trim();
return (
<InfoPageShell data-component="feedback-page" title="问题反馈" subtitle="告诉我们您遇到的问题或建议">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:gap-6">
<FeedbackForm />
<aside className="flex flex-col gap-[5px] rounded-2xl bg-[#13111C] px-4 py-3 lg:w-80 lg:shrink-0 lg:gap-[18px] lg:bg-[#0E0D17] lg:p-[22px] lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-[#2E293D]">
<h2 className="text-sm leading-5 font-bold text-[#FAF7FF] lg:text-xl lg:leading-[26px] lg:font-semibold lg:text-[#F5F2FC]">
反馈说明
</h2>
{notes.map((note) => (
<div key={note.title} className="flex flex-col lg:gap-1.5">
<h3 className="hidden text-sm leading-5 text-[#F5F2FC] lg:block">{note.title}</h3>
<p className="text-xs leading-[18px] text-[#9E94AD] lg:text-sm lg:leading-5 lg:text-[#948CA8]">
<span className="lg:hidden">{note.title}:</span>
{note.text}
</p>
</div>
))}
{email ? (
<a href={`mailto:${email}`} className="text-xs leading-[18px] text-[#F12B9E]">
联系邮箱:{email}
</a>
) : null}
</aside>
</div>
</InfoPageShell>
);
}
-43
View File
@@ -1,43 +0,0 @@
import { EmptyState } from "@/components/common/empty-state";
import { InfoPageShell, infoCardClass } from "@/components/info/info-page-layout";
import type { RichTextInfo } from "@/lib/api/types";
const richtextBodyClass =
"richtext-body [&_a]:text-[#F12B9E] [&_h1]:mb-2 [&_h1]:font-bold [&_h2]:mt-3 [&_h2]:mb-1.5 [&_h2]:font-bold [&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:font-semibold [&_li]:ml-4 [&_li]:list-disc [&_p]:mb-2 [&_ul]:mb-2";
export function HelpPage({ data }: { data: RichTextInfo | null }) {
const html = data?.content?.trim() || "";
const subtitle = data?.seoInfo?.description?.trim() || data?.title?.trim() || "常见问题与使用指南";
return (
<InfoPageShell data-component="help-page" title="帮助中心" subtitle={subtitle} mobile={
html ? (
<article
data-component="help-page-h5"
className={`${richtextBodyClass} rounded-2xl bg-[#13111C] px-4 py-3.5 text-xs leading-[18px] text-[#9E94AD] [&_h1]:text-base [&_h1]:text-[#FAF7FF] [&_h2]:text-sm [&_h2]:text-[#FAF7FF] [&_h3]:text-sm [&_h3]:text-[#FAF7FF]`}
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<EmptyState
title="暂无帮助内容"
description="帮助文档正在完善中,请稍后再试或通过问题反馈联系我们"
className="rounded-2xl px-4 py-12"
/>
)
}>
{html ? (
<article
data-component="help-page-pc"
className={`${infoCardClass} ${richtextBodyClass} gap-4 px-8 pt-7 pb-[34px] text-sm leading-5 text-[#948CA8] [&_h1]:text-xl [&_h1]:leading-[26px] [&_h1]:font-semibold [&_h1]:text-[#F5F2FC] [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-[#F5F2FC] [&_h3]:text-base [&_h3]:font-medium [&_h3]:text-[#F5F2FC] [&_li]:ml-5 [&_p]:mb-3 [&_ul]:mb-3`}
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<EmptyState
title="暂无帮助内容"
description="帮助文档正在完善中,请稍后再试或通过问题反馈联系我们"
className="rounded-3xl px-[110px] py-24"
/>
)}
</InfoPageShell>
);
}
+1 -1
View File
@@ -243,7 +243,7 @@ const boardItems: RankItem[] = [
export const rankBoards: RankBoard[] = [1, 2, 3, 4].map((index) => ({
id: `board-${index}`,
title: "一周口碑电影榜",
subtitle: "海角榜单",
subtitle: "黄果榜单",
cover: cover(`haijiao-board-${index}`, 410, 644),
items: boardItems,
}));
+2 -2
View File
@@ -135,7 +135,7 @@ function StyleHBoardCard({
/** 样式 H:统一主题样式2 · 口碑榜竖版 4 卡(topicList) */
export function HomeModuleStyleH({ module }: { module: HomeModuleData }) {
const topics = (module.topicList ?? []).filter((topic) => topic.name).slice(0, 4);
const subtitle = (module.label || "海角榜单").trim();
const subtitle = (module.label || "黄果榜单").trim();
if (!topics.length) return null;
return (
@@ -212,7 +212,7 @@ function H5StyleHBoardCard({
export function HomeModuleStyleHH5({ module }: { module: HomeModuleData }) {
const moreHref = morePageHref(module);
const title = (module.title || module.label || "推荐").trim();
const subtitle = (module.label || "海角榜单").trim();
const subtitle = (module.label || "黄果榜单").trim();
const topics = (module.topicList ?? []).filter((topic) => topic.name).slice(0, 4);
if (!topics.length) return null;
+10 -39
View File
@@ -9,13 +9,12 @@ import {
MEDIA_CARD_EPISODES_H5,
MEDIA_CARD_META,
MEDIA_CARD_META_H5,
MEDIA_CARD_TITLE_PC,
MEDIA_CARD_TITLE_PC_MD,
} from "@/components/common/media-card-hover";
import { formatHeat } from "@/components/ranking/data";
import { morePageHref } from "@/components/more/href";
import { dramaHref } from "@/components/short-drama/data";
import { topicDetailHref } from "@/components/topic/href";
import { TopicListCard } from "@/components/topic/topic-list-card";
import { videoHref } from "@/components/video/video-card";
import type { HomeModuleData, PlayletData, VideoInfo } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
@@ -32,8 +31,6 @@ import { HomeModuleStyleH, HomeModuleStyleHH5 } from "./home-module-style-h";
import {
isVideoDataType,
normalizeHomeModuleStyle,
topicCountLabel,
topicCoverSrc,
unwrapHomeModuleList,
videoCoverSrc,
} from "./home-module-style";
@@ -355,41 +352,15 @@ export function HomeModules({
</div>
) : null}
{topics.length ? (
<div className={compact ? "grid grid-cols-2 gap-[7px]" : "grid grid-cols-2 gap-5 lg:grid-cols-4"}>
{topics.map((topic) => {
const countText = topicCountLabel(topic, module.dataType, "部");
return (
<Link
key={topic.id}
href={topicDetailHref(topic, isVideoDataType(module.dataType) ? 2 : 1)}
className={`relative overflow-hidden rounded-lg bg-[#110F1F] ${compact ? "aspect-[16/10]" : "h-[231px]"}`}
>
<DecryptedImage
src={topicCoverSrc(topic)}
fallback={IMG_PLACEHOLDER}
alt={topic.name || ""}
className="absolute inset-0 h-full w-full object-cover object-center"
/>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_66%_60%,transparent,rgba(0,0,0,0.55))]" />
<div className={compact ? "absolute bottom-3 left-3" : "absolute bottom-6 left-8"}>
<h3 className={compact ? "text-base font-medium text-[#F2F2FA]" : `text-[#F2F2FA] ${MEDIA_CARD_TITLE_PC}`}>
{topic.name}
</h3>
{countText ? (
<span
className={
compact
? "mt-1 inline-flex rounded bg-black/50 px-1.5 py-0.5 text-[10px] text-[#F68804]"
: "mt-3 inline-flex rounded bg-black/50 px-2.5 py-1 text-xs text-[#F68804]"
}
>
{countText}
</span>
) : null}
</div>
</Link>
);
})}
<div className={compact ? "grid grid-cols-2 gap-x-2.5 gap-y-4" : "grid grid-cols-2 gap-x-5 gap-y-7 lg:grid-cols-4"}>
{topics.map((topic) => (
<TopicListCard
key={topic.id}
topic={topic}
dataType={isVideoDataType(module.dataType) ? 2 : 1}
compact={compact}
/>
))}
</div>
) : null}
</section>
+1 -1
View File
@@ -10,7 +10,7 @@ import { HomePageH5 } from "./home-page-h5";
import { NoticeMarquee } from "./notice-marquee";
import { DownloadBanner } from "./page-sections";
const HOME_H1_FALLBACK = "海角社区 - 热门短剧在线看";
const HOME_H1_FALLBACK = "黄果黑料 - 热门短剧在线看";
export function HomePage({
home,
-8
View File
@@ -44,11 +44,3 @@ export function PlayIcon({ className }: IconProps) {
);
}
export function HistoryIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" strokeWidth="1.6" />
<path d="M12 8v4.2l2.6 1.6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
+7 -7
View File
@@ -12,11 +12,11 @@ export function DownloadBanner() {
target="_blank"
rel="noopener noreferrer"
className="block w-full"
aria-label="下载海角社区 App"
aria-label="下载黄果黑料 App"
>
<img
src="/images/download_banner.png"
alt="在海角社区APP上继续追剧,离线缓存随时看"
alt="在黄果黑料APP上继续追剧,离线缓存随时看"
className="h-auto w-full"
/>
</a>
@@ -25,11 +25,11 @@ export function DownloadBanner() {
type="button"
onClick={() => openAppDownload()}
className="block w-full cursor-pointer"
aria-label="下载海角社区 App"
aria-label="下载黄果黑料 App"
>
<img
src="/images/download_banner.png"
alt="在海角社区APP上继续追剧,离线缓存随时看"
alt="在黄果黑料APP上继续追剧,离线缓存随时看"
className="h-auto w-full"
/>
</button>
@@ -41,7 +41,7 @@ export function DownloadBanner() {
export function DownloadBannerH5() {
const href = useAppDownloadHref();
const className = "block w-full overflow-hidden rounded-xl";
const img = <img src="/images/download_banner_h5.png" alt="在海角社区APP上继续追剧" className="h-auto w-full" />;
const img = <img src="/images/download_banner_h5.png" alt="在黄果黑料APP上继续追剧" className="h-auto w-full" />;
if (href) {
return (
@@ -52,7 +52,7 @@ export function DownloadBannerH5() {
target="_blank"
rel="noopener noreferrer"
className={className}
aria-label="下载海角社区 App"
aria-label="下载黄果黑料 App"
>
{img}
</a>
@@ -66,7 +66,7 @@ export function DownloadBannerH5() {
type="button"
onClick={() => openAppDownload()}
className={`cursor-pointer ${className}`}
aria-label="下载海角社区 App"
aria-label="下载黄果黑料 App"
>
{img}
</button>
@@ -58,8 +58,6 @@ function DesktopNavItemInner({
<Link
href={parentHref}
className={linkClass(active)}
data-navigation-key={category.urlValue || `nav_${category.id || category.name}`}
data-navigation-name={category.name || ""}
>
<span>{category.name}</span>
<ActiveMark active={active} />
@@ -71,8 +69,6 @@ function DesktopNavItemInner({
key={item.id}
href={resolveNavHref(item.urlValue)}
className="block px-4 py-2 text-sm text-[#948CA8] hover:bg-white/5 hover:text-white"
data-navigation-key={item.urlValue || `nav_item_${item.id || item.name}`}
data-navigation-name={item.name || ""}
>
{item.name}
</Link>
@@ -90,8 +86,6 @@ function DesktopNavItemInner({
target="_blank"
rel="noopener noreferrer"
className={linkClass(false)}
data-navigation-key={category.urlValue || `nav_${category.id || category.name}`}
data-navigation-name={category.name || ""}
>
<span>{category.name}</span>
</a>
@@ -102,8 +96,6 @@ function DesktopNavItemInner({
<Link
href={href}
className={linkClass(active)}
data-navigation-key={category.urlValue || `nav_${category.id || category.name}`}
data-navigation-name={category.name || ""}
>
<span>{category.name}</span>
<ActiveMark active={active} />
-13
View File
@@ -1,13 +0,0 @@
import { infoLinkHref } from "@/components/layout/info-links";
export type FooterContactLink = {
key: string;
href: string;
text: string;
};
/** 页脚「客户服务」:仅帮助中心、问题反馈 */
export const FOOTER_CUSTOMER_SERVICE_LINKS: FooterContactLink[] = [
{ key: "help", href: infoLinkHref("帮助中心"), text: "帮助中心" },
{ key: "feedback", href: infoLinkHref("问题反馈"), text: "问题反馈" },
];
+2 -30
View File
@@ -3,19 +3,12 @@ import type { AppTranslationInfo, TranslationInfo } from "@/lib/api/types";
/** 接口失败时兜底:PC「关于平台」(与 home/base → bottonTranslations 对齐) */
export const BOTTOM_NAV_FALLBACK_NAMES = ["用户条款", "隐私政策", "联系我们", "常见问题"] as const;
/** PC「客户服务」从底部导航拆出的名称(含意见反馈别名) */
export const CUSTOMER_SERVICE_NAMES = ["帮助中心", "问题反馈"] as const;
/** 已下线的客服入口:接口仍下发时不展示 */
function isCustomerServiceName(name?: string | null) {
const value = name?.trim() || "";
return value === "帮助中心" || value === "问题反馈" || value === "意见反馈";
}
function isFeedbackName(name?: string | null) {
const value = name?.trim() || "";
return value === "问题反馈" || value === "意见反馈";
}
/**
* home/base → bottonTranslations 扁平化(底部导航)
* 优先取 categoryType=3(底部导航)的分类;无 type=3 时用全部(字段本身即底部导航)。
@@ -26,26 +19,5 @@ export function footerNavItems(bottonTranslations?: AppTranslationInfo | null):
const source = bottomCats.length ? bottomCats : categories;
return source
.flatMap((category) => category.translationList ?? [])
.filter((item): item is TranslationInfo => Boolean(item.name?.trim()));
}
/** PC「关于平台」:底部导航去掉帮助中心 / 问题反馈(意见反馈) */
export function footerAboutNavItems(bottonTranslations?: AppTranslationInfo | null): TranslationInfo[] {
return footerNavItems(bottonTranslations).filter((item) => !isCustomerServiceName(item.name));
}
/**
* PC「客户服务」:从底部导航拆出帮助中心、问题反馈/意见反馈;
* 保留接口 name / urlValue;缺项时用同名兜底。
*/
export function footerCustomerServiceNavItems(
bottonTranslations?: AppTranslationInfo | null,
): TranslationInfo[] {
const api = footerNavItems(bottonTranslations);
const help = api.find((item) => item.name?.trim() === "帮助中心");
const feedback = api.find((item) => isFeedbackName(item.name));
return [
help ?? ({ id: -100, name: "帮助中心" } satisfies TranslationInfo),
feedback ?? ({ id: -101, name: "问题反馈" } satisfies TranslationInfo),
];
.filter((item): item is TranslationInfo => Boolean(item.name?.trim()) && !isCustomerServiceName(item.name));
}
-3
View File
@@ -6,9 +6,6 @@ const infoLinkHrefs: Record<string, string> = {
用户条款: toRichTextPath("用户条款"),
隐私政策: toRichTextPath("隐私政策"),
常见问题: toRichTextPath("常见问题"),
帮助中心: "/help",
问题反馈: "/feedback",
意见反馈: "/feedback",
联系我们: toRichTextPath("联系我们"),
};
+2 -11
View File
@@ -4,15 +4,11 @@ import Form from "next/form";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { useMockUser } from "@/components/profile/use-mock-user";
import type { AppTranslationInfo, ConfigInfo, TranslationCategory } from "@/lib/api/types";
import { MobileMenu } from "./mobile-menu";
import { SiteSearchInput } from "./site-search-input";
const fallbackAvatar = "/images/default_avatar.svg";
export function MobileHeader({
categoryList,
bottonTranslations,
@@ -22,8 +18,6 @@ export function MobileHeader({
bottonTranslations?: AppTranslationInfo | null;
config?: ConfigInfo | null;
}) {
const { user } = useMockUser();
const avatar = user?.avatar || fallbackAvatar;
const [panel, setPanel] = useState<"menu" | "search" | null>(null);
const closePanel = useCallback(() => setPanel(null), []);
const toggle = (next: "menu" | "search") => setPanel((current) => (current === next ? null : next));
@@ -40,8 +34,8 @@ export function MobileHeader({
>
<img src="/images/menu.svg" alt="" className="size-full" />
</button>
<Link href="/" aria-label="海角社区" onClick={() => setPanel(null)}>
<img src="/logo.svg" alt="海角社区" className="h-6 w-auto" />
<Link href="/" aria-label="黄果黑料" onClick={() => setPanel(null)}>
<img src="/logo.svg" alt="黄果黑料" className="h-6 w-auto" />
</Link>
<div className="flex items-center gap-3">
<button
@@ -53,9 +47,6 @@ export function MobileHeader({
>
<img src="/images/search_h5.svg" alt="" className="size-full" />
</button>
<Link href="/profile" aria-label="我的" className="size-6 overflow-hidden rounded-full" onClick={() => setPanel(null)}>
<DecryptedImage src={avatar} fallback={fallbackAvatar} priority alt="" className="size-full object-cover" />
</Link>
</div>
</div>
<MobileMenu
+15 -63
View File
@@ -7,6 +7,7 @@ import { useEffect, useState, type ReactNode } from "react";
import { openAppDownload, useAppDownloadHref } from "@/components/download/download-dialog";
import { footerNavItems } from "@/components/layout/footer-nav-items";
import type { AppTranslationInfo, ConfigInfo, TranslationCategory, TranslationInfo } from "@/lib/api/types";
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL } from "@/lib/seo/metadata";
import { resolveAboutPlatformHref, resolveNavHref } from "./nav-href";
import { categoryHref, isNavActive } from "./nav-utils";
@@ -21,54 +22,18 @@ function withDividers(items: ReactNode[], divider: string) {
);
}
/** 仅接口有值才展示,不造假数据 */
function buildContacts(config?: ConfigInfo | null) {
const email = config?.gmailAddr?.trim();
const business = config?.telegram?.trim();
const group = config?.tgGroup?.trim() || config?.telegramAdd?.trim();
const items: { key: string; node: ReactNode }[] = [];
if (email) {
items.push({
key: "email",
node: (
<a href={`mailto:${email}`} className="text-xs leading-[18px] text-[#615970]">
E-mail:{email}
</a>
),
});
}
if (business) {
items.push({
key: "business",
node: (
<a href={business} target="_blank" rel="noopener noreferrer" className="text-xs leading-[18px] text-[#615970]">
商务合作: 立即联系
</a>
),
});
}
if (group) {
items.push({
key: "group",
node: (
<a href={group} target="_blank" rel="noopener noreferrer" className="text-xs leading-[18px] text-[#615970]">
官方群: 立即加入
</a>
),
});
}
return items;
}
function buildSocials(config?: ConfigInfo | null) {
const telegram = config?.tgGroup?.trim() || config?.telegramAdd?.trim() || config?.telegram?.trim();
const twitter = config?.twitterAddr?.trim();
return [
telegram ? { label: "Telegram", icon: "/images/footer_tg.svg", href: telegram } : null,
twitter ? { label: "X", icon: "/images/footer_x.svg", href: twitter } : null,
].filter(Boolean) as { label: string; icon: string; href: string }[];
}
/** 联系方式只保留官方 TG */
const contacts = [
{
key: "tg",
node: (
<a href={OFFICIAL_TG_URL} target="_blank" rel="noopener noreferrer" className="text-xs leading-[18px] text-[#615970]">
官方TG:{OFFICIAL_TG_HANDLE}
</a>
),
},
];
const socials = [{ label: "Telegram", icon: "/images/footer_tg.svg", href: OFFICIAL_TG_URL }];
function InfoNavLink({ item, onClose }: { item: TranslationInfo; onClose: () => void }) {
const href = resolveAboutPlatformHref(item);
@@ -93,7 +58,6 @@ export function MobileMenu({
onClose,
categoryList,
bottonTranslations,
config,
}: {
open: boolean;
onClose: () => void;
@@ -107,8 +71,6 @@ export function MobileMenu({
// 仅渲染接口 bottonTranslations,不补自定义项
const bottomNavLinks = footerNavItems(bottonTranslations);
const downloadHref = useAppDownloadHref();
const contacts = buildContacts(config);
const socials = buildSocials(config);
useEffect(() => {
if (!open) return;
@@ -148,8 +110,8 @@ export function MobileMenu({
</svg>
</button>
<Link href="/" onClick={onClose} aria-label="海角社区" className="shrink-0">
<img src="/logo.svg" alt="海角社区" className="h-[62px] w-[137px]" />
<Link href="/" onClick={onClose} aria-label="黄果黑料" className="shrink-0">
<img src="/logo.svg" alt="黄果黑料" className="h-[62px] w-[137px]" />
</Link>
<nav className="flex w-full flex-col gap-[11px]" aria-label="主导航">
@@ -244,16 +206,6 @@ export function MobileMenu({
</Link>
);
})}
<Link
href="/profile"
onClick={onClose}
aria-current={pathname === "/profile" || pathname.startsWith("/profile/") ? "page" : undefined}
className={`flex h-11 shrink-0 items-center rounded-[9px] bg-white/10 px-[18px] text-base leading-[22px] font-medium ${
pathname === "/profile" || pathname.startsWith("/profile/") ? "text-[#F12B9E]" : "text-white/90"
}`}
>
我的
</Link>
</nav>
{bottomNavLinks.length ? (
-2
View File
@@ -75,8 +75,6 @@ export function resolveAboutPlatformHref(item: { name?: string; urlValue?: strin
const fromNav = resolveNavHref(item.urlValue);
if (fromNav && fromNav !== "#") return fromNav;
if (name === "帮助中心") return "/help";
if (name === "问题反馈" || name === "意见反馈") return "/feedback";
if (name === "关于我们") return "/about";
if (name) return toRichTextPath(name);
return "#";
@@ -10,17 +10,9 @@ import {
} from "@/components/layout/nav-utils";
export const mobileChromelessPaths = [
"/profile",
"/history",
"/favorites",
"/likes",
"/settings",
"/settings/profile",
"/about",
"/terms",
"/privacy",
"/help",
"/feedback",
"/richtext",
];
+31 -77
View File
@@ -1,28 +1,16 @@
import Link from "next/link";
import {
BOTTOM_NAV_FALLBACK_NAMES,
footerAboutNavItems,
footerCustomerServiceNavItems,
footerNavItems,
} from "@/components/layout/footer-nav-items";
import { BOTTOM_NAV_FALLBACK_NAMES, footerNavItems } from "@/components/layout/footer-nav-items";
import { infoLinkHref } from "@/components/layout/info-links";
import { resolveAboutPlatformHref } from "@/components/layout/nav-href";
import type { AppTranslationInfo, ConfigInfo, TranslationInfo } from "@/lib/api/types";
import { OFFICIAL_TG_URL, SITE_DOMAIN, SITE_NAME, SITE_URL } from "@/lib/seo/metadata";
import { FooterDownloadButtons } from "./footer-download-buttons";
const fallbackSocialLinks = [
{ label: "商务", icon: "/images/footer_sw.svg", href: "/#community" },
{ label: "Telegram", icon: "/images/footer_tg.svg", href: "/#community" },
{ label: "X", icon: "/images/footer_x.svg", href: "/#community" },
];
/** 官方社群:只保留 TG */
const socialLinks = [{ label: "Telegram", icon: "/images/footer_tg.svg", href: OFFICIAL_TG_URL }];
const aboutLinksFallback = BOTTOM_NAV_FALLBACK_NAMES;
const officialLinksFallback = [
{ label: "官网", href: "https://HaijiaoPortal.com", text: "官网:HaijiaoPortal.com" },
{ label: "最新下载地址", href: "https://HaijiaoPortal.com", text: "最新下载地址:HaijiaoPortal.com" },
{ label: "永久地址", href: "https://HaijiaoPortal.com", text: "永久地址:HaijiaoPortal.com" },
];
function hrefOf(value?: string | null) {
const text = value?.trim();
@@ -36,43 +24,23 @@ type FooterProps = {
config?: ConfigInfo | null;
};
function buildSocialLinks(config?: ConfigInfo | null) {
const business = config?.telegram?.trim();
const telegram = config?.tgGroup?.trim() || config?.telegramAdd?.trim();
const twitter = config?.twitterAddr?.trim();
if (!business && !telegram && !twitter) return fallbackSocialLinks;
return [
business ? { label: "商务", icon: "/images/footer_sw.svg", href: business } : null,
telegram ? { label: "Telegram", icon: "/images/footer_tg.svg", href: telegram } : null,
twitter ? { label: "X", icon: "/images/footer_x.svg", href: twitter } : null,
].filter(Boolean) as { label: string; icon: string; href: string }[];
}
/** 官网 / 永久地址固定本站域名;下载地址仍取接口 appAddr(APP 落地页) */
function buildOfficialLines(config?: ConfigInfo | null) {
const lines = [
config?.webAddr?.trim()
? { label: "官网", href: hrefOf(config.webAddr)!, text: `官网:${config.webAddr.trim()}` }
: null,
config?.appAddr?.trim()
? { label: "最新下载地址", href: hrefOf(config.appAddr)!, text: `最新下载地址:${config.appAddr.trim()}` }
: null,
config?.foreverUrlAddr?.trim()
? {
label: "永久地址",
href: hrefOf(config.foreverUrlAddr)!,
text: `永久地址:${config.foreverUrlAddr.trim()}`,
}
: null,
].filter(Boolean) as { label: string; href: string; text: string }[];
return lines.length ? lines : officialLinksFallback;
return [
{ label: "官网", href: SITE_URL, text: `官网:${SITE_DOMAIN}` },
{
label: "最新下载地址",
href: hrefOf(config?.appAddr) || SITE_URL,
text: `最新下载地址:${config?.appAddr?.trim() || SITE_DOMAIN}`,
},
{ label: "永久地址", href: SITE_URL, text: `永久地址:${SITE_DOMAIN}` },
];
}
function FooterNavAnchor({ item }: { item: TranslationInfo }) {
const href = resolveAboutPlatformHref(item);
const external = /^https?:\/\//i.test(href);
const className = "text-sm leading-5 text-[#989898] hover:text-white";
const navKey = item.urlValue || `footer_${item.name || ""}`;
const navName = item.name || "";
if (external) {
return (
<a
@@ -80,15 +48,13 @@ function FooterNavAnchor({ item }: { item: TranslationInfo }) {
target="_blank"
rel="noopener noreferrer"
className={className}
data-navigation-key={navKey}
data-navigation-name={navName}
>
{item.name}
</a>
);
}
return (
<Link href={href} className={className} data-navigation-key={navKey} data-navigation-name={navName}>
<Link href={href} className={className}>
{item.name}
</Link>
);
@@ -116,19 +82,15 @@ export function SiteFooter({ bottonTranslations, config }: FooterProps) {
);
}
export function MobileFooter({ bottonTranslations, config }: FooterProps = {}) {
export function MobileFooter({ bottonTranslations }: FooterProps = {}) {
// 仅渲染接口 bottonTranslations,不补自定义项
const bottomNavLinks = footerNavItems(bottonTranslations);
const socialLinks = buildSocialLinks(config);
const latest = config?.webAddr?.trim() || config?.newUrlAddr?.trim();
const forever = config?.foreverUrlAddr?.trim();
const portal = config?.translationAddr?.trim() || config?.webAddr?.trim();
return (
<footer data-component="mobile-footer" className="flex flex-col items-center gap-[18px] bg-white/10 px-4 py-[18px] text-center lg:hidden">
<img src="/logo.svg" alt="海角社区" className="h-8 w-auto" />
<img src="/logo.svg" alt="黄果黑料" className="h-8 w-auto" />
<p className="text-sm leading-5 text-white/55">
Copyright @ 2026 {config?.webSiteName?.trim() || config?.webAddr?.trim() || "haijiao.com"} All rights reserved
Copyright @ 2026 {SITE_DOMAIN} All rights reserved
</p>
{bottomNavLinks.length ? (
<nav
@@ -149,36 +111,36 @@ export function MobileFooter({ bottonTranslations, config }: FooterProps = {}) {
</p>
<div className="flex flex-col gap-1.5 text-xs leading-[18px] text-white/55">
<p>
海角社区最新地址:
黄果黑料最新地址:
<a
href={hrefOf(latest) || "https://Haijiaohaijiao.so"}
href={SITE_URL}
target="_blank"
rel="noopener noreferrer"
className="text-[#F12B9E] underline-offset-2 hover:underline"
>
{latest || "https://Haijiaohaijiao.so"}
{SITE_URL}
</a>
</p>
<p>
海角社区永久入口:
黄果黑料永久入口:
<a
href={hrefOf(forever) || "https://Haijiaohaijiao.so"}
href={SITE_URL}
target="_blank"
rel="noopener noreferrer"
className="underline-offset-2 hover:underline"
>
{forever || "https://Haijiaohaijiao.so"}
{SITE_URL}
</a>
</p>
<p>
海角社区官方永久地址:
黄果黑料官方永久地址:
<a
href={hrefOf(portal) || "https://HaijiaoPortal.com"}
href={SITE_URL}
target="_blank"
rel="noopener noreferrer"
className="underline-offset-2 hover:underline"
>
{portal || "HaijiaoPortal.com"}
{SITE_DOMAIN}
</a>
</p>
</div>
@@ -203,18 +165,16 @@ export function MobileFooter({ bottonTranslations, config }: FooterProps = {}) {
}
function DesktopFooter({ bottonTranslations, config }: FooterProps) {
const aboutLinks = footerAboutNavItems(bottonTranslations);
const customerServiceLinks = footerCustomerServiceNavItems(bottonTranslations);
const socialLinks = buildSocialLinks(config);
const aboutLinks = footerNavItems(bottonTranslations);
const officialLines = buildOfficialLines(config);
const siteName = config?.dhSiteName?.trim() || config?.webSiteName?.trim() || "海角社区";
const siteName = SITE_NAME;
return (
<footer data-component="desktop-footer" className="design-gutter hidden bg-[#0E1012] py-[74px] lg:block">
<div className="mx-auto flex w-full max-w-[1700px] flex-col items-center gap-6">
<div className="flex w-full items-start justify-between gap-10">
<div className="flex w-64 flex-col gap-5">
<img src="/logo.svg" alt="海角社区" className="h-[43px] w-[95px]" />
<img src="/logo.svg" alt="黄果黑料" className="h-[43px] w-[95px]" />
<p className="text-lg leading-6 font-medium text-white">{siteName} 官方社群</p>
<div className="flex gap-6">
{socialLinks.map((link) => (
@@ -242,12 +202,6 @@ function DesktopFooter({ bottonTranslations, config }: FooterProps) {
) : (
<FooterColumn title="关于平台" links={aboutLinksFallback} />
)}
<div className="flex min-w-24 flex-col gap-4">
<h2 className="text-lg leading-6 font-medium text-white">客户服务</h2>
{customerServiceLinks.map((link) => (
<FooterNavAnchor key={link.id || link.name} item={link} />
))}
</div>
<div className="flex flex-col gap-4">
<h2 className="text-lg leading-6 font-medium text-white">官方入口</h2>
{officialLines.map((line) => (
@@ -269,7 +223,7 @@ function DesktopFooter({ bottonTranslations, config }: FooterProps) {
</div>
</div>
<p className="text-center text-sm leading-5 text-white/55">
Copyright @ 2026 {config?.webAddr?.trim() || "haijiao.com"} All rights reserved
Copyright @ 2026 {SITE_DOMAIN} All rights reserved
</p>
</div>
</footer>
+3 -10
View File
@@ -2,11 +2,10 @@ import Form from "next/form";
import Link from "next/link";
import type { ReactNode } from "react";
import { HistoryIcon, SearchIcon } from "@/components/home/icons";
import { SearchIcon } from "@/components/home/icons";
import type { AppTranslationInfo, ConfigInfo, TranslationCategory } from "@/lib/api/types";
import { HeaderShell } from "./header-shell";
import { HeaderUserChip } from "./header-user-chip";
import { MobileHeader } from "./mobile-header";
import { SiteSearchInput } from "./site-search-input";
@@ -28,8 +27,8 @@ export function SiteHeader({
<HeaderShell>
<MobileHeader categoryList={categories} bottonTranslations={bottonTranslations} config={config} />
<div className="design-gutter hidden h-[72px] w-full items-center gap-4 lg:flex xl:gap-6">
<Link href="/" className="shrink-0" aria-label="海角社区">
<img src="/logo.svg" alt="海角社区" className="h-[43px] w-[95px]" />
<Link href="/" className="shrink-0" aria-label="黄果黑料">
<img src="/logo.svg" alt="黄果黑料" className="h-[43px] w-[95px]" />
</Link>
{desktopNav}
<Form
@@ -44,12 +43,6 @@ export function SiteHeader({
className="site-search-input h-full w-full bg-transparent text-sm text-white outline-none placeholder:text-[#7A728C]/55"
/>
</Form>
<Link href="/history" className="flex shrink-0 items-center gap-1 text-sm text-white/90">
<HistoryIcon className="size-6" />
历史
</Link>
<span className="h-3 w-px bg-white/30" />
<HeaderUserChip />
</div>
</HeaderShell>
);
@@ -0,0 +1,133 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import {
getEpisodeChunks,
getEpisodeState,
playHref,
totalEpisodeOf,
updateStatusLabel,
type DramaDetail,
} from "@/components/drama-detail/data";
import { SectionTitle } from "./parts";
const CHUNK = 30;
type Variant = "panel" | "side" | "h5";
function LockMark() {
return (
<svg viewBox="0 0 12 12" fill="none" className="absolute top-1 right-1 size-2.5 opacity-70" aria-hidden="true">
<rect x="2" y="5" width="8" height="6" rx="1" stroke="currentColor" strokeWidth="1.2" />
<path d="M4 5V3.6a2 2 0 0 1 4 0V5" stroke="currentColor" strokeWidth="1.2" />
</svg>
);
}
const gridClass: Record<Variant, string> = {
panel: "grid grid-cols-[repeat(15,minmax(0,1fr))] gap-2.5",
side: "grid grid-cols-6 gap-2",
h5: "grid grid-cols-6 gap-2",
};
const cellClass: Record<Variant, string> = {
panel: "h-12 text-base",
side: "h-12 text-base",
h5: "h-10 text-sm",
};
/**
* 分集目录:每一集都是可抓取的 <a> 链接;
* 分段只切换显示(hidden),全部集数都在 HTML 里,便于搜索引擎发现分集 URL。
*/
export function EpisodeDirectory({
detail,
current,
variant,
title = "分集目录",
}: {
detail: DramaDetail;
/** 当前播放集;详情页不传 */
current?: number;
variant: Variant;
title?: string;
}) {
const total = totalEpisodeOf(detail);
const chunks = getEpisodeChunks(total, CHUNK);
const [active, setActive] = useState(() => (current ? Math.max(0, Math.floor((current - 1) / CHUNK)) : 0));
const compact = variant === "h5";
return (
<section
data-component="hl-episode-directory"
aria-label={`${detail.title || ""}${title}`}
className={`flex min-w-0 flex-col ${
variant === "side"
? "h-[648px] w-[528px] shrink-0 gap-4 rounded-lg border border-white/8 bg-[#121118] p-5"
: compact
? "gap-3"
: "gap-5"
}`}
>
<SectionTitle compact={compact || variant === "side"}>{title}</SectionTitle>
<p className={`text-[#8E8C99] ${compact ? "-mt-1 text-xs leading-[18px]" : "text-sm leading-5"}`}>
{updateStatusLabel(detail.updateStatus)} · 共 {total} 集
{current ? ` · 正在看第 ${current} 集` : ""}
</p>
{chunks.length > 1 ? (
<div role="tablist" className="flex shrink-0 gap-2 overflow-x-auto [scrollbar-width:none]">
{chunks.map((chunk, index) => (
<button
key={chunk.label}
type="button"
role="tab"
aria-selected={index === active}
onClick={() => setActive(index)}
className={`shrink-0 cursor-pointer rounded px-3 py-1 tabular-nums ${compact ? "text-xs" : "text-sm"} ${
index === active ? "bg-[#FFB020] text-[#1A1206]" : "bg-white/5 text-[#B5B3BE] hover:text-white"
}`}
>
{chunk.start}–{chunk.end}
</button>
))}
</div>
) : null}
<div className={variant === "side" ? "min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-width:thin]" : ""}>
{chunks.map((chunk, index) => (
<ol key={chunk.label} className={`${gridClass[variant]} ${index === active ? "" : "hidden"}`}>
{Array.from({ length: chunk.end - chunk.start + 1 }, (_, i) => chunk.start + i).map((episode) => {
const playing = episode === current;
const locked = getEpisodeState(episode, detail) === "locked";
return (
<li key={episode}>
<Link
href={playHref(detail.id, episode, detail.slug)}
scroll={variant !== "side"}
aria-current={playing ? "page" : undefined}
title={`${detail.title || ""} 第${episode}集`}
className={`relative flex items-center justify-center rounded tabular-nums transition-colors ${cellClass[variant]} ${
playing
? "bg-[#FFB020] font-semibold text-[#1A1206]"
: locked
? "bg-white/[0.03] text-[#5F5D69] hover:text-[#8E8C99]"
: "bg-white/[0.07] text-[#E4E2EA] hover:bg-[#FFB020]/15 hover:text-[#FFB020]"
}`}
>
{episode}
<span className="sr-only">集{locked ? "(APP 观看)" : ""}</span>
{locked && !playing ? <LockMark /> : null}
</Link>
</li>
);
})}
</ol>
))}
</div>
</section>
);
}
+247
View File
@@ -0,0 +1,247 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { cardTagCanLink, pickPlayletCardTags, type CardTagItem } from "@/components/common/card-tags";
import type { RelatedDrama } from "@/components/drama-detail/data";
import { resolveContentBadge } from "@/components/home/section-heading";
import { formatHeat } from "@/components/ranking/data";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { H5ShortDramaCard, ShortDramaCard } from "@/components/short-drama/short-drama-card";
import { tagPageHref, type TagContentKind } from "@/components/tag/href";
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
/**
* 短剧详情 / 短剧播放 / 视频页共用的版块:
* 资料表(dl)、操作按钮、推荐网格(替代原评论区)、分区标题。
*/
/** 分区标题:左侧琥珀竖线 + h2,可带「查看更多」 */
export function SectionTitle({
children,
moreHref,
moreLabel = "查看更多",
compact,
}: {
children: ReactNode;
moreHref?: string;
moreLabel?: string;
compact?: boolean;
}) {
return (
<div className="flex items-center justify-between gap-3">
<h2
className={`flex items-center gap-2 font-semibold text-[#F4F3F7] before:block before:w-1 before:rounded-full before:bg-[#FFB020] ${
compact ? "text-base leading-[22px] before:h-4" : "text-2xl leading-8 before:h-6"
}`}
>
{children}
</h2>
{moreHref ? (
<Link
href={moreHref}
className={`shrink-0 text-[#8E8C99] hover:text-[#FFB020] ${compact ? "text-xs leading-[18px]" : "text-sm leading-5"}`}
>
{moreLabel} ›
</Link>
) : null}
</div>
);
}
export type InfoRow = { label: string; value: ReactNode };
/** 资料表:语义化 dl,便于搜索引擎理解字段 */
export function InfoList({ rows, compact }: { rows: InfoRow[]; compact?: boolean }) {
const list = rows.filter((row) => row.value !== null && row.value !== undefined && row.value !== "");
if (!list.length) return null;
return (
<dl
className={`grid gap-x-6 ${
compact ? "grid-cols-2 gap-y-1.5 text-xs leading-[18px]" : "grid-cols-[repeat(3,minmax(0,1fr))] gap-y-3 text-sm leading-5"
}`}
>
{list.map((row) => (
<div key={row.label} className="flex min-w-0 gap-2">
<dt className="shrink-0 text-[#6F6C7B]">{row.label}</dt>
<dd className="min-w-0 truncate text-[#D8D6DF]">{row.value}</dd>
</div>
))}
</dl>
);
}
export type TagLinkItem = { key: string; name: string; href?: string };
/** 卡片标签 → 标签链接(有 slug / id 才可跳转) */
export function tagLinkItems(tags: CardTagItem[], kind: TagContentKind): TagLinkItem[] {
return tags
.filter((tag) => tag.name?.trim())
.map((tag) => ({
key: tag.slug ?? String(tag.id ?? tag.name),
name: tag.name.trim(),
href: cardTagCanLink(tag) ? tagPageHref(tag, kind) : undefined,
}));
}
/** 标签链接行:#标签 */
export function TagLinks({
tags,
compact,
}: {
tags: TagLinkItem[];
compact?: boolean;
}) {
if (!tags.length) return null;
const cls = `rounded border border-[#FFB020]/25 text-[#D9C59A] ${compact ? "px-1.5 py-0.5 text-[11px] leading-4" : "px-2.5 py-1 text-xs leading-[18px]"}`;
return (
<ul className="flex flex-wrap gap-2">
{tags.map((tag) => (
<li key={tag.key}>
{tag.href ? (
<Link href={tag.href} className={`${cls} block transition-colors hover:border-[#FFB020] hover:text-[#FFB020]`}>
#{tag.name}
</Link>
) : (
<span className={`${cls} block`}>#{tag.name}</span>
)}
</li>
))}
</ul>
);
}
const actionIcons = {
like: (
<path
d="M8 13.6c-.2 0-.4-.07-.55-.2C5.3 11.6 2.2 9.4 2.2 6.5 2.2 4.6 3.6 3 5.3 3c1.1 0 2 .6 2.7 1.5C8.7 3.6 9.6 3 10.7 3c1.7 0 3.1 1.6 3.1 3.5 0 2.9-3.1 5.1-5.25 6.9-.15.13-.35.2-.55.2Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
),
collect: (
<path
d="m8 2.2 1.75 3.6 3.95.55-2.85 2.75.68 3.9L8 11.15 4.47 13l.68-3.9L2.3 6.35l3.95-.55L8 2.2Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
),
share: (
<path
d="M10.5 5.5 5.5 8l5 2.5M12 4.25a1.75 1.75 0 1 1-3.5 0 1.75 1.75 0 0 1 3.5 0Zm0 7.5a1.75 1.75 0 1 1-3.5 0 1.75 1.75 0 0 1 3.5 0ZM5.5 8a1.75 1.75 0 1 1-3.5 0 1.75 1.75 0 0 1 3.5 0Z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
),
} as const;
/** 次要操作按钮:点赞 / 收藏 / 分享(描边方形,不用胶囊) */
export function HlAction({
kind,
label,
active = false,
onClick,
compact,
}: {
kind: keyof typeof actionIcons;
label: string;
active?: boolean;
onClick?: () => void;
compact?: boolean;
}) {
return (
<button
type="button"
aria-pressed={kind === "share" ? undefined : active}
onClick={onClick}
className={`inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-md border transition-colors ${
active ? "border-[#FFB020] bg-[#FFB020]/10 text-[#FFB020]" : "border-white/12 text-[#D8D6DF] hover:border-white/30"
} ${compact ? "h-9 px-3 text-xs" : "h-11 px-5 text-sm"}`}
>
<svg viewBox="0 0 16 16" fill={active && kind !== "share" ? "currentColor" : "none"} className={compact ? "size-4" : "size-[18px]"} aria-hidden="true">
{actionIcons[kind]}
</svg>
{label}
</button>
);
}
/** 主操作:琥珀实底按钮(链接) */
export function HlPrimaryLink({
href,
children,
compact,
className = "",
}: {
href: string;
children: ReactNode;
compact?: boolean;
className?: string;
}) {
return (
<Link
href={href}
className={`inline-flex items-center justify-center gap-2 rounded-md bg-[#FFB020] font-semibold text-[#1A1206] transition-colors hover:bg-[#FFC14D] ${
compact ? "h-10 px-4 text-sm" : "h-11 px-7 text-base"
} ${className}`}
>
<svg viewBox="0 0 16 16" className="size-3.5" aria-hidden="true">
<path d="M4.5 2.8v10.4L13 8 4.5 2.8Z" fill="currentColor" />
</svg>
{children}
</Link>
);
}
/** 推荐短剧:接口 PlayletData → 卡片数据 */
export function relatedToItem(item: RelatedDrama): ShortDramaItem {
return {
kind: "drama",
id: String(item.id),
slug: item.slug?.trim() || undefined,
title: (item.title || "").trim() || "未命名",
subtitle: (item.desc || "").trim(),
tags: pickPlayletCardTags(item),
heat: formatHeat(item.heatValue ?? item.countBrowse),
episodes: item.totalEpisode ? `${item.totalEpisode}集` : "",
badge: resolveContentBadge(item),
cover: (item.cover || "").trim(),
};
}
/** 同类短剧推荐(替代原评论区):PC 6 列 / H5 2 列 */
export function DramaRecommend({ items, compact }: { items: RelatedDrama[]; compact?: boolean }) {
const list = items.filter((item) => item.id).map(relatedToItem);
if (!list.length) return null;
return (
<section aria-label="同类短剧推荐" className={`flex flex-col ${compact ? "gap-3" : "gap-6"}`}>
<SectionTitle moreHref="/short-drama/hot" moreLabel="短剧库" compact={compact}>
同类短剧推荐
</SectionTitle>
<div className={compact ? "grid grid-cols-3 gap-x-2.5 gap-y-4" : "grid grid-cols-[repeat(6,266px)] justify-between gap-y-8"}>
{list.map((item) =>
compact ? <H5ShortDramaCard key={item.id} item={item} /> : <ShortDramaCard key={item.id} item={item} />,
)}
</div>
</section>
);
}
/** 相关视频推荐(替代原评论区):PC 5 列 / H5 2 列 */
export function VideoRecommend({ items, compact }: { items: ShortDramaItem[]; compact?: boolean }) {
if (!items.length) return null;
return (
<section aria-label="相关视频推荐" className={`flex flex-col ${compact ? "gap-3" : "gap-6"}`}>
<SectionTitle moreHref="/video/hot" moreLabel="视频库" compact={compact}>
相关视频推荐
</SectionTitle>
<div className={compact ? "grid grid-cols-2 gap-x-2.5 gap-y-4" : "grid grid-cols-[repeat(5,324px)] justify-between gap-y-8"}>
{items.map((item) =>
compact ? <H5VideoCard key={item.id} item={item} /> : <VideoCard key={item.id} item={item} />,
)}
</div>
</section>
);
}
+5 -47
View File
@@ -1,20 +1,16 @@
import { AdBannersSlot, AdIconsSlot, FriendLinksSlot, H5FriendLinksSlot } from "@/components/common/ads/slots";
import Link from "next/link";
import type { ReactNode } from "react";
import { ChannelHeader, H5ChannelHeader } from "@/components/common/channel-header";
import { Pagination } from "@/components/common/pagination";
import { buildPageHrefMap } from "@/components/common/pagination-map";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { FloatingActions } from "@/components/home/floating-actions";
import { topicCountLabel, topicCoverSrc } from "@/components/home/home-module-style";
import { DownloadBanner } from "@/components/home/page-sections";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { H5ShortDramaCard, ShortDramaCard } from "@/components/short-drama/short-drama-card";
import { topicDetailHref } from "@/components/topic/href";
import { TopicListCard } from "@/components/topic/topic-list-card";
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import type { MorePageData } from "./data";
import { moreListHref } from "./route";
@@ -47,48 +43,10 @@ function TopicGrid({
}) {
if (!topics.length) return null;
return (
<div className={compact ? "grid grid-cols-2 gap-[7px]" : "grid grid-cols-2 gap-5 lg:grid-cols-4"}>
{topics.map((topic) => {
const cover = topicCoverSrc(topic);
const countText = topicCountLabel(topic, dataType, "部");
return (
<Link
key={topic.id}
href={topicDetailHref(topic, dataType)}
className={`relative overflow-hidden rounded-lg bg-[#110F1F] ${compact ? "aspect-[16/10]" : "h-[231px]"}`}
>
<DecryptedImage
src={cover}
fallback={IMG_PLACEHOLDER}
alt={topic.name || ""}
className="absolute inset-0 h-full w-full object-cover object-center"
/>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_38.85%_58.45%_at_66.18%_60.1%,rgba(255,255,255,0)_0%,rgba(0,0,0,0.55)_100%)]" />
<div className={compact ? "absolute bottom-3 left-3 right-3" : "absolute bottom-6 left-8 right-8"}>
<h3
className={
compact
? "truncate text-base font-medium text-[#F2F2FA]"
: "truncate text-2xl leading-8 font-medium text-[#F2F2FA]"
}
>
{topic.name}
</h3>
{countText ? (
<span
className={
compact
? "mt-1 inline-flex rounded bg-black/50 px-1.5 py-0.5 text-[10px] text-[#F68804]"
: "mt-3 inline-flex rounded bg-black/50 px-2.5 py-1 text-xs text-[#F68804]"
}
>
{countText}
</span>
) : null}
</div>
</Link>
);
})}
<div className={compact ? "grid grid-cols-2 gap-x-2.5 gap-y-4" : "grid grid-cols-2 gap-x-5 gap-y-7 lg:grid-cols-4"}>
{topics.map((topic) => (
<TopicListCard key={topic.id} topic={topic} dataType={dataType} compact={compact} />
))}
</div>
);
}
+15 -38
View File
@@ -5,10 +5,9 @@ import { DownloadTrigger } from "@/components/download/download-trigger";
import { MobileFooter } from "@/components/layout/site-footer";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { AppTranslationInfo, ConfigInfo } from "@/lib/api/types";
import { OFFICIAL_TG_URL, SITE_NAME, SITE_URL } from "@/lib/seo/metadata";
import { NavEmailCard } from "./nav-email-card";
const FALLBACK_LINE = "https://d81ub37cgqxo3.cloudfront.net";
import { NavTgCard } from "./nav-tg-card";
const guides = [
{ title: "使用方式", text: "优先使用线路一入口访问,如遇异常可切换线路2~3继续访问浏览。" },
@@ -42,41 +41,19 @@ function hrefOf(value?: string | null) {
return `https://${text}`;
}
function buildLines(config?: ConfigInfo | null) {
const site = config?.dhSiteName?.trim() || "海角社区";
const candidates = [
{ title: `${site} 精选全站导航中心-线路一`, url: config?.dhNewUrlAddr1 },
{ title: `${site} 精选全站导航中心-线路二`, url: config?.dhNewUrlAddr2 },
{ title: `${site} 精选全站导航中心-线路三`, url: config?.dhNewUrlAddr3 },
{ title: "永久域名(VPN访问)", url: config?.foreverUrlAddr },
]
.map((line) => ({ title: line.title, url: line.url?.trim() || "" }))
.filter((line) => line.url);
if (candidates.length) return candidates;
/** 线路固定本站域名,不取接口 dhNewUrlAddr* / foreverUrlAddr */
function buildLines() {
return [
{ title: "海角社区 精选全站导航中心-线路一", url: FALLBACK_LINE },
{ title: "海角社区 精选全站导航中心-线路二", url: FALLBACK_LINE },
{ title: "海角社区 精选全站导航中心-线路三", url: FALLBACK_LINE },
{ title: "永久域名(VPN访问)", url: FALLBACK_LINE },
{ title: `${SITE_NAME} 精选全站导航中心-线路一`, url: SITE_URL },
{ title: `${SITE_NAME} 精选全站导航中心-线路二`, url: SITE_URL },
{ title: `${SITE_NAME} 精选全站导航中心-线路三`, url: SITE_URL },
{ title: "永久域名(VPN访问)", url: SITE_URL },
];
}
function buildShortcuts(config?: ConfigInfo | null): Shortcut[] {
return [
{
label: "官方推特",
icon: "/images/nav_x.svg",
href: hrefOf(config?.twitterAddr) || "https://x.com/qimengshorts",
external: true,
},
{
label: "官方TG群",
icon: "/images/nav_tg.svg",
href: hrefOf(config?.tgGroup || config?.telegramAdd || config?.telegram) || "https://t.me/qimengshorts",
external: true,
},
{ label: "官方TG", icon: "/images/nav_tg.svg", href: OFFICIAL_TG_URL, external: true },
config?.appAddr?.trim()
? { label: "APP", icon: "/logo2.png", href: hrefOf(config.appAddr), external: true }
: { label: "APP", icon: "/logo2.png" },
@@ -85,7 +62,7 @@ function buildShortcuts(config?: ConfigInfo | null): Shortcut[] {
}
function AppBrand({ siteName }: { siteName?: string | null }) {
const name = siteName?.trim() || "海角社区";
const name = siteName?.trim() || "黄果黑料";
return (
<div className="flex items-center gap-3">
<img src="/logo2.png" alt="" className="size-10 rounded-[7px]" />
@@ -163,10 +140,10 @@ export function NavPage({
config?: ConfigInfo | null;
bottonTranslations?: AppTranslationInfo | null;
}) {
const lines = buildLines(config);
const lines = buildLines();
const shortcuts = buildShortcuts(config);
const siteName = config?.dhSiteName;
const webAddr = config?.webAddr?.trim() || config?.foreverUrlAddr?.trim() || "https://haijiaosq.net";
const siteName = SITE_NAME;
const webAddr = SITE_URL;
return (
<main data-component="nav-page" className="relative flex min-h-screen flex-1 flex-col overflow-hidden bg-black/70 text-white lg:bg-black/90">
@@ -247,11 +224,11 @@ export function NavPage({
alt=""
className="pointer-events-none absolute -top-[47px] -right-6 z-10 size-[107px] lg:right-3"
/>
<NavEmailCard gmailAddr={config?.gmailAddr} />
<NavTgCard />
<footer className="hidden flex-col items-center gap-6 py-6 text-center text-sm leading-5 font-medium lg:flex">
<AppBrand siteName={siteName} />
<p className="text-[#DCDCDC]">
2026 {siteName?.trim() || "海角社区"}精选全站导航中心 {webAddr} Inc. All Rights Reserved
2026 {siteName}精选全站导航中心 {webAddr} Inc. All Rights Reserved
</p>
<p className="text-[#F12B9E]">本站只为海外华人提供服务 非法律允许地区以及18岁以下用户禁止访问!</p>
</footer>
+15 -9
View File
@@ -3,11 +3,10 @@
import { useEffect, useState } from "react";
import { DownloadTrigger } from "@/components/download/download-trigger";
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL } from "@/lib/seo/metadata";
const FALLBACK_EMAIL = "xxxxxxx@proton.me";
export function NavEmailCard({ gmailAddr }: { gmailAddr?: string | null }) {
const email = gmailAddr?.trim() || FALLBACK_EMAIL;
/** 官方 TG 联系卡片(只保留 TG 联系方式,与黄果其它站点一致) */
export function NavTgCard() {
const [copied, setCopied] = useState(false);
useEffect(() => {
@@ -18,7 +17,7 @@ export function NavEmailCard({ gmailAddr }: { gmailAddr?: string | null }) {
const copy = async () => {
try {
await navigator.clipboard.writeText(email);
await navigator.clipboard.writeText(OFFICIAL_TG_HANDLE);
setCopied(true);
} catch {
setCopied(false);
@@ -28,13 +27,20 @@ export function NavEmailCard({ gmailAddr }: { gmailAddr?: string | null }) {
return (
<section className="flex flex-col gap-3 rounded-[20px] bg-[linear-gradient(140deg,#7B34FD_0%,#D2AFFD_31%,#D2AFFD_60%,#7B34FD_100%)] px-3 py-5 opacity-90">
<h2 className="text-xl leading-7 font-semibold text-black lg:text-center lg:font-medium lg:text-black/90">
发送<span className="text-[#1C3EFF] lg:text-[#1B3AE7]">任意消息</span>到邮箱
联系<span className="text-[#1C3EFF] lg:text-[#1B3AE7]">官方TG</span>
<br className="lg:hidden" />
自动获取<span className="text-[#1C3EFF] lg:text-[#1A36D1]">最新【免翻墙】地址</span>
获取<span className="text-[#1C3EFF] lg:text-[#1A36D1]">最新【免翻墙】地址</span>
</h2>
<div className="flex flex-col gap-4">
<div className="flex h-11 items-center justify-between gap-3 rounded-xl bg-[#FAFAFA] px-3">
<span className="truncate text-base leading-[22px] font-medium text-black">{email}</span>
<a
href={OFFICIAL_TG_URL}
target="_blank"
rel="noopener noreferrer"
className="truncate text-base leading-[22px] font-medium text-black hover:underline"
>
{OFFICIAL_TG_HANDLE}
</a>
<button
type="button"
onClick={copy}
@@ -44,7 +50,7 @@ export function NavEmailCard({ gmailAddr }: { gmailAddr?: string | null }) {
</button>
</div>
<DownloadTrigger className="flex h-10 items-center justify-center gap-2 rounded-xl bg-[linear-gradient(135deg,#F12B9E_0%,#E952E2_52%,#F12B9E_100%)] text-sm leading-5 font-medium text-white shadow-[inset_0_2px_2px_rgba(255,255,255,0.4)]">
下载海角社区精选全站导航中心APP
下载黄果黑料精选全站导航中心APP
<svg viewBox="0 0 24 24" className="size-6" fill="none" aria-hidden="true">
<path d="m9 6 6 6-6 6" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
+1 -13
View File
@@ -5,7 +5,6 @@ import type Player from "xgplayer";
import type { IPlayerOptions } from "xgplayer";
import "xgplayer/dist/index.min.css";
import { bindPlayerAnalytics } from "@/components/analytics/use-playback-analytics";
import { useDecryptedSrc } from "@/hooks/use-decrypted-src";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { isHLSURL, nativeHLS, SHELL_BROWSER } from "@/lib/media/playback";
@@ -63,8 +62,6 @@ type XgPlayerProps = {
onPlaySuccess?: () => void;
/** 当前集播放结束 */
onEnded?: () => void;
/** 播放埋点类型;不传则不上报播放行为 */
analyticsKind?: "drama" | "video";
/**
* 进入页不自动播,需点播放按钮。
* 默认 true;短剧连播下一集时可显式传 false。
@@ -81,7 +78,6 @@ export function XgPlayer({
onReady,
onPlaySuccess,
onEnded,
analyticsKind,
clickToPlay = true,
}: XgPlayerProps) {
const hostRef = useRef<HTMLDivElement>(null);
@@ -105,7 +101,6 @@ export function XgPlayer({
let player: Player | null = null;
let cancelled = false;
let playSucceeded = false;
let unbindAnalytics: (() => void) | undefined;
/** 用户点过播放后,不再拦截正常起播 */
let userStarted = !clickToPlay;
@@ -230,12 +225,6 @@ export function XgPlayer({
onEndedRef.current?.();
});
window.addEventListener("resize", syncZoom);
if (analyticsKind) {
unbindAnalytics = bindPlayerAnalytics(
player as { media?: HTMLVideoElement | null },
analyticsKind,
);
}
if (playerRef) playerRef.current = player;
onReadyRef.current?.(player);
if (clickToPlay) {
@@ -255,12 +244,11 @@ export function XgPlayer({
return () => {
cancelled = true;
unbindAnalytics?.();
window.removeEventListener("resize", syncZoom);
if (playerRef) playerRef.current = null;
player?.destroy();
};
}, [src, posterSrc, playerRef, analyticsKind, clickToPlay]);
}, [src, posterSrc, playerRef, clickToPlay]);
return <div data-component="xg-player" ref={hostRef} className={`xg-theme isolate ${className}`} />;
}
+1 -1
View File
@@ -19,7 +19,7 @@ const clauses: Clause[] = [
},
{
title: "五、您的权利",
text: "您可以依法访问、更正、删除个人信息,管理授权或注销账号。如需帮助,可通过 privacy@qimengshorts.com 联系我们。",
text: "您可以依法访问、更正、删除个人信息,管理授权或注销账号。如需帮助,可通过官方TG @jbd125 联系我们。",
},
];
-15
View File
@@ -29,18 +29,3 @@ export const loadRichText = cache(async (title: string): Promise<RichTextInfo |
return null;
}
});
/**
* 按候选 title 依次查询富文本,返回首个有内容的结果。
* 帮助中心等入口:上游未必有「帮助中心」标题,可回退「常见问题」等。
*/
export async function loadRichTextFirst(titles: string[]): Promise<RichTextInfo | null> {
for (const title of titles) {
const data = await loadRichText(title);
if (data?.content?.trim()) return data;
}
return null;
}
/** 帮助中心富文本 title 候选(与 swagger GET /api/app/richtext?title= 一致) */
export const HELP_RICHTEXT_TITLES = ["帮助中心", "常见问题", "帮助-鉴别指南"] as const;
-9
View File
@@ -14,7 +14,6 @@ import { H5VideoCard, VideoCard } from "@/components/video/video-card";
import { searchSummary, searchTypes, type SearchListResult } from "./data";
import { searchListHref } from "./route";
import { SearchTrack } from "./search-track";
function SearchIcon({ className = "" }: { className?: string }) {
return (
@@ -32,14 +31,6 @@ export function SearchPage({ list }: { list: SearchListResult }) {
return (
<main data-component="search-page" className="flex-1 bg-[#07060E] lg:bg-[linear-gradient(90deg,#0E0813_0%,#07060E_100%)]">
{query ? (
<SearchTrack
query={query}
resultCount={totalCount}
page={page}
contentType={isVideo ? "video" : "playlet"}
/>
) : null}
<div
id="search-top"
data-pc-scale
-32
View File
@@ -1,32 +0,0 @@
"use client";
import { useEffect } from "react";
import { tracker } from "@/lib/tracker";
/** 搜索结果页:上报 keywordSearch(仅首页) */
export function SearchTrack({
query,
resultCount,
page,
contentType,
}: {
query: string;
resultCount: number;
page: number;
contentType?: string;
}) {
useEffect(() => {
const keyword = query.trim();
if (!keyword || page > 1) return;
void tracker.keywordSearch({
keyword,
search_result_count: Math.max(0, resultCount),
search_content_type: contentType || "",
search_trace_id: "",
search_id: "",
});
}, [query, resultCount, page, contentType]);
return null;
}
+2
View File
@@ -37,6 +37,8 @@ export type ShortDramaItem =
episodes: string;
badge?: BadgeTone;
cover: string;
/** 发布时间(接口原值,视频卡展示日期用) */
date?: string;
/** 覆盖默认详情/播放链接(如历史继续看到某集) */
href?: string;
}
+76 -114
View File
@@ -2,79 +2,75 @@ import Link from "next/link";
import { CardTags } from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
MEDIA_CARD_COVER,
MEDIA_CARD_DESC,
MEDIA_CARD_DESC_SM,
MEDIA_CARD_EPISODES,
MEDIA_CARD_EPISODES_H5,
MEDIA_CARD_FOOTER_H5,
MEDIA_CARD_INFO_H5,
MEDIA_CARD_META,
MEDIA_CARD_ROOT,
MEDIA_CARD_TITLE_H5,
MEDIA_CARD_TITLE_PC,
} from "@/components/common/media-card-hover";
import { CoverRankBadge, RankNumber } from "@/components/common/rank-number";
import { Badge } from "@/components/home/section-heading";
import { EyeIcon, HL_COVER, HL_COVER_IMG, HL_TITLE_LINK, HlBadge } from "@/components/common/media-meta";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { dramaHref, type ShortDramaItem } from "./data";
export function ShortDramaCard({ item, rank }: { item: ShortDramaItem; rank?: number }) {
if (item.kind === "ad") {
return (
<a
href={item.href || "#download"}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`block w-[266px] ${MEDIA_CARD_ROOT}`}
>
<div className="relative h-[355px] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
<div className="absolute inset-0 bg-black/40" />
<img src="/logo.svg" alt="海角社区" className="absolute top-[21px] left-[21px] h-[19px] w-[84px]" />
</div>
<div className="flex min-h-[96px] flex-col justify-center gap-1 px-2.5 py-3">
<h3 className={MEDIA_CARD_TITLE_PC}>{item.title}</h3>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>{item.subtitle}</p>
</div>
</a>
);
}
/** 排行序号:No.01(琥珀色,放在标题前) */
function RankLabel({ rank, className = "" }: { rank?: number; className?: string }) {
if (rank === undefined) return null;
return (
<span className={`mr-1.5 font-bold text-[#FFB020] tabular-nums ${className}`}>No.{String(rank).padStart(2, "0")}</span>
);
}
const detailHref = item.href || dramaHref(item.id, item.slug);
/** 短剧卡元信息:集数 + 热度(文字行,不叠在封面上) */
function DramaMeta({ item, className }: { item: Extract<ShortDramaItem, { kind: "drama" }>; className: string }) {
return (
<p className={`flex min-w-0 items-center gap-2 text-[#8E8C99] ${className}`}>
{item.episodes ? <span className="shrink-0 text-[#D9C59A]">共{item.episodes}</span> : null}
{item.episodes && item.heat ? <span aria-hidden="true" className="h-2.5 w-px bg-white/15" /> : null}
{item.heat ? (
<span className="inline-flex min-w-0 items-center gap-1 truncate">
<EyeIcon className="size-3.5 shrink-0" />
{item.heat}
</span>
) : null}
</p>
);
}
function AdDramaCard({ item, compact }: { item: ShortDramaItem; compact?: boolean }) {
return (
<a
href={item.href || (compact ? "#h5-download" : "#download")}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`group flex flex-col ${compact ? "gap-1.5" : "w-[266px] gap-2.5"}`}
>
<span className={`${HL_COVER} aspect-[2/3]`}>
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt={item.title} className={HL_COVER_IMG} />
<span className="absolute top-0 left-0 rounded-br-md bg-white/85 px-1.5 py-0.5 text-[10px] leading-[14px] text-black">
推广
</span>
</span>
<span className={`truncate text-white ${compact ? "text-sm leading-5" : "media-card-title-md"}`}>{item.title}</span>
</a>
);
}
export function ShortDramaCard({ item, rank }: { item: ShortDramaItem; rank?: number }) {
if (item.kind === "ad") return <AdDramaCard item={item} />;
const href = item.href || dramaHref(item.id, item.slug);
return (
<article data-component="short-drama-card" className={`w-[266px] ${MEDIA_CARD_ROOT}`}>
<Link href={detailHref} className="block">
<div className="relative h-[355px] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
{item.badge ? (
<div className="absolute top-2 right-2">
<Badge tone={item.badge} />
</div>
) : null}
{item.episodes ? (
<span
className={`absolute right-2 bottom-2 rounded-md bg-black/60 px-2.5 py-1.5 font-medium text-white ${MEDIA_CARD_EPISODES}`}
>
{item.episodes}
</span>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-1 px-2.5 pt-3">
<h3 className={`flex min-h-7 items-center gap-1 ${MEDIA_CARD_TITLE_PC}`}>
<RankNumber rank={rank} />
<span className="min-w-0 truncate">{item.title}</span>
</h3>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>{item.subtitle}</p>
</div>
<article data-component="hl-drama-card" className="group flex w-[266px] flex-col gap-2">
<Link href={href} tabIndex={-1} aria-hidden="true" className={`${HL_COVER} aspect-[2/3]`}>
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt={item.title} className={HL_COVER_IMG} />
<HlBadge tone={item.badge} />
</Link>
<div className={`flex min-w-0 items-center justify-between gap-2 px-2.5 pt-1 pb-3 ${MEDIA_CARD_META}`}>
<CardTags tags={item.tags} kind="playlet" className="min-w-0 text-white/60" />
<span className="shrink-0 text-[#F92882]">{item.heat}</span>
</div>
<h3 className="media-card-title-md truncate font-medium text-[#F4F3F7]">
<RankLabel rank={rank} />
<Link href={href} title={item.title} className={HL_TITLE_LINK}>
{item.title}
</Link>
</h3>
<DramaMeta item={item} className="media-card-desc" />
{item.subtitle ? (
<p className="media-card-desc line-clamp-2 min-h-[2.4em] text-[#7D7A88]">{item.subtitle}</p>
) : null}
<CardTags tags={item.tags} kind="playlet" className="media-card-meta min-w-0 text-[#B49A6A]" />
</article>
);
}
@@ -82,63 +78,29 @@ export function ShortDramaCard({ item, rank }: { item: ShortDramaItem; rank?: nu
export function H5ShortDramaCard({
item,
rank,
showEpisodes = true,
}: {
item: ShortDramaItem;
rank?: number;
/** @deprecated 新卡片不在封面显示集数,保留参数兼容调用方 */
showEpisodes?: boolean;
}) {
if (item.kind === "ad") {
return (
<a
href={item.href || "#h5-download"}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`block ${MEDIA_CARD_ROOT}`}
>
<div className="relative aspect-[3/4] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
<div className="absolute inset-0 bg-black/40" />
<img src="/logo.svg" alt="海角社区" className="absolute top-[17px] left-[13px] h-4 w-[71px]" />
</div>
<div className={`${MEDIA_CARD_INFO_H5} pb-1.5`}>
<h3 className={MEDIA_CARD_TITLE_H5}>{item.title}</h3>
<p className={`${MEDIA_CARD_DESC_SM} text-[#8C859C]`}>{item.subtitle}</p>
</div>
</a>
);
}
const detailHref = item.href || dramaHref(item.id, item.slug);
if (item.kind === "ad") return <AdDramaCard item={item} compact />;
const href = item.href || dramaHref(item.id, item.slug);
return (
<article data-component="h5-short-drama-card" className={MEDIA_CARD_ROOT}>
<Link href={detailHref} className="block">
<div className="relative aspect-[3/4] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
{item.badge ? (
<div className="absolute top-2 right-2">
<Badge tone={item.badge} className="h-6" />
</div>
) : null}
<CoverRankBadge rank={rank} />
{showEpisodes ? (
<span
className={`absolute right-2 bottom-2 rounded-lg bg-[#08060D]/88 px-1.5 py-0.5 text-white ${MEDIA_CARD_EPISODES_H5}`}
>
{item.episodes}
</span>
) : null}
</div>
<div className={MEDIA_CARD_INFO_H5}>
<h3 className={MEDIA_CARD_TITLE_H5}>{item.title}</h3>
<p className={`${MEDIA_CARD_DESC_SM} text-[#8C859C]`}>{item.subtitle}</p>
</div>
<article data-component="hl-drama-card-h5" className="group flex min-w-0 flex-col gap-1">
<Link href={href} tabIndex={-1} aria-hidden="true" className={`${HL_COVER} aspect-[2/3]`}>
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt={item.title} className={HL_COVER_IMG} />
<HlBadge tone={item.badge} small />
</Link>
<div className={MEDIA_CARD_FOOTER_H5}>
<CardTags tags={item.tags} kind="playlet" className="min-w-0 text-[#A89EB5]" />
<span className="shrink-0 text-[#F12B9E]">{item.heat}</span>
</div>
<h3 className="mt-0.5 truncate text-sm leading-5 font-medium text-[#F4F3F7]">
<RankLabel rank={rank} className="text-xs" />
<Link href={href} title={item.title} className={HL_TITLE_LINK}>
{item.title}
</Link>
</h3>
<DramaMeta item={item} className="text-[11px] leading-4" />
{item.subtitle ? <p className="line-clamp-2 text-[11px] leading-4 text-[#7D7A88]">{item.subtitle}</p> : null}
</article>
);
}
+88
View File
@@ -0,0 +1,88 @@
import Link from "next/link";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { HL_COVER, HL_COVER_IMG, HL_TITLE_LINK } from "@/components/common/media-meta";
import { resolveTopicWorkCount, topicCoverSrc } from "@/components/home/home-module-style";
import { dramaHref } from "@/components/short-drama/data";
import { videoHref } from "@/components/video/video-card";
import type { HomeTopicData, PlayletData, VideoInfo } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { topicDetailHref } from "./href";
type TopicWork = { key: string; title: string; href: string };
/** 专题里的前几部作品:给卡片补充可抓取的站内链接 */
function topicWorks(topic: HomeTopicData, dataType: 1 | 2, limit: number): TopicWork[] {
const works: TopicWork[] = [];
for (const raw of (topic.list ?? []) as Array<PlayletData | VideoInfo | { playlet?: PlayletData; video?: VideoInfo }>) {
if (works.length >= limit) break;
if (!raw || typeof raw !== "object") continue;
const wrapped = raw as { playlet?: PlayletData; video?: VideoInfo };
const isVideo = Boolean(wrapped.video) || (!wrapped.playlet && dataType === 2);
const item = (wrapped.playlet ?? wrapped.video ?? raw) as PlayletData | VideoInfo;
const title = (item.title || "").trim();
if (!item.id || !title) continue;
works.push({
key: `${isVideo ? "v" : "p"}-${item.id}`,
title,
href: isVideo ? videoHref(String(item.id), item.slug) : dramaHref(String(item.id), item.slug),
});
}
return works;
}
/**
* 专题列表卡:上图下文。封面不叠文字;
* 下方专题名 + 收录数量 + 前几部作品链接(区别于其它导航站的全图叠字 / 扇形叠封面)。
*/
export function TopicListCard({
topic,
dataType,
compact,
}: {
topic: HomeTopicData;
dataType: 1 | 2;
compact?: boolean;
}) {
const href = topicDetailHref(topic, dataType);
const name = topic.name?.trim() || "未命名专题";
const count = resolveTopicWorkCount(topic, dataType);
const works = topicWorks(topic, dataType, compact ? 2 : 3);
const unit = dataType === 2 ? "个视频" : "部短剧";
return (
<article data-component="hl-topic-card" className="group flex min-w-0 flex-col gap-2 lg:gap-3">
<Link href={href} tabIndex={-1} aria-hidden="true" className={`${HL_COVER} ${compact ? "aspect-[2/1]" : "aspect-[21/10]"}`}>
<DecryptedImage src={topicCoverSrc(topic)} fallback={IMG_PLACEHOLDER} alt={name} className={HL_COVER_IMG} />
</Link>
<div className="flex min-w-0 items-baseline justify-between gap-2">
<h3 className={`min-w-0 truncate font-medium text-[#F4F3F7] ${compact ? "text-sm leading-5" : "media-card-title-md"}`}>
<Link href={href} title={name} className={HL_TITLE_LINK}>
{name}
</Link>
</h3>
{count ? (
<span className={`shrink-0 text-[#FFB020] ${compact ? "text-[11px] leading-4" : "media-card-desc"}`}>
收录{count}
{unit}
</span>
) : null}
</div>
{works.length ? (
<ul className={`flex min-w-0 flex-col border-l border-[#FFB020]/40 pl-2.5 ${compact ? "gap-0.5" : "gap-1"}`}>
{works.map((work) => (
<li key={work.key} className="min-w-0">
<Link
href={work.href}
className={`block truncate text-[#8E8C99] hover:text-[#FFB020] ${compact ? "text-[11px] leading-4" : "media-card-desc"}`}
>
{work.title}
</Link>
</li>
))}
</ul>
) : null}
</article>
);
}
+3 -33
View File
@@ -1,18 +1,13 @@
import Link from "next/link";
import { AdBannersSlot, AdIconsSlot, FriendLinksSlot, H5FriendLinksSlot } from "@/components/common/ads/slots";
import { ChannelHeader, H5ChannelHeader } from "@/components/common/channel-header";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { FilterChipLink, FilterOptionLink } from "@/components/common/filter-link";
import { MEDIA_CARD_TITLE_PC } from "@/components/common/media-card-hover";
import { Pagination } from "@/components/common/pagination";
import { buildPageHrefMap } from "@/components/common/pagination-map";
import { FloatingActions } from "@/components/home/floating-actions";
import { topicCoverSrc } from "@/components/home/home-module-style";
import { DownloadBanner } from "@/components/home/page-sections";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { HomeTopicData, PlayletData, VideoInfo } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import {
isTopicVideo,
@@ -20,9 +15,9 @@ import {
unwrapTopicListItem,
type TopicPageData,
} from "./data";
import { topicDetailHref } from "./href";
import { topicListHref, topicSortOptions } from "./route";
import { TopicPlayletCard, TopicVideoCard } from "./topic-cards";
import { TopicListCard } from "./topic-list-card";
const layouts = {
playlet: {
@@ -54,34 +49,9 @@ function MoreTopicGrid({
return (
<section className="flex flex-col gap-3 lg:gap-5">
<h2 className={compact ? "text-base font-medium text-white" : "text-xl font-medium text-white"}>更多专题</h2>
<div className={compact ? "grid grid-cols-2 gap-[7px]" : gridClassName}>
<div className={compact ? "grid grid-cols-2 gap-x-2.5 gap-y-4" : gridClassName}>
{topics.map((topic) => (
<Link
key={topic.id}
href={topicDetailHref(topic, dataType)}
className={`relative overflow-hidden rounded-lg bg-[#110F1F] ${
compact ? (dataType === 2 ? "aspect-[7/4]" : "aspect-[16/10]") : "h-[231px]"
}`}
>
<DecryptedImage
src={topicCoverSrc(topic)}
fallback={IMG_PLACEHOLDER}
alt={topic.name || ""}
className="absolute inset-0 h-full w-full object-cover object-center"
/>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_38.85%_58.45%_at_66.18%_60.1%,rgba(255,255,255,0)_0%,rgba(0,0,0,0.55)_100%)]" />
<div className={compact ? "absolute bottom-3 left-3 right-3" : "absolute bottom-6 left-8 right-8"}>
<h3
className={
compact
? "truncate text-base font-medium text-[#F2F2FA]"
: `text-[#F2F2FA] ${MEDIA_CARD_TITLE_PC}`
}
>
{topic.name}
</h3>
</div>
</Link>
<TopicListCard key={topic.id} topic={topic} dataType={dataType} compact={compact} />
))}
</div>
</section>
+1
View File
@@ -34,6 +34,7 @@ function mapHotVideoItem(item: VideoData): ShortDramaItem {
heat: formatHeat(item.watchNum),
episodes: "",
cover: (item.coverImg || "").trim(),
date: item.addedTime || undefined,
};
}
+58 -181
View File
@@ -1,19 +1,14 @@
"use client";
import Link from "next/link";
import { useState, type ReactNode } from "react";
import type { ReactNode } from "react";
import { BackButton } from "@/components/common/back-button";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { H5CommentsTab, useComments, type CommentTarget } from "@/components/common/comments";
import { BackButton } from "@/components/common/back-button";
import { HlAction, InfoList, SectionTitle, TagLinks, VideoRecommend, type InfoRow, type TagLinkItem } from "@/components/media-page/parts";
import { XgPlayer } from "@/components/player/xg-player";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { tagPageHref } from "@/components/tag/href";
import { H5VideoCard } from "@/components/video/video-card";
import { formatCount, formatWatchNum, type VideoDetail } from "./data";
type Tab = "intro" | "comments";
import { formatCount, type VideoDetail } from "./data";
type StatsProps = {
liked: boolean;
@@ -27,35 +22,16 @@ type StatsProps = {
onShare: () => void;
};
function H5VideoPlayer({
/** H5 视频页:播放器吸顶 → 正文(h1 / 资料 / 标签 / 介绍 / 操作)→ 相关视频推荐(替代原评论标签页) */
export function H5VideoPlay({
detail,
src,
related = [],
infoRows,
tags,
onPlaySuccess,
}: {
detail: VideoDetail;
src: string;
onPlaySuccess?: () => void;
}) {
// 上下文由 VideoPlayPage 统一设置;此处仅绑定播放器行为
return (
<div className="sticky top-0 z-20 aspect-video w-full overflow-hidden bg-black">
<XgPlayer
src={src}
poster={detail.coverImg || ""}
onPlaySuccess={onPlaySuccess}
analyticsKind="video"
clickToPlay
className="absolute inset-0"
options={{ ignores: ["cssfullscreen"], fullscreen: { rotateFullscreen: true } }}
/>
<BackButton fallback="/video/hot" className="absolute top-3 left-4 z-10" />
</div>
);
}
function IntroTab({
detail,
related,
adGrid,
bannerAds,
liked,
likeNum,
collected,
@@ -63,163 +39,64 @@ function IntroTab({
onLike,
onCollect,
onShare,
adGrid,
bannerAds,
}: StatsProps & {
detail: VideoDetail;
related: ShortDramaItem[];
adGrid?: ReactNode;
bannerAds?: ReactNode;
}) {
const tags = (detail.tags ?? []).filter((tag) =>
Boolean(tag.name?.trim() || tag.slug?.trim() || tag.id),
);
const likeCount = likeNum ?? detail.likeNum;
const collectCount = countCollect ?? detail.countCollect;
return (
<div className="flex flex-col gap-[18px] bg-[#010406] px-4 py-3">
<h1 className="line-clamp-2 text-sm leading-5 font-medium text-white/80">{detail.title}</h1>
{detail.desc?.trim() ? (
<p className="text-xs leading-[18px] text-white/80">{detail.desc}</p>
) : null}
{tags.length ? (
<div className="flex flex-wrap items-center gap-1.5">
{tags.map((tag) => {
const name = (tag.tagName || tag.name || "").trim() || tag.slug?.trim() || "";
if (!name) return null;
const canLink = Boolean(tag.slug?.trim() || tag.id != null);
return canLink ? (
<Link
key={tag.slug || tag.id || name}
href={tagPageHref({ slug: tag.slug, id: tag.id }, "video")}
className="rounded-[3px] bg-white/10 px-1.5 py-0.5 text-xs leading-[18px] text-white/90 hover:text-[#F12B9E]"
>
#{name}
</Link>
) : (
<span
key={name}
className="rounded-[3px] bg-white/10 px-1.5 py-0.5 text-xs leading-[18px] text-white/90"
>
#{name}
</span>
);
})}
</div>
) : null}
<div className="flex items-center justify-between text-xs leading-[18px]">
<span className="text-[#BDBDBD]">{formatWatchNum(detail.watchNum)}</span>
<div className="flex items-center gap-3 text-[#BDBDBD]">
<button type="button" aria-pressed={liked} onClick={onLike} className="cursor-pointer flex items-center gap-0.5">
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
<path
d="M8 14c-.2 0-.4-.07-.56-.2C5.3 12 2 9.7 2 6.6 2 4.6 3.4 3 5.2 3c1.1 0 2.1.6 2.8 1.5C8.7 3.6 9.7 3 10.8 3 12.6 3 14 4.6 14 6.6c0 3.1-3.3 5.4-5.44 7.2-.16.13-.36.2-.56.2Z"
fill={liked ? "#F52C56" : "none"}
stroke="#F52C56"
strokeWidth="1.4"
/>
</svg>
{formatCount(likeCount)}
</button>
<button
type="button"
aria-pressed={collected}
onClick={onCollect}
className="cursor-pointer flex items-center gap-0.5"
>
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
<path
d="m8 2 1.8 3.7 4 .6-2.9 2.8.7 4L8 11.2l-3.6 1.9.7-4-2.9-2.8 4-.6L8 2Z"
fill={collected ? "#FFC53D" : "none"}
stroke="#FFC53D"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
{formatCount(collectCount)}
</button>
<button type="button" onClick={onShare} className="cursor-pointer flex items-center gap-0.5">
<img src="/images/share_h5.svg" alt="" className="size-4" />
分享
</button>
</div>
</div>
{/* icon 广告 → 热门推荐 → banner 广告 */}
{adGrid ?? <AdIcons variant="h5" />}
{related.length ? (
<section className="flex flex-col items-center gap-3">
<h2 className="w-full text-lg leading-6 font-semibold text-white">热门推荐</h2>
<div className="grid w-full grid-cols-2 gap-x-[7px] gap-y-3">
{related.map((item) => (
<H5VideoCard key={item.id} item={item} showEpisodes={false} />
))}
</div>
</section>
) : null}
{bannerAds ?? <AdBanners variant="h5" />}
</div>
);
}
export function H5VideoPlay({
detail,
src,
related = [],
commentTarget,
onPlaySuccess,
adGrid,
bannerAds,
...stats
}: StatsProps & {
detail: VideoDetail;
src: string;
related?: ShortDramaItem[];
commentTarget: CommentTarget | null;
infoRows: InfoRow[];
tags: TagLinkItem[];
onPlaySuccess?: () => void;
adGrid?: ReactNode;
bannerAds?: ReactNode;
}) {
const [tab, setTab] = useState<Tab>("intro");
const comments = useComments({ target: commentTarget, initialSort: "最新" });
const tabs: { key: Tab; label: string }[] = [
{ key: "intro", label: "简介" },
{ key: "comments", label: `评论(${comments.total})` },
];
const title = detail.title || "视频";
const desc = detail.desc?.trim() || "";
return (
<div data-component="video-h5-play" className="flex flex-col bg-[#010406] lg:hidden">
<H5VideoPlayer detail={detail} src={src} onPlaySuccess={onPlaySuccess} />
<div role="tablist" className="flex gap-3 px-4 pt-[18px]">
{tabs.map((item) => (
<button
key={item.key}
type="button"
role="tab"
aria-selected={tab === item.key}
onClick={() => setTab(item.key)}
className={`cursor-pointer text-base leading-[22px] ${tab === item.key ? "font-medium text-white" : "text-[#8D8E8F]"}`}
>
{item.label}
</button>
))}
</div>
{tab === "intro" ? (
<IntroTab detail={detail} related={related} adGrid={adGrid} bannerAds={bannerAds} {...stats} />
) : (
<H5CommentsTab
state={comments}
footerExtra={
bannerAds ? <div className="px-4 py-3">{bannerAds}</div> : <AdBanners variant="h5" className="px-4 py-3" />
}
<div data-component="hl-video-play-h5" className="flex flex-col bg-[#0C0B10] lg:hidden">
<div className="sticky top-0 z-20 aspect-video w-full overflow-hidden bg-black">
<XgPlayer
src={src}
poster={detail.coverImg || ""}
onPlaySuccess={onPlaySuccess}
clickToPlay
className="absolute inset-0"
options={{ ignores: ["cssfullscreen"], fullscreen: { rotateFullscreen: true } }}
/>
)}
<BackButton fallback="/video/hot" className="absolute top-3 left-4 z-10" />
</div>
<div className="flex flex-col gap-6 px-4 pt-4 pb-10">
<article className="flex flex-col gap-3">
<p className="text-[11px] leading-4 tracking-[0.15em] text-[#FFB020]">视频库</p>
<h1 className="text-lg leading-7 font-bold text-white">{title}</h1>
<InfoList rows={infoRows} compact />
<TagLinks tags={tags} compact />
<div className="flex items-center gap-2">
<HlAction kind="like" label={formatCount(likeNum ?? detail.likeNum)} active={liked} onClick={onLike} compact />
<HlAction
kind="collect"
label={formatCount(countCollect ?? detail.countCollect)}
active={collected}
onClick={onCollect}
compact
/>
<HlAction kind="share" label="分享" onClick={onShare} compact />
</div>
{desc ? (
<section className="flex flex-col gap-2 pt-1">
<SectionTitle compact>视频介绍</SectionTitle>
<p className="text-[13px] leading-[22px] whitespace-pre-line text-[#A9A7B3]">{desc}</p>
</section>
) : null}
</article>
{adGrid ?? <AdIcons variant="h5" />}
<VideoRecommend items={related} compact />
{bannerAds ?? <AdBanners variant="h5" />}
</div>
</div>
);
}
+84 -98
View File
@@ -1,28 +1,32 @@
"use client";
import { toast } from "@heroui/react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { useVideoContext } from "@/components/analytics/use-playback-analytics";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { CommentPanel, videoCommentTarget } from "@/components/common/comments";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { ActionPill } from "@/components/drama-play/drama-play-page";
import { formatDay, formatDuration } from "@/components/common/media-meta";
import { PcPlayer } from "@/components/drama-play/pc-player";
import { FloatingActions } from "@/components/home/floating-actions";
import {
HlAction,
InfoList,
SectionTitle,
TagLinks,
VideoRecommend,
type InfoRow,
type TagLinkItem,
} from "@/components/media-page/parts";
import { PageBreadcrumb } from "@/components/seo/page-breadcrumb";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { tagPageHref } from "@/components/tag/href";
import { favoriteVideo, getVideoInfo, likeVideo } from "@/lib/api/video-actions";
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import type { VideoDetail } from "./data";
import { formatCount, formatWatchNum, type VideoDetail } from "./data";
import { H5VideoPlay } from "./h5-video-play";
import { VideoRelatedAside } from "./related-aside";
import { useReportVideoHistory } from "./use-report-video-history";
async function ensureVideoAuthToken() {
let token = getAuthToken();
@@ -51,7 +55,6 @@ export function VideoPlayPage({
detail,
src,
related = [],
relatedAside,
promo,
adIcons,
adGrid,
@@ -60,7 +63,6 @@ export function VideoPlayPage({
detail: VideoDetail;
src: string;
related?: ShortDramaItem[];
relatedAside?: ReactNode;
promo?: ReactNode;
adIcons?: ReactNode;
adGrid?: ReactNode;
@@ -72,24 +74,6 @@ export function VideoPlayPage({
const [collected, setCollected] = useState(Boolean(detail.isFavorite));
const [countCollect, setCountCollect] = useState(Number(detail.countCollect) || 0);
const [collecting, setCollecting] = useState(false);
const reportWatchHistory = useReportVideoHistory(Number(detail.id));
const tagName = (detail.tags?.[0]?.tagName || detail.tags?.[0]?.name || "").trim();
const videoTrackContext = useMemo(
() => ({
media_id: String(detail.id || ""),
video_id: String(detail.id || ""),
video_title: detail.title || "未命名视频",
video_type_id: "0",
video_type_name: "未分类",
video_content_type: "video",
recommend_trace_id: "",
video_tag_key: tagName ? "tag" : "default",
video_tag_name: tagName || "默认标签",
video_duration: Math.max(0, Math.round(Number(detail.video?.videoTime) || 0)),
}),
[detail.id, detail.title, detail.video?.videoTime, tagName],
);
useVideoContext(videoTrackContext);
const likeFavoriteKey = `${detail.id}:${detail.isLike}:${detail.likeNum}:${detail.isFavorite}:${detail.countCollect}`;
const [likeFavoriteSync, setLikeFavoriteSync] = useState(likeFavoriteKey);
@@ -206,88 +190,92 @@ export function VideoPlayPage({
}
};
return (
<main data-component="video-play-page" className="relative flex-1 bg-[#0B0709]">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 hidden h-[1200px] overflow-hidden [mask-image:linear-gradient(180deg,#000_70%,transparent_100%)] lg:block"
>
<DecryptedImage
src={detail.coverImg}
fallback={IMG_PLACEHOLDER}
alt=""
className="size-full scale-110 object-cover opacity-70 blur-[12px]"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(9,5,9,0.32)_0%,rgba(9,5,9,0.56)_55%,rgba(6,5,9,0.94)_100%)]" />
</div>
const title = detail.title || "视频";
const desc = detail.desc?.trim() || "";
const day = formatDay(detail.addedTime);
const duration = formatDuration(detail.video?.videoTime);
const tags: TagLinkItem[] = (detail.tags ?? [])
.map((tag): TagLinkItem | null => {
const name = (tag.tagName || tag.name || "").trim();
if (!name) return null;
const canLink = Boolean(tag.slug?.trim() || tag.id != null);
return {
key: tag.slug || String(tag.id ?? name),
name,
href: canLink ? tagPageHref({ slug: tag.slug, id: tag.id }, "video") : undefined,
};
})
.filter((tag): tag is TagLinkItem => Boolean(tag));
const infoRows: InfoRow[] = [
{ label: "发布", value: day ? <time dateTime={day}>{day}</time> : "" },
{ label: "时长", value: duration },
{ label: "播放", value: formatWatchNum(detail.watchNum) },
{ label: "收藏", value: formatCount(countCollect) },
];
const crumbs = [
{ name: "首页", href: "/" },
{ name: "视频库", href: "/video/hot" },
{ name: title },
];
return (
<main data-component="hl-video-play" className="relative flex-1 bg-[#0C0B10]">
<SeoBreadcrumb items={crumbs} />
<div
data-pc-scale
className="relative mx-auto hidden w-[1700px] flex-col gap-6 pt-6 pb-8 [zoom:var(--page-scale,1)] lg:flex"
className="relative mx-auto hidden w-[1700px] flex-col gap-8 pt-6 pb-14 [zoom:var(--page-scale,1)] lg:flex"
>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "视频详情" },
]}
/>
<PageBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "视频详情" },
]}
<PageBreadcrumb items={crumbs} />
<PcPlayer
src={src}
poster={detail.coverImg || ""}
title={title}
locked={false}
dramaTitle={title}
layout="wide"
/>
<div className="flex flex-col gap-4">
<PcPlayer
src={src}
poster={detail.coverImg || ""}
title={detail.title || ""}
locked={false}
dramaTitle={detail.title || ""}
layout="wide"
onPlaySuccess={reportWatchHistory}
analyticsKind="video"
/>
{adIcons ?? <AdIcons variant="pc" labels={false} />}
<section className="flex h-[140px] items-center justify-between gap-10 rounded-xl bg-[rgba(19,15,22,0.9)] px-6 py-5 outline outline-1 -outline-offset-1 outline-[rgba(199,145,89,0.7)]">
<div className="flex min-w-0 flex-col gap-1.5">
<p className="truncate text-[28px] leading-[34px] font-semibold text-white">{detail.title}</p>
<p className="line-clamp-2 text-xs leading-[18px] text-[#9991AB]">{detail.desc}</p>
<article className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-10 gap-y-5 border-t-2 border-[#FFB020] pt-8">
<div className="flex min-w-0 flex-col gap-4">
<p className="text-sm leading-5 tracking-[0.2em] text-[#FFB020]">视频库</p>
<h1 className="text-4xl leading-[48px] font-bold text-white">{title}</h1>
<div className="max-w-[900px]">
<InfoList rows={infoRows} />
</div>
<div className="flex shrink-0 gap-3">
<ActionPill
icon="/images/icon_like.svg"
label={liked ? "已点赞" : "点赞"}
active={liked}
onClick={liking ? undefined : toggleLike}
/>
<ActionPill
icon="/images/icon_collect.svg"
label={collected ? "已收藏" : "收藏"}
active={collected}
onClick={collecting ? undefined : toggleCollect}
/>
<ActionPill icon="/images/icon_share.svg" label="分享" onClick={share} />
<ActionPill icon="/images/icon_feedback.svg" label="反馈" href="/feedback" />
</div>
</section>
<div className="flex items-start gap-6">
<CommentPanel variant="play" target={videoCommentTarget(detail.id)} />
{relatedAside ?? <VideoRelatedAside items={related} />}
<TagLinks tags={tags} />
</div>
<div className="flex shrink-0 gap-3 pt-7">
<HlAction kind="like" label={`点赞 ${formatCount(likeNum)}`} active={liked} onClick={liking ? undefined : toggleLike} />
<HlAction
kind="collect"
label={collected ? "已收藏" : "收藏"}
active={collected}
onClick={collecting ? undefined : toggleCollect}
/>
<HlAction kind="share" label="分享" onClick={share} />
</div>
{desc ? (
<section className="col-span-2 flex flex-col gap-3">
<SectionTitle>视频介绍</SectionTitle>
<p className="max-w-[1100px] text-[15px] leading-7 whitespace-pre-line text-[#B5B3BE]">{desc}</p>
</section>
) : null}
</article>
{promo ?? <AdBanners variant="pc" />}
</div>
{adIcons ?? <AdIcons variant="pc" labels={false} />}
<VideoRecommend items={related} />
{promo ?? <AdBanners variant="pc" />}
</div>
<H5VideoPlay
detail={detail}
src={src}
related={related}
infoRows={infoRows.slice(0, 3)}
tags={tags}
liked={liked}
likeNum={likeNum}
collected={collected}
@@ -295,8 +283,6 @@ export function VideoPlayPage({
onLike={liking ? () => undefined : toggleLike}
onCollect={collecting ? () => undefined : toggleCollect}
onShare={share}
commentTarget={videoCommentTarget(detail.id)}
onPlaySuccess={reportWatchHistory}
adGrid={adGrid}
bannerAds={bannerAds}
/>
+1
View File
@@ -36,6 +36,7 @@ export function mapVideoListItem(item: VideoListItem): ShortDramaItem | null {
heat: formatHeat(item.watchNum),
episodes: "",
cover: (item.coverImg || "").trim(),
date: item.addedTime || undefined,
};
}
+79 -114
View File
@@ -3,20 +3,13 @@ import Link from "next/link";
import { CardTags } from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
MEDIA_CARD_COVER,
MEDIA_CARD_DESC,
MEDIA_CARD_DESC_SM,
MEDIA_CARD_EPISODES,
MEDIA_CARD_EPISODES_H5,
MEDIA_CARD_FOOTER_H5,
MEDIA_CARD_INFO_H5,
MEDIA_CARD_META,
MEDIA_CARD_ROOT,
MEDIA_CARD_TITLE_H5,
MEDIA_CARD_TITLE_PC,
} from "@/components/common/media-card-hover";
import { RankNumber } from "@/components/common/rank-number";
import { Badge } from "@/components/home/section-heading";
EyeIcon,
formatDay,
HL_COVER,
HL_COVER_IMG,
HL_TITLE_LINK,
HlBadge,
} from "@/components/common/media-meta";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
@@ -24,59 +17,68 @@ export function videoHref(id: string, slug?: string | null) {
return `/video/${(slug?.trim() || id).trim()}`;
}
export function VideoCard({ item, rank }: { item: ShortDramaItem; rank?: number }) {
if (item.kind === "ad") {
return (
<a
href={item.href || "#download"}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`block w-[324px] ${MEDIA_CARD_ROOT}`}
>
<div className="relative h-[192px] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
<img src="/logo.svg" alt="海角社区" className="absolute top-[25px] left-[15px] h-[27px] w-[120px]" />
</div>
<div className="flex min-h-24 flex-col justify-center gap-1.5 px-2.5 py-3">
<h3 className={MEDIA_CARD_TITLE_PC}>{item.title}</h3>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>{item.subtitle}</p>
</div>
</a>
);
}
/** 排行序号:No.01(琥珀色,放在标题前) */
function RankLabel({ rank, className = "" }: { rank?: number; className?: string }) {
if (rank === undefined) return null;
return (
<span className={`mr-1.5 font-bold text-[#FFB020] tabular-nums ${className}`}>No.{String(rank).padStart(2, "0")}</span>
);
}
const detailHref = item.href || videoHref(item.id, item.slug);
/** 视频卡元信息:观看数 + 发布日期 */
function VideoMeta({ item, className }: { item: Extract<ShortDramaItem, { kind: "drama" }>; className: string }) {
const day = formatDay(item.date);
return (
<p className={`flex min-w-0 items-center gap-2 text-[#8E8C99] ${className}`}>
{item.heat ? (
<span className="inline-flex shrink-0 items-center gap-1">
<EyeIcon />
{item.heat}
</span>
) : null}
{item.heat && day ? <span aria-hidden="true" className="h-2.5 w-px bg-white/15" /> : null}
{day ? <time dateTime={day}>{day}</time> : null}
</p>
);
}
function AdVideoCard({ item, compact }: { item: ShortDramaItem; compact?: boolean }) {
return (
<a
href={item.href || (compact ? "#h5-download" : "#download")}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`group flex flex-col ${compact ? "gap-1.5" : "w-[324px] gap-2.5"}`}
>
<span className={`${HL_COVER} aspect-[16/10]`}>
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt={item.title} className={HL_COVER_IMG} />
<span className="absolute top-0 left-0 rounded-br-md bg-white/85 px-1.5 py-0.5 text-[10px] leading-[14px] text-black">
推广
</span>
</span>
<span className={`line-clamp-2 text-white ${compact ? "text-sm leading-5" : "media-card-title-md"}`}>{item.title}</span>
</a>
);
}
export function VideoCard({ item, rank }: { item: ShortDramaItem; rank?: number }) {
if (item.kind === "ad") return <AdVideoCard item={item} />;
const href = item.href || videoHref(item.id, item.slug);
return (
<article data-component="video-card" className={`w-[324px] ${MEDIA_CARD_ROOT}`}>
<Link href={detailHref} className="block">
<div className="relative h-[192px] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
{item.badge ? (
<div className="absolute top-[15.5px] right-4">
<Badge tone={item.badge} />
</div>
) : null}
{item.episodes ? (
<span
className={`absolute right-2 bottom-2 rounded-md bg-black/60 px-3 py-2 text-white ${MEDIA_CARD_EPISODES}`}
>
{item.episodes}
</span>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-1.5 px-2.5 pt-3">
<h3 className={`flex min-h-7 items-center gap-1.5 ${MEDIA_CARD_TITLE_PC}`}>
<RankNumber rank={rank} className="media-card-title" />
<span className="min-w-0 truncate">{item.title}</span>
</h3>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>{item.subtitle}</p>
</div>
<article data-component="hl-video-card" className="group flex w-[324px] flex-col gap-2.5">
<Link href={href} tabIndex={-1} aria-hidden="true" className={`${HL_COVER} aspect-[16/10]`}>
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt={item.title} className={HL_COVER_IMG} />
<HlBadge tone={item.badge} />
</Link>
<div className={`flex min-w-0 items-center justify-between gap-2 px-2.5 pt-1.5 pb-3 ${MEDIA_CARD_META}`}>
<CardTags tags={item.tags} kind="video" className="min-w-0 text-white/60" />
<span className="shrink-0 text-[#F92882]">{item.heat}</span>
</div>
<h3 className="media-card-title-md line-clamp-2 min-h-[2.5em] font-medium text-[#F4F3F7]">
<RankLabel rank={rank} />
<Link href={href} title={item.title} className={HL_TITLE_LINK}>
{item.title}
</Link>
</h3>
<VideoMeta item={item} className="media-card-desc" />
<CardTags tags={item.tags} kind="video" className="media-card-meta min-w-0 text-[#B49A6A]" />
</article>
);
}
@@ -84,65 +86,28 @@ export function VideoCard({ item, rank }: { item: ShortDramaItem; rank?: number
export function H5VideoCard({
item,
rank,
showEpisodes = true,
}: {
item: ShortDramaItem;
rank?: number;
/** @deprecated 新卡片不在封面显示集数,保留参数兼容调用方 */
showEpisodes?: boolean;
}) {
if (item.kind === "ad") {
return (
<a
href={item.href || "#h5-download"}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`block ${MEDIA_CARD_ROOT}`}
>
<div className="relative aspect-[7/4] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
<div className="absolute inset-0 bg-black/40" />
<img src="/logo.svg" alt="海角社区" className="absolute top-[17px] left-[13px] h-4 w-[71px]" />
</div>
<div className={`${MEDIA_CARD_INFO_H5} pb-1.5`}>
<h3 className={MEDIA_CARD_TITLE_H5}>{item.title}</h3>
<p className={`${MEDIA_CARD_DESC_SM} text-[#8C859C]`}>{item.subtitle}</p>
</div>
</a>
);
}
const detailHref = item.href || videoHref(item.id, item.slug);
if (item.kind === "ad") return <AdVideoCard item={item} compact />;
const href = item.href || videoHref(item.id, item.slug);
return (
<article data-component="h5-video-card" className={MEDIA_CARD_ROOT}>
<Link href={detailHref} className="block">
<div className="relative aspect-[7/4] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
{item.badge ? (
<div className="absolute top-2 right-2">
<Badge tone={item.badge} className="h-6" />
</div>
) : null}
{showEpisodes && item.episodes ? (
<span
className={`absolute right-2 bottom-[3px] flex h-6 items-center rounded-lg bg-[#08060D]/88 px-1.5 text-white ${MEDIA_CARD_EPISODES_H5}`}
>
{item.episodes}
</span>
) : null}
</div>
<div className={MEDIA_CARD_INFO_H5}>
<h3 className={`flex items-center gap-1 ${MEDIA_CARD_TITLE_H5}`}>
<RankNumber rank={rank} className="text-sm leading-5" />
<span className="truncate">{item.title}</span>
</h3>
<p className={`${MEDIA_CARD_DESC_SM} text-[#8C859C]`}>{item.subtitle}</p>
</div>
<article data-component="hl-video-card-h5" className="group flex min-w-0 flex-col gap-1.5">
<Link href={href} tabIndex={-1} aria-hidden="true" className={`${HL_COVER} aspect-[16/10]`}>
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt={item.title} className={HL_COVER_IMG} />
<HlBadge tone={item.badge} small />
</Link>
<div className={MEDIA_CARD_FOOTER_H5}>
<CardTags tags={item.tags} kind="video" className="min-w-0 text-[#A89EB5]" />
<span className="shrink-0 text-[#F12B9E]">{item.heat}</span>
</div>
<h3 className="line-clamp-2 min-h-10 text-sm leading-5 font-medium text-[#F4F3F7]">
<RankLabel rank={rank} className="text-xs" />
<Link href={href} title={item.title} className={HL_TITLE_LINK}>
{item.title}
</Link>
</h3>
<VideoMeta item={item} className="text-[11px] leading-4" />
</article>
);
}
+2 -47
View File
@@ -1,52 +1,7 @@
export type BackupLine = { name: string; domain: string; speed: string };
export type PermanentEntry = { label: string; value: string };
export type ContactChannel = { title: string; description: string; action: string; href: string };
export type Step = { title: string; description: string };
export type DeviceTip = { icon: string; title: string; recommend: string; warning: string };
export type Faq = { question: string; answer: string };
export const replyEmail = "haidao@gmail.com";
export const backupLines: BackupLine[] = [
{ name: "备用地址 01", domain: "jsczyr.naquostmx.cc", speed: "极速" },
{ name: "备用地址 02", domain: "jsczyr.naquostmx.cc", speed: "极速" },
{ name: "备用地址 03", domain: "jsczyr.naquostmx.cc", speed: "极速" },
];
export const permanentEntries: PermanentEntry[] = [
{ label: "永久地址", value: "jsczyr.naquostmx.cc" },
{ label: "永久中转页", value: "haidao.ai" },
{ label: "最新中转页(免翻墙)", value: "haidao.com" },
{ label: "永久发布页", value: "https://haidao.pages.dev" },
];
export const contactChannels: ContactChannel[] = [
{
title: "✉ 邮箱自动回复",
description: "发送任意邮件到下方地址,30秒内自动回复当前所有可用入口的最新汇总。",
action: replyEmail,
href: `mailto:${replyEmail}`,
},
{
title: "➤ TG 官方交流群",
description: "获取域名变更通知的首选渠道,实时播报并加入用户互助社区。",
action: "立即订阅最新动态",
href: "https://t.me/haijiao",
},
{
title: "◆ 官方商务合作",
description: "联系官方商务合作,洽谈商务事宜,精诚合作,财富共赢。",
action: "立即联系",
href: "https://t.me/haijiao",
},
{
title: "𝕏 推特社区",
description: "关注官方推特社区,第一时间获取地址变更与官方公告。",
action: "快速访问",
href: "https://x.com/haijiao",
},
];
export const troubleshootSteps: Step[] = [
{ title: "打不开页面", description: "先切换网络,再尝试永久入口或备用入口。" },
{ title: "地址跳转异常", description: "确认地址拼写完整,不要从陌生弹窗进入。" },
@@ -77,7 +32,7 @@ export const deviceTips: DeviceTip[] = [
export const faqs: Faq[] = [
{
question: "回家的路页面有什么作用?",
answer: "集中展示最新地址、永久入口、备用入口和保存方式,方便访问异常时快速返回海角社区。",
answer: "集中展示最新地址、永久入口、备用入口和保存方式,方便访问异常时快速返回黄果黑料。",
},
{ question: "建议保存哪个入口?", answer: "优先收藏当前推荐入口,并额外保存永久入口或备用入口。" },
{
@@ -86,6 +41,6 @@ export const faqs: Faq[] = [
},
{
question: "如何确认入口是否安全?",
answer: "只从本页、站内导航或海角社区提示进入,不要从陌生弹窗、未知短链跳转。",
answer: "只从本页、站内导航或黄果黑料提示进入,不要从陌生弹窗、未知短链跳转。",
},
];
+32 -73
View File
@@ -3,15 +3,13 @@ import type { ReactNode } from "react";
import { H5PageHeader } from "@/components/common/h5-page-header";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { AnnouncementInfo, ConfigInfo } from "@/lib/api/types";
import type { AnnouncementInfo } from "@/lib/api/types";
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL, SITE_DOMAIN } from "@/lib/seo/metadata";
import { CopyButton } from "./copy-button";
import {
backupLines as mockBackupLines,
contactChannels as mockContactChannels,
deviceTips,
faqs,
permanentEntries as mockPermanentEntries,
troubleshootSteps,
} from "./data";
@@ -76,7 +74,7 @@ function Hero() {
<img src="/logo2.png" alt="" className="size-10 rounded-[7px] bg-white lg:size-[62px] lg:rounded-[10.9px]" />
<div className="flex flex-col">
<p className="text-base leading-[22px] font-semibold text-white lg:text-[24.8px] lg:leading-[34.1px]">
海角社区<span className="ml-0.5 text-[#F12B9E] lg:ml-[3px]">APP</span>
黄果黑料<span className="ml-0.5 text-[#F12B9E] lg:ml-[3px]">APP</span>
</p>
<p className="text-xs leading-[18px] text-[#DCDCDC] lg:text-[18.6px] lg:leading-[27.9px] lg:text-white">
全网最全成人视频平台
@@ -84,7 +82,7 @@ function Hero() {
</div>
</div>
<h1 className="relative text-base leading-[22px] font-medium text-white lg:text-2xl lg:leading-8">
海角社区 · 官方最新地址发布页
黄果黑料 · 官方最新地址发布页
</h1>
<p className="relative text-xs leading-[18px] text-[#DCDCDC] lg:text-xl lg:leading-7 lg:font-medium">
实时同步最新可用线路与备用入口,帮助你在不同网络环境下快速返回。
@@ -105,16 +103,9 @@ function Hero() {
);
}
function BackupLines({ announcement }: { announcement?: AnnouncementInfo | null }) {
const apiLines = [
{ name: "新国内地址", value: announcement?.newUrlAddr },
{ name: "备用地址 01", value: announcement?.newUrlAddr1 },
{ name: "备用地址 02", value: announcement?.newUrlAddr2 },
].filter((line) => line.value?.trim());
const lines = apiLines.length
? apiLines.map((line) => ({ name: line.name, value: line.value!.trim(), speed: "极速" as const }))
: mockBackupLines.map((line) => ({ name: line.name, value: line.domain, speed: line.speed }));
/** 地址固定本站域名,不取接口 newUrlAddr* */
function BackupLines() {
const lines = [{ name: "最新地址", value: SITE_DOMAIN, speed: "极速" }];
return (
<div className="grid gap-3 lg:grid-cols-3 lg:gap-[18px]">
@@ -150,16 +141,16 @@ function BackupLines({ announcement }: { announcement?: AnnouncementInfo | null
}
function PermanentEntries({ announcement }: { announcement?: AnnouncementInfo | null }) {
const apiEntries = [
{ label: "永久海外地址", value: announcement?.foreverUrlAddr },
{ label: "应用导航", value: announcement?.translationAddr },
{ label: "回家的路 PDF", value: announcement?.pdfAddr },
{ label: "吃瓜 APP", value: announcement?.appAddr },
].filter((entry) => entry.value?.trim());
const entries = apiEntries.length
? apiEntries.map((entry) => ({ label: entry.label, value: entry.value!.trim() }))
: mockPermanentEntries;
// 永久地址固定本站域名;PDF / APP 下载仍取接口
const entries = [
{ label: "永久地址", value: SITE_DOMAIN },
...[
{ label: "回家的路 PDF", value: announcement?.pdfAddr },
{ label: "吃瓜 APP", value: announcement?.appAddr },
]
.filter((entry) => entry.value?.trim())
.map((entry) => ({ label: entry.label, value: entry.value!.trim() })),
];
return (
<div className="flex flex-col gap-3 lg:gap-2.5">
@@ -185,46 +176,20 @@ function PermanentEntries({ announcement }: { announcement?: AnnouncementInfo |
);
}
function ContactChannels({ config }: { config?: ConfigInfo | null }) {
const apiChannels = [
config?.gmailAddr
? {
title: "✉ 邮箱自动回复",
description: "发送任意邮件到下方地址,30秒内自动回复当前所有可用入口的最新汇总。",
action: config.gmailAddr,
href: `mailto:${config.gmailAddr}`,
}
: null,
config?.tgGroup || config?.telegramAdd
? {
title: "➤ TG 官方交流群",
description: "获取域名变更通知的首选渠道,实时播报并加入用户互助社区。",
action: "立即订阅最新动态",
href: (config.tgGroup || config.telegramAdd)!,
}
: null,
config?.telegram
? {
title: "◆ 官方商务合作",
description: "联系官方商务合作,洽谈商务事宜,精诚合作,财富共赢。",
action: "立即联系",
href: config.telegram,
}
: null,
config?.twitterAddr
? {
title: "𝕏 推特社区",
description: "关注官方推特社区,第一时间获取地址变更与官方公告。",
action: "快速访问",
href: config.twitterAddr,
}
: null,
].filter(Boolean) as { title: string; description: string; action: string; href: string }[];
/** 联系方式只保留官方 TG,不取接口 gmailAddr / telegram / twitterAddr */
const channels = [
{
title: "➤ TG 官方联系",
description: "获取域名变更通知的首选渠道,地址更新、商务合作都可直接联系。",
action: OFFICIAL_TG_HANDLE,
href: OFFICIAL_TG_URL,
},
];
const channels = apiChannels.length ? apiChannels : mockContactChannels;
function ContactChannels() {
return (
<div className="grid gap-3 lg:grid-cols-2 lg:gap-4 xl:grid-cols-4">
<div className="grid gap-3 lg:gap-4">
{channels.map((channel) => (
<div
key={channel.title}
@@ -234,7 +199,7 @@ function ContactChannels({ config }: { config?: ConfigInfo | null }) {
<p className="self-stretch text-sm leading-5 text-[#DCDCDC] lg:flex-1">{channel.description}</p>
<a
href={channel.href}
target={channel.href.startsWith("mailto:") ? undefined : "_blank"}
target="_blank"
rel="noopener noreferrer"
className="flex self-stretch rounded-full bg-[#3D3D3D] px-4 py-3 text-sm leading-5 text-white outline outline-1 -outline-offset-1 outline-black/5 transition-colors hover:bg-[#4A4A4A]"
>
@@ -293,13 +258,7 @@ function FaqList() {
);
}
export function WayHomePage({
announcement,
config,
}: {
announcement?: AnnouncementInfo | null;
config?: ConfigInfo | null;
}) {
export function WayHomePage({ announcement }: { announcement?: AnnouncementInfo | null }) {
return (
<main data-component="way-home-page" className="relative isolate flex-1 overflow-x-clip bg-black/70">
<div className="pointer-events-none absolute inset-0 -z-10 hidden overflow-hidden lg:block" aria-hidden="true">
@@ -321,7 +280,7 @@ export function WayHomePage({
tagClassName="text-base leading-[22px] text-[#F12B9E] lg:text-sm lg:leading-5"
description="以下线路均经过可用性检查,优先选择高速线路。"
>
<BackupLines announcement={announcement} />
<BackupLines />
</Section>
<Section
@@ -341,7 +300,7 @@ export function WayHomePage({
tagClassName="text-[#FF5412] lg:text-[#F12B9E]"
description="通过以下方式获取最新地址或接收线路变更提醒。"
>
<ContactChannels config={config} />
<ContactChannels />
</Section>
<Section
-35
View File
@@ -1,35 +0,0 @@
/** 渠道首次归因:dc → ch → pc,已有渠道不覆盖(对齐 jha-1043 trackerInit) */
function queryParam(name: string) {
try {
const match = new RegExp(`[?&]${name}=([^&#]*)`).exec(window.location.search || "");
return match ? decodeURIComponent(match[1].replace(/\+/g, " ")) : "";
} catch {
return "";
}
}
export function resolveChannel(): string {
try {
const stored = String(window.localStorage.getItem("channel") || "").trim();
if (stored) {
window.__CHANNEL_CODE__ = stored;
return stored;
}
const channel = String(queryParam("dc") || queryParam("ch") || queryParam("pc") || "").trim();
if (channel) {
window.localStorage.setItem("channel", channel);
window.__CHANNEL_CODE__ = channel;
return channel;
}
} catch {
// localStorage 不可用时仍用当前 URL 渠道完成本次上报
const channel = String(queryParam("dc") || queryParam("ch") || queryParam("pc") || "").trim();
if (channel) {
window.__CHANNEL_CODE__ = channel;
return channel;
}
}
window.__CHANNEL_CODE__ = window.__CHANNEL_CODE__ || "organic";
return window.__CHANNEL_CODE__;
}
+2 -17
View File
@@ -1,5 +1,5 @@
import { apiRequest, apiUpload } from "./client";
import type { GuestLoginReq, ImageUploadData, LoginResp, UpdateProfileReq, UserInfoResp } from "./types";
import { apiRequest } from "./client";
import type { GuestLoginReq, LoginResp, UserInfoResp } from "./types";
export function guestLogin(payload: GuestLoginReq, token?: string | null, signal?: AbortSignal) {
return apiRequest<LoginResp>("/api/app/login/guest", {
@@ -40,18 +40,3 @@ export function accountRegister(
token: options?.token,
});
}
export function updateProfile(payload: UpdateProfileReq, token: string) {
return apiRequest<string>("/api/app/auth/profile", {
method: "POST",
body: payload,
token,
});
}
export function uploadImage(file: File, token: string) {
const formData = new FormData();
formData.append("watermark", "0");
formData.append("file", file, file.name || "image.jpg");
return apiUpload<ImageUploadData>("/api/app/upload/image", formData, token);
}

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