整体UI调整
This commit is contained in:
@@ -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: [
|
||||
["首页", "/"],
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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: [
|
||||
["首页", "/"],
|
||||
|
||||
@@ -9,7 +9,7 @@ import { moreListHref } from "@/components/more/route";
|
||||
|
||||
/** 无 id 的空壳页;正式内容在 /more/{id},本页不索引 */
|
||||
export const metadata: Metadata = {
|
||||
title: "更多内容 - 海角社区",
|
||||
title: "更多内容 - 黄果黑料",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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: [
|
||||
["首页", "/"],
|
||||
|
||||
@@ -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
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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" />}
|
||||
/>
|
||||
|
||||
@@ -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: [
|
||||
["首页", "/"],
|
||||
|
||||
@@ -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: [
|
||||
["首页", "/"],
|
||||
|
||||
@@ -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: [
|
||||
["首页", "/"],
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
["首页", "/"],
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 || ""),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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: "问题反馈" },
|
||||
];
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ const infoLinkHrefs: Record<string, string> = {
|
||||
用户条款: toRichTextPath("用户条款"),
|
||||
隐私政策: toRichTextPath("隐私政策"),
|
||||
常见问题: toRichTextPath("常见问题"),
|
||||
帮助中心: "/help",
|
||||
问题反馈: "/feedback",
|
||||
意见反馈: "/feedback",
|
||||
联系我们: toRichTextPath("联系我们"),
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`} />;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const clauses: Clause[] = [
|
||||
},
|
||||
{
|
||||
title: "五、您的权利",
|
||||
text: "您可以依法访问、更正、删除个人信息,管理授权或注销账号。如需帮助,可通过 privacy@qimengshorts.com 联系我们。",
|
||||
text: "您可以依法访问、更正、删除个人信息,管理授权或注销账号。如需帮助,可通过官方TG @jbd125 联系我们。",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -37,6 +37,8 @@ export type ShortDramaItem =
|
||||
episodes: string;
|
||||
badge?: BadgeTone;
|
||||
cover: string;
|
||||
/** 发布时间(接口原值,视频卡展示日期用) */
|
||||
date?: string;
|
||||
/** 覆盖默认详情/播放链接(如历史继续看到某集) */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -34,6 +34,7 @@ function mapHotVideoItem(item: VideoData): ShortDramaItem {
|
||||
heat: formatHeat(item.watchNum),
|
||||
episodes: "",
|
||||
cover: (item.coverImg || "").trim(),
|
||||
date: item.addedTime || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -36,6 +36,7 @@ export function mapVideoListItem(item: VideoListItem): ShortDramaItem | null {
|
||||
heat: formatHeat(item.watchNum),
|
||||
episodes: "",
|
||||
cover: (item.coverImg || "").trim(),
|
||||
date: item.addedTime || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: "只从本页、站内导航或黄果黑料提示进入,不要从陌生弹窗、未知短链跳转。",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* - 设为空字符串:走同域 `/api/*`(配合 next.config rewrites 代理,避免 CORS)
|
||||
* - 设为完整 URL:直连该地址
|
||||
*/
|
||||
const DEFAULT_UPSTREAM = "https://mdcom-wz.80sygtm.com";
|
||||
const DEFAULT_UPSTREAM = "https://webhghlapi.mfcg.me";
|
||||
const raw = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
export const API_BASE_URL = (raw === undefined ? DEFAULT_UPSTREAM : raw).replace(/\/$/, "");
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { apiRequest } from "./client";
|
||||
import type { QuestionnaireSubmitReq } from "./types";
|
||||
|
||||
/** 客户端:提交问题反馈问卷 POST /api/app/questionnaire/submit */
|
||||
export function submitQuestionnaire(payload: QuestionnaireSubmitReq, token: string) {
|
||||
return apiRequest<unknown>("/api/app/questionnaire/submit", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
token,
|
||||
});
|
||||
}
|
||||
@@ -4,12 +4,6 @@ import type {
|
||||
FavoritePlayletResp,
|
||||
LikePlayletReq,
|
||||
LikePlayletResp,
|
||||
PlayletHistoryClearResp,
|
||||
PlayletHistoryListResp,
|
||||
PlayletHistoryReportReq,
|
||||
PlayletHistoryReportResp,
|
||||
QueryFavoritePlayletListResp,
|
||||
QueryLikePlayletListResp,
|
||||
QueryPlayletInfoResp,
|
||||
} from "./types";
|
||||
|
||||
@@ -48,65 +42,3 @@ export function favoritePlaylet(payload: FavoritePlayletReq, token: string) {
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户端:短剧收藏列表 GET /api/app/playlet/favorite/list */
|
||||
export function getPlayletFavoriteList(
|
||||
query: { pageNum?: number; pageSize?: number },
|
||||
token: string,
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
pageNum: String(query.pageNum ?? 1),
|
||||
pageSize: String(query.pageSize ?? 24),
|
||||
});
|
||||
return apiRequest<QueryFavoritePlayletListResp>(`/api/app/playlet/favorite/list?${params}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户端:短剧点赞列表 GET /api/app/playlet/like/list */
|
||||
export function getPlayletLikeList(
|
||||
query: { pageNum?: number; pageSize?: number },
|
||||
token: string,
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
pageNum: String(query.pageNum ?? 1),
|
||||
pageSize: String(query.pageSize ?? 24),
|
||||
});
|
||||
return apiRequest<QueryLikePlayletListResp>(`/api/app/playlet/like/list?${params}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户端:上报短剧观看历史 POST /api/app/playlet/history */
|
||||
export function reportPlayletHistory(payload: PlayletHistoryReportReq, token: string) {
|
||||
return apiRequest<PlayletHistoryReportResp>("/api/app/playlet/history", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户端:短剧观看历史列表 GET /api/app/playlet/history/list */
|
||||
export function getPlayletHistoryList(
|
||||
query: { pageNum?: number; pageSize?: number },
|
||||
token: string,
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
pageNum: String(query.pageNum ?? 1),
|
||||
pageSize: String(query.pageSize ?? 24),
|
||||
});
|
||||
return apiRequest<PlayletHistoryListResp>(`/api/app/playlet/history/list?${params}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户端:清空短剧观看历史 DELETE /api/app/playlet/history */
|
||||
export function clearPlayletHistory(token: string) {
|
||||
return apiRequest<PlayletHistoryClearResp>("/api/app/playlet/history", {
|
||||
method: "DELETE",
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,29 +54,6 @@ export type ImageUploadData = {
|
||||
url?: string;
|
||||
};
|
||||
|
||||
/** POST /api/app/questionnaire/submit 附件 */
|
||||
export type QuestionnaireAttachment = {
|
||||
/** img / video */
|
||||
fileType?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
/** POST /api/app/questionnaire/submit */
|
||||
export type QuestionnaireSubmitReq = {
|
||||
description: string;
|
||||
deviceType: string;
|
||||
frequency: string;
|
||||
networkType: string;
|
||||
operator: string;
|
||||
province: string;
|
||||
questionType: string;
|
||||
attachments?: QuestionnaireAttachment[];
|
||||
city?: string;
|
||||
contact?: string;
|
||||
deviceModel?: string;
|
||||
userId?: number;
|
||||
};
|
||||
|
||||
/** GET /api/app/applist/list */
|
||||
export type AppInfoData = {
|
||||
id: number;
|
||||
@@ -640,68 +617,6 @@ export type LikeVideoResp = {
|
||||
likeNum?: number;
|
||||
};
|
||||
|
||||
/** 评论对象类型:0=视频 4=短剧 11=短剧剧集 */
|
||||
export type CommentObjectType = 0 | 4 | 11;
|
||||
|
||||
export const COMMENT_OBJECT_TYPE = {
|
||||
video: 0,
|
||||
playlet: 4,
|
||||
playletChapter: 11,
|
||||
} as const satisfies Record<string, CommentObjectType>;
|
||||
|
||||
/** GET /api/app/comment/list — sortOrder: 1=热门 2=最新 */
|
||||
export type CommentInfo = {
|
||||
id?: number;
|
||||
objectId?: number;
|
||||
parentsId?: number;
|
||||
level?: number;
|
||||
userName?: string;
|
||||
avatar?: string;
|
||||
text?: string;
|
||||
countLike?: number;
|
||||
isLike?: boolean;
|
||||
commentCount?: number;
|
||||
createdAt?: string;
|
||||
replyUserName?: string;
|
||||
subComment?: CommentInfo[];
|
||||
};
|
||||
|
||||
export type CommentListResp = {
|
||||
list?: CommentInfo[];
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
totalCount?: number;
|
||||
};
|
||||
|
||||
export type CommentListQuery = {
|
||||
objectId: number;
|
||||
objectType: CommentObjectType;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
sortOrder?: 1 | 2;
|
||||
};
|
||||
|
||||
/** POST /api/app/comment/send */
|
||||
export type CommentSendReq = {
|
||||
objectId: number;
|
||||
objectType: CommentObjectType;
|
||||
text: string;
|
||||
/** 上级评论 ID,顶级评论传 0 */
|
||||
parentsId?: number;
|
||||
};
|
||||
|
||||
/** POST /api/app/comment/like/set */
|
||||
export type CommentLikeSetReq = {
|
||||
commentId: number;
|
||||
isLike: boolean;
|
||||
};
|
||||
|
||||
export type CommentLikeSetResp = {
|
||||
commentId?: number;
|
||||
countLike?: number;
|
||||
isLike?: boolean;
|
||||
};
|
||||
|
||||
/** pubdef.TranslationType:1=URL模式 2=二级导航 3=底部导航 */
|
||||
export type TranslationType = 1 | 2 | 3;
|
||||
|
||||
@@ -803,8 +718,6 @@ export type ConfigInfo = {
|
||||
dhNewUrlAddr3?: string;
|
||||
dhSiteName?: string;
|
||||
dhTitle?: string;
|
||||
/** Web SDK 加密配置串(埋点必传) */
|
||||
reportSdkConfig?: string;
|
||||
channelCode?: string;
|
||||
enableH265?: boolean;
|
||||
m3u8DayRateLimitMax?: number;
|
||||
|
||||
@@ -4,13 +4,7 @@ import type {
|
||||
FavoriteVideoResp,
|
||||
LikeVideoReq,
|
||||
LikeVideoResp,
|
||||
QueryFavoriteVideoListResp,
|
||||
QueryLikeVideoListResp,
|
||||
QueryVideoInfoResp,
|
||||
VideoHistoryClearResp,
|
||||
VideoHistoryListResp,
|
||||
VideoHistoryReportReq,
|
||||
VideoHistoryReportResp,
|
||||
} from "./types";
|
||||
|
||||
/** 客户端:视频详情 GET /api/app/video/info(带登录态,同步 isLike / isFavorite) */
|
||||
@@ -40,35 +34,6 @@ export function favoriteVideo(payload: FavoriteVideoReq, token: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户端:视频收藏列表 GET /api/app/video/favorite/list */
|
||||
export function getVideoFavoriteList(
|
||||
query: { pageNum?: number; pageSize?: number },
|
||||
token: string,
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
pageNum: String(query.pageNum ?? 1),
|
||||
pageSize: String(query.pageSize ?? 24),
|
||||
});
|
||||
return apiRequest<QueryFavoriteVideoListResp>(`/api/app/video/favorite/list?${params}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户端:视频点赞列表 GET /api/app/video/like/list */
|
||||
export function getVideoLikeList(
|
||||
query: { pageNum?: number; pageSize?: number },
|
||||
token: string,
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
pageNum: String(query.pageNum ?? 1),
|
||||
pageSize: String(query.pageSize ?? 24),
|
||||
});
|
||||
return apiRequest<QueryLikeVideoListResp>(`/api/app/video/like/list?${params}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户端:点赞/取消点赞视频 POST /api/app/video/like */
|
||||
export function likeVideo(payload: LikeVideoReq, token: string) {
|
||||
@@ -78,35 +43,3 @@ export function likeVideo(payload: LikeVideoReq, token: string) {
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户端:上报视频观看历史 POST /api/app/video/history */
|
||||
export function reportVideoHistory(payload: VideoHistoryReportReq, token: string) {
|
||||
return apiRequest<VideoHistoryReportResp>("/api/app/video/history", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户端:视频观看历史列表 GET /api/app/video/history/list */
|
||||
export function getVideoHistoryList(
|
||||
query: { pageNum?: number; pageSize?: number },
|
||||
token: string,
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
pageNum: String(query.pageNum ?? 1),
|
||||
pageSize: String(query.pageSize ?? 24),
|
||||
});
|
||||
return apiRequest<VideoHistoryListResp>(`/api/app/video/history/list?${params}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户端:清空视频观看历史 DELETE /api/app/video/history */
|
||||
export function clearVideoHistory(token: string) {
|
||||
return apiRequest<VideoHistoryClearResp>("/api/app/video/history", {
|
||||
method: "DELETE",
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
+2
-44
@@ -1,7 +1,7 @@
|
||||
import { accountLogin, accountRegister, getAuthInfo, guestLogin, updateProfile, uploadImage } from "@/lib/api/auth";
|
||||
import { accountLogin, accountRegister, getAuthInfo, guestLogin } from "@/lib/api/auth";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { isUnauthorizedStatus } from "@/lib/api/unauthorized";
|
||||
import type { LoginResp, UpdateProfileReq, UserInfoResp } from "@/lib/api/types";
|
||||
import type { LoginResp, UserInfoResp } from "@/lib/api/types";
|
||||
import { getDeviceId } from "@/lib/device-id";
|
||||
|
||||
const TOKEN_KEY = "hjsq-auth-token";
|
||||
@@ -258,45 +258,3 @@ export async function logoutAndGuest() {
|
||||
clearSession();
|
||||
return loginAsGuest();
|
||||
}
|
||||
|
||||
export type UpdateProfileInput = {
|
||||
nickName?: string;
|
||||
avatarFile?: File | null;
|
||||
avatarUrl?: string;
|
||||
oldPassword?: string;
|
||||
newPassword?: string;
|
||||
};
|
||||
|
||||
export async function updateUserProfile(input: UpdateProfileInput) {
|
||||
const token = getAuthToken();
|
||||
const user = getAuthUser();
|
||||
if (!token || !user) throw new ApiError("请先登录", 401);
|
||||
if (!user.accountNo) throw new ApiError("请先注册账号后再修改资料", 400);
|
||||
|
||||
let avatar = (input.avatarUrl || "").trim() || undefined;
|
||||
if (input.avatarFile) {
|
||||
const uploaded = await uploadImage(input.avatarFile, token);
|
||||
avatar =
|
||||
(uploaded?.sourceNameFs || uploaded?.url || uploaded?.id || "").trim() || avatar;
|
||||
if (!avatar) throw new ApiError("头像上传成功但未返回地址", 500);
|
||||
}
|
||||
|
||||
const payload: UpdateProfileReq = { accountNo: user.accountNo };
|
||||
if (input.nickName !== undefined) payload.nikeName = input.nickName;
|
||||
if (avatar) payload.avatar = avatar;
|
||||
if (input.oldPassword || input.newPassword) {
|
||||
if (!input.oldPassword || !input.newPassword) {
|
||||
throw new ApiError("请同时填写旧密码和新密码", 400);
|
||||
}
|
||||
payload.oldPassword = input.oldPassword;
|
||||
payload.newPassword = input.newPassword;
|
||||
}
|
||||
|
||||
const hasChange = Boolean(payload.nikeName || payload.avatar || payload.newPassword);
|
||||
if (!hasChange) {
|
||||
throw new ApiError("请至少修改一项资料", 400);
|
||||
}
|
||||
|
||||
await updateProfile(payload, token);
|
||||
return refreshProfile();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export {
|
||||
SITE_NAME,
|
||||
SITE_DOMAIN,
|
||||
SITE_URL,
|
||||
OFFICIAL_TG_HANDLE,
|
||||
OFFICIAL_TG_URL,
|
||||
OG_DEFAULT_IMAGE,
|
||||
buildPageMetadata,
|
||||
normalizeSEO,
|
||||
@@ -7,6 +11,7 @@ export {
|
||||
resolveShareImage,
|
||||
siteSEOFromConfig,
|
||||
withSiteSuffix,
|
||||
clipText,
|
||||
type BuildMetadataOptions,
|
||||
type NormalizedSEO,
|
||||
} from "./metadata";
|
||||
|
||||
+37
-36
@@ -63,24 +63,24 @@ function organizationAndWebsite(siteName: string): JsonLdNode[] {
|
||||
return [
|
||||
{
|
||||
"@type": "Organization",
|
||||
"@id": `${site}/#organization`,
|
||||
"@id": `${site}/#hl-org`,
|
||||
name: siteName,
|
||||
url: absoluteUrl("/"),
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
"@id": `${site}/#logo`,
|
||||
"@id": `${site}/#hl-logo`,
|
||||
url: logo,
|
||||
contentUrl: logo,
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${site}/#website`,
|
||||
"@id": `${site}/#hl-site`,
|
||||
name: siteName,
|
||||
url: absoluteUrl("/"),
|
||||
description: `${siteName}汇集热门短剧、视频与精选推荐,支持高清在线观看。`,
|
||||
inLanguage: "zh-CN",
|
||||
publisher: ref(`${site}/#organization`),
|
||||
publisher: ref(`${site}/#hl-org`),
|
||||
// 站内搜索(与 /search?q= → /search/{type}/{q} 兼容)
|
||||
potentialAction: {
|
||||
"@type": "SearchAction",
|
||||
@@ -98,13 +98,13 @@ function webpage(url: string, name: string, description: string, type = "WebPage
|
||||
const site = getSiteOrigin();
|
||||
return {
|
||||
"@type": type,
|
||||
"@id": `${url}#webpage`,
|
||||
"@id": `${url}#hl-page`,
|
||||
name,
|
||||
description,
|
||||
url,
|
||||
inLanguage: "zh-CN",
|
||||
isPartOf: ref(`${site}/#website`),
|
||||
publisher: ref(`${site}/#organization`),
|
||||
isPartOf: ref(`${site}/#hl-site`),
|
||||
publisher: ref(`${site}/#hl-org`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ function breadcrumb(items: Array<[string, string]>): JsonLdNode {
|
||||
const last = items.at(-1)?.[1] || "";
|
||||
return {
|
||||
"@type": "BreadcrumbList",
|
||||
"@id": `${last}#breadcrumb`,
|
||||
"@id": `${last}#hl-trail`,
|
||||
itemListElement: items
|
||||
.filter(([name, item]) => name && item)
|
||||
.map(([name, item], index) => ({
|
||||
@@ -148,16 +148,16 @@ export function buildWebPageJSONLD(input: WebPageJsonLdInput) {
|
||||
const nodes: JsonLdNode[] = [page];
|
||||
|
||||
if (input.breadcrumbs?.length) {
|
||||
page.breadcrumb = ref(`${url}#breadcrumb`);
|
||||
page.breadcrumb = ref(`${url}#hl-trail`);
|
||||
nodes.push(breadcrumb(input.breadcrumbs.map(([name, path]) => [name, absoluteUrl(path)])));
|
||||
}
|
||||
|
||||
const listUrls = unique((input.itemListUrls ?? []).map((item) => absoluteUrl(item)).filter(Boolean));
|
||||
if (listUrls.length >= 2) {
|
||||
page.mainEntity = ref(`${url}#itemlist`);
|
||||
page.mainEntity = ref(`${url}#hl-list`);
|
||||
nodes.push({
|
||||
"@type": "ItemList",
|
||||
"@id": `${url}#itemlist`,
|
||||
"@id": `${url}#hl-list`,
|
||||
numberOfItems: listUrls.length,
|
||||
itemListElement: listUrls.map((item, index) => ({
|
||||
"@type": "ListItem",
|
||||
@@ -188,19 +188,20 @@ export type PlayletDetailJsonLdInput = {
|
||||
export function buildPlayletDetailJSONLD(input: PlayletDetailJsonLdInput) {
|
||||
const url = absoluteUrl(input.path);
|
||||
const seo = normalizeSEO(input.seoInfo);
|
||||
const episodes = Number(input.totalEpisode) || 0;
|
||||
const name = first(input.title, seo.title, "短剧详情");
|
||||
const description = first(seo.description, input.description, `在线观看《${name}》,高清短剧全集免费看。`);
|
||||
const description = first(seo.description, input.description, `《${name}》${episodes > 0 ? `全${episodes}集` : ""}短剧,剧情介绍与分集目录收录于黄果黑料。`);
|
||||
const cover = input.cover?.trim() || "";
|
||||
const site = getSiteOrigin();
|
||||
|
||||
const series: JsonLdNode = {
|
||||
"@type": "TVSeries",
|
||||
"@id": `${url}#tvseries`,
|
||||
"@id": `${url}#hl-series`,
|
||||
name,
|
||||
description,
|
||||
url,
|
||||
inLanguage: "zh-CN",
|
||||
publisher: ref(`${site}/#organization`),
|
||||
publisher: ref(`${site}/#hl-org`),
|
||||
potentialAction: {
|
||||
"@type": "WatchAction",
|
||||
target: { "@type": "EntryPoint", urlTemplate: url },
|
||||
@@ -210,7 +211,6 @@ export function buildPlayletDetailJSONLD(input: PlayletDetailJsonLdInput) {
|
||||
series.image = [cover];
|
||||
series.thumbnailUrl = [cover];
|
||||
}
|
||||
const episodes = Number(input.totalEpisode) || 0;
|
||||
if (episodes > 0) series.numberOfEpisodes = episodes;
|
||||
const published = isoDate(input.onlineAt);
|
||||
if (published) series.datePublished = published;
|
||||
@@ -220,17 +220,17 @@ export function buildPlayletDetailJSONLD(input: PlayletDetailJsonLdInput) {
|
||||
|
||||
const page = webpage(url, name, description);
|
||||
page.mainEntity = ref(series["@id"] as string);
|
||||
page.breadcrumb = ref(`${url}#breadcrumb`);
|
||||
page.breadcrumb = ref(`${url}#hl-trail`);
|
||||
|
||||
return wrapGraph(
|
||||
[
|
||||
series,
|
||||
page,
|
||||
breadcrumb([
|
||||
["首页", absoluteUrl("/")],
|
||||
["短剧", absoluteUrl("/short-drama/hot")],
|
||||
["短剧库", absoluteUrl("/short-drama/hot")],
|
||||
[name, url],
|
||||
]),
|
||||
series,
|
||||
],
|
||||
input.siteName,
|
||||
);
|
||||
@@ -249,31 +249,32 @@ export function buildPlayletPlayJSONLD(input: PlayletPlayJsonLdInput) {
|
||||
const url = absoluteUrl(input.playPath);
|
||||
const seo = normalizeSEO(input.seoInfo);
|
||||
const title = first(input.title, seo.title, "短剧");
|
||||
const description = first(seo.description, input.description, `正在播放《${title}》第${input.episode}集`);
|
||||
const description = first(seo.description, input.description, `《${title}》第${input.episode}集分集内容,可在黄果黑料按集收看全剧。`);
|
||||
const cover = input.cover?.trim() || "";
|
||||
const published = isoDateTime(input.onlineAt);
|
||||
const videoName = `${title} - 第${input.episode}集`;
|
||||
const videoName = `${title} 第${input.episode}集`;
|
||||
const site = getSiteOrigin();
|
||||
|
||||
const page = webpage(url, videoName, description);
|
||||
page.breadcrumb = ref(`${url}#breadcrumb`);
|
||||
page.breadcrumb = ref(`${url}#hl-trail`);
|
||||
const nodes: JsonLdNode[] = [
|
||||
page,
|
||||
breadcrumb([
|
||||
["首页", absoluteUrl("/")],
|
||||
["短剧库", absoluteUrl("/short-drama/hot")],
|
||||
[title, detailPath],
|
||||
[videoName, url],
|
||||
[`第${input.episode}集`, url],
|
||||
]),
|
||||
];
|
||||
|
||||
const series: JsonLdNode = {
|
||||
"@type": "TVSeries",
|
||||
"@id": `${detailPath}#tvseries`,
|
||||
"@id": `${detailPath}#hl-series`,
|
||||
name: title,
|
||||
description: first(seo.description, input.description, description),
|
||||
url: detailPath,
|
||||
inLanguage: "zh-CN",
|
||||
publisher: ref(`${site}/#organization`),
|
||||
publisher: ref(`${site}/#hl-org`),
|
||||
};
|
||||
if (cover) {
|
||||
series.image = [cover];
|
||||
@@ -284,15 +285,15 @@ export function buildPlayletPlayJSONLD(input: PlayletPlayJsonLdInput) {
|
||||
if (cover && published) {
|
||||
const video: JsonLdNode = {
|
||||
"@type": "VideoObject",
|
||||
"@id": `${url}#video`,
|
||||
"@id": `${url}#hl-clip`,
|
||||
name: videoName,
|
||||
description,
|
||||
url,
|
||||
mainEntityOfPage: ref(`${url}#webpage`),
|
||||
mainEntityOfPage: ref(`${url}#hl-page`),
|
||||
inLanguage: "zh-CN",
|
||||
isFamilyFriendly: false,
|
||||
isPartOf: ref(`${site}/#website`),
|
||||
publisher: ref(`${site}/#organization`),
|
||||
isPartOf: ref(`${site}/#hl-site`),
|
||||
publisher: ref(`${site}/#hl-org`),
|
||||
thumbnailUrl: [cover],
|
||||
image: [cover],
|
||||
uploadDate: published,
|
||||
@@ -307,7 +308,7 @@ export function buildPlayletPlayJSONLD(input: PlayletPlayJsonLdInput) {
|
||||
nodes.push(video);
|
||||
nodes.push({
|
||||
"@type": "TVEpisode",
|
||||
"@id": `${url}#episode`,
|
||||
"@id": `${url}#hl-ep`,
|
||||
name: videoName,
|
||||
description,
|
||||
episodeNumber: input.episode,
|
||||
@@ -342,18 +343,18 @@ export function buildVideoPlayJSONLD(input: VideoPlayJsonLdInput) {
|
||||
const url = absoluteUrl(input.path);
|
||||
const seo = normalizeSEO(input.seoInfo);
|
||||
const name = first(seo.title, input.title, "在线播放");
|
||||
const description = first(seo.description, input.description, `在线观看《${name}》。`);
|
||||
const description = first(seo.description, input.description, `视频《${name}》的内容介绍与相关推荐,收录于黄果黑料视频库。`);
|
||||
const cover = input.cover?.trim() || "";
|
||||
const published = isoDateTime(input.addedTime);
|
||||
const site = getSiteOrigin();
|
||||
|
||||
const page = webpage(url, name, description);
|
||||
page.breadcrumb = ref(`${url}#breadcrumb`);
|
||||
page.breadcrumb = ref(`${url}#hl-trail`);
|
||||
const nodes: JsonLdNode[] = [
|
||||
page,
|
||||
breadcrumb([
|
||||
["首页", absoluteUrl("/")],
|
||||
["视频", absoluteUrl("/video/hot")],
|
||||
["视频库", absoluteUrl("/video/hot")],
|
||||
[name, url],
|
||||
]),
|
||||
];
|
||||
@@ -361,15 +362,15 @@ export function buildVideoPlayJSONLD(input: VideoPlayJsonLdInput) {
|
||||
if (cover && published) {
|
||||
const video: JsonLdNode = {
|
||||
"@type": "VideoObject",
|
||||
"@id": `${url}#video`,
|
||||
"@id": `${url}#hl-clip`,
|
||||
name,
|
||||
description,
|
||||
url,
|
||||
mainEntityOfPage: ref(`${url}#webpage`),
|
||||
mainEntityOfPage: ref(`${url}#hl-page`),
|
||||
inLanguage: "zh-CN",
|
||||
isFamilyFriendly: false,
|
||||
isPartOf: ref(`${site}/#website`),
|
||||
publisher: ref(`${site}/#organization`),
|
||||
isPartOf: ref(`${site}/#hl-site`),
|
||||
publisher: ref(`${site}/#hl-org`),
|
||||
thumbnailUrl: [cover],
|
||||
image: [cover],
|
||||
uploadDate: published,
|
||||
|
||||
+29
-7
@@ -2,13 +2,21 @@ import type { Metadata } from "next";
|
||||
|
||||
import type { SeoValue } from "@/lib/api/types";
|
||||
|
||||
/** 站点默认品牌名(与 layout 兜底一致) */
|
||||
export const SITE_NAME = "海角社区";
|
||||
/** 站点品牌名:页面展示统一用它,不取接口 webSiteName / dhSiteName */
|
||||
export const SITE_NAME = "黄果黑料";
|
||||
|
||||
/** 站点域名:页面展示的官网 / 最新 / 永久地址统一用它,不取接口地址字段 */
|
||||
export const SITE_DOMAIN = "huangguohl.com";
|
||||
export const SITE_URL = `https://${SITE_DOMAIN}`;
|
||||
|
||||
/** 官方联系方式:只保留 TG(与黄果其它站点一致),不取接口 gmailAddr / telegram / twitterAddr */
|
||||
export const OFFICIAL_TG_HANDLE = "@jbd125";
|
||||
export const OFFICIAL_TG_URL = "https://t.me/jbd125";
|
||||
|
||||
/** 默认分享卡片图(OG / Twitter),全站写死 */
|
||||
export const OG_DEFAULT_IMAGE = "/images/og-default.png";
|
||||
|
||||
/** 后台占位文案(如「海角社区-网站标题」)不当作有效 SEO */
|
||||
/** 后台占位文案(如「黄果黑料-网站标题」)不当作有效 SEO */
|
||||
export function isPlaceholderSeoText(value?: string | null) {
|
||||
const text = value?.trim() || "";
|
||||
if (!text) return true;
|
||||
@@ -68,12 +76,26 @@ export function normalizeSEO(raw?: SeoValue | null | Record<string, unknown>): N
|
||||
return { title, description, keywords };
|
||||
}
|
||||
|
||||
/** 标题追加站点名:`标题 - 海角社区` */
|
||||
/** 标题追加站点名:`标题 - 黄果黑料` */
|
||||
export function withSiteSuffix(title: string, siteName = SITE_NAME) {
|
||||
const clean = title.trim();
|
||||
if (!clean) return siteName;
|
||||
if (clean === siteName || clean.endsWith(` - ${siteName}`) || clean.endsWith(` | ${siteName}`)) return clean;
|
||||
return `${clean} - ${siteName}`;
|
||||
if (
|
||||
clean === siteName ||
|
||||
clean.endsWith(`|${siteName}`) ||
|
||||
clean.endsWith(` - ${siteName}`) ||
|
||||
clean.endsWith(` | ${siteName}`)
|
||||
) {
|
||||
return clean;
|
||||
}
|
||||
return `${clean}|${siteName}`;
|
||||
}
|
||||
|
||||
/** 简介截断成描述片段:去换行、按字数截断并补省略号 */
|
||||
export function clipText(text?: string | null, max = 80) {
|
||||
const clean = (text || "").replace(/\s+/g, " ").trim();
|
||||
if (clean.length <= max) return clean;
|
||||
return `${clean.slice(0, max).trim()}…`;
|
||||
}
|
||||
|
||||
/** 列表分页 TDK:第 N 页 */
|
||||
@@ -171,7 +193,7 @@ export function siteSEOFromConfig(config?: {
|
||||
description: config.webDescription,
|
||||
keyworld: config.webKeyworld,
|
||||
},
|
||||
siteName: config.webSiteName?.trim() || SITE_NAME,
|
||||
siteName: SITE_NAME,
|
||||
fallbackTitle: config.webTitle,
|
||||
fallbackDescription: config.webDescription,
|
||||
fallbackKeywords: config.webKeyworld,
|
||||
|
||||
@@ -46,10 +46,10 @@ export function configToSiteSEO(config?: ConfigInfo | null): SeoValue {
|
||||
};
|
||||
}
|
||||
|
||||
export function configSiteName(config?: ConfigInfo | null) {
|
||||
const name = config?.webSiteName?.trim();
|
||||
if (!name || isPlaceholderSeoText(name)) return SITE_NAME;
|
||||
return name;
|
||||
/** 站点名固定为 SITE_NAME,不取接口 webSiteName(保留参数兼容调用方) */
|
||||
export function configSiteName(_config?: ConfigInfo | null) {
|
||||
void _config;
|
||||
return SITE_NAME;
|
||||
}
|
||||
|
||||
/** 同请求复用:拉取 /api/app/home/base 公共 SEO */
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { SITE_NAME } from "./metadata";
|
||||
import { SITE_NAME, SITE_URL } from "./metadata";
|
||||
|
||||
/** 站点绝对根地址(环境变量兜底,与 layout metadataBase 一致) */
|
||||
export function getSiteOrigin() {
|
||||
return (process.env.NEXT_PUBLIC_SITE_URL || "https://haijiaosq.net").replace(/\/$/, "");
|
||||
return (process.env.NEXT_PUBLIC_SITE_URL || SITE_URL).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export {
|
||||
track,
|
||||
tracker,
|
||||
dramaEvent,
|
||||
dramaStatusChange,
|
||||
setDramaContext,
|
||||
videoEvent,
|
||||
setVideoContext,
|
||||
setPage,
|
||||
inferPage,
|
||||
initializeTracker,
|
||||
pageView,
|
||||
pageClick,
|
||||
navigation,
|
||||
externalAd,
|
||||
normalizeUID,
|
||||
normalizeChannel,
|
||||
} from "@/components/analytics/tracker";
|
||||
export type { Fields, TrackerBoot } from "@/components/analytics/tracker";
|
||||
Reference in New Issue
Block a user