[Ai] feat: 花魁短剧 Next.js 站点初始提交
- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from '@/server/react/page';
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { handleRequest } from "@/server/handler";
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const GET = handleRequest;
|
||||
export const HEAD = handleRequest;
|
||||
export const POST = handleRequest;
|
||||
export const PUT = handleRequest;
|
||||
export const PATCH = handleRequest;
|
||||
export const DELETE = handleRequest;
|
||||
export const OPTIONS = handleRequest;
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { handleRequest } from "@/server/handler";
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const GET = handleRequest;
|
||||
export const HEAD = handleRequest;
|
||||
export const POST = handleRequest;
|
||||
export const PUT = handleRequest;
|
||||
export const PATCH = handleRequest;
|
||||
export const DELETE = handleRequest;
|
||||
export const OPTIONS = handleRequest;
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
export default function ErrorPage({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<main className="hk-page-layout hk-not-found">
|
||||
<h1>暂时无法加载页面</h1>
|
||||
<p>请稍后重试。</p>
|
||||
<button className="hk-button hk-button--primary" onClick={reset}>
|
||||
重新加载
|
||||
</button>
|
||||
<a className="hk-button hk-button--ghost" href="/">
|
||||
返回首页
|
||||
</a>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { handleRequest } from "@/server/handler";
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const GET = handleRequest;
|
||||
export const HEAD = handleRequest;
|
||||
export const POST = handleRequest;
|
||||
export const PUT = handleRequest;
|
||||
export const PATCH = handleRequest;
|
||||
export const DELETE = handleRequest;
|
||||
export const OPTIONS = handleRequest;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { handleRequest } from "@/server/handler";
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const GET = handleRequest;
|
||||
export const HEAD = handleRequest;
|
||||
export const POST = handleRequest;
|
||||
export const PUT = handleRequest;
|
||||
export const PATCH = handleRequest;
|
||||
export const DELETE = handleRequest;
|
||||
export const OPTIONS = handleRequest;
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,87 @@
|
||||
import "xgplayer/dist/index.min.css";
|
||||
import "@/styles/components/video-player.css";
|
||||
import "@/styles/foundations/base.css";
|
||||
import "@/styles/components/site-header.css";
|
||||
import "@/styles/components/site-footer.css";
|
||||
import "@/styles/components/age-gate.css";
|
||||
import "@/styles/components/advertisement-dialog.css";
|
||||
import "@/styles/components/app-download-dialog.css";
|
||||
import "@/styles/components/installation-guide.css";
|
||||
import "@/styles/components/toast.css";
|
||||
import "@/styles/components/mobile-tabbar.css";
|
||||
import "@/styles/components/pagination.css";
|
||||
import "@/styles/components/promotional-banner.css";
|
||||
import "@/styles/components/promotional-grid.css";
|
||||
import "@/styles/components/advertising-compat.css";
|
||||
import "@/styles/components/playback-links.css";
|
||||
import "@/styles/pages/home.css";
|
||||
import "@/styles/pages/drama-list.css";
|
||||
import "@/styles/pages/recently-added.css";
|
||||
import "@/styles/pages/popular-recommendations.css";
|
||||
import "@/styles/pages/tag-detail.css";
|
||||
import "@/styles/pages/expanded-list.css";
|
||||
import "@/styles/pages/series-list.css";
|
||||
import "@/styles/pages/rankings.css";
|
||||
import "@/styles/pages/topics.css";
|
||||
import "@/styles/pages/topic-detail.css";
|
||||
import "@/styles/pages/search-results.css";
|
||||
import "@/styles/pages/about.css";
|
||||
import "@/styles/pages/about-responsive.css";
|
||||
import "@/styles/pages/help.css";
|
||||
import "@/styles/pages/feedback.css";
|
||||
import "@/styles/pages/legal.css";
|
||||
import "@/styles/pages/not-found.css";
|
||||
import "@/styles/pages/mobile-search.css";
|
||||
import "@/styles/pages/site-navigation.css";
|
||||
import "@/styles/pages/home-road.css";
|
||||
import "@/styles/pages/playback.css";
|
||||
import "@/styles/overrides/pwa-safe-area.css";
|
||||
import type { ReactNode } from "react";
|
||||
import { PwaProvider } from "@/components/pwa/PwaProvider";
|
||||
import { pwaViewport } from "@/lib/pwa/config";
|
||||
import { getRequestPage } from "@/server/react/request-page";
|
||||
import { toNextMetadata } from "@/server/seo";
|
||||
import { presentations } from "@/server/react/presentation";
|
||||
export async function generateMetadata() {
|
||||
return toNextMetadata((await getRequestPage()).data);
|
||||
}
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const viewport = pwaViewport;
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { view, data } = await getRequestPage(),
|
||||
presentation = presentations[view] || presentations["404"];
|
||||
const styleTokens = presentation.styleTokens.join(" ");
|
||||
return (
|
||||
<html lang="zh-CN" data-hk-page-styles={styleTokens}>
|
||||
<head>
|
||||
{["play", "seriesPlay"].includes(view) && (
|
||||
<>
|
||||
<meta name="renderer" content="webkit" />
|
||||
<meta name="force-rendering" content="webkit" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
<meta name="x5-orientation" content="portrait" />
|
||||
<meta name="x5-page-mode" content="app" />
|
||||
<meta name="browsermode" content="application" />
|
||||
<meta name="imagemode" content="force" />
|
||||
</>
|
||||
)}
|
||||
{data.jsonLD && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: String(data.jsonLD)
|
||||
.replace(/</g, "\\u003c")
|
||||
.replace(/>/g, "\\u003e"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body className={presentation.bodyClass}><PwaProvider />{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { handleDiscoveryFileRequest } from '@/server/seo/discovery-handler';
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const GET = handleDiscoveryFileRequest;
|
||||
export const HEAD = handleDiscoveryFileRequest;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { pwaConfig } from "@/lib/pwa/config";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
id: pwaConfig.scope,
|
||||
name: pwaConfig.name,
|
||||
short_name: pwaConfig.shortName,
|
||||
description: "花魁短剧,发现精彩短剧,随时畅享好故事。",
|
||||
lang: "zh-CN",
|
||||
start_url: pwaConfig.scope,
|
||||
scope: pwaConfig.scope,
|
||||
display: "fullscreen",
|
||||
display_override: ["fullscreen", "standalone"],
|
||||
orientation: "any",
|
||||
background_color: pwaConfig.themeColor,
|
||||
theme_color: pwaConfig.themeColor,
|
||||
icons: [
|
||||
{ src: pwaConfig.icons.small, sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: pwaConfig.icons.large, sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
{ src: pwaConfig.icons.maskable, sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { handleRequest } from "@/server/handler";
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const GET = handleRequest;
|
||||
export const HEAD = handleRequest;
|
||||
export const POST = handleRequest;
|
||||
export const PUT = handleRequest;
|
||||
export const PATCH = handleRequest;
|
||||
export const DELETE = handleRequest;
|
||||
export const OPTIONS = handleRequest;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getRequestPage } from "@/server/react/request-page";
|
||||
import { SiteShell } from "@/components/layout/SiteShell";
|
||||
import { NotFoundPage } from "@/features/content/NotFoundPage";
|
||||
export default async function NotFound() {
|
||||
const { data } = await getRequestPage();
|
||||
return (
|
||||
<SiteShell view="404" data={data}>
|
||||
<NotFoundPage data={data} />
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,5 @@
|
||||
import { handleDiscoveryFileRequest } from '@/server/seo/discovery-handler';
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const GET = handleDiscoveryFileRequest;
|
||||
export const HEAD = handleDiscoveryFileRequest;
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,5 @@
|
||||
import { handleDiscoveryFileRequest } from '@/server/seo/discovery-handler';
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const GET = handleDiscoveryFileRequest;
|
||||
export const HEAD = handleDiscoveryFileRequest;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { handleDiscoveryFileRequest } from '@/server/seo/discovery-handler';
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const GET = handleDiscoveryFileRequest;
|
||||
export const HEAD = handleDiscoveryFileRequest;
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DocumentPage as default,
|
||||
generatePageMetadata as generateMetadata,
|
||||
} from "@/server/react/page";
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import type { Data } from "@/server/services/model";
|
||||
import {
|
||||
initializeTracker,
|
||||
navigation,
|
||||
pageClick,
|
||||
pageView,
|
||||
externalAd,
|
||||
tracker,
|
||||
track,
|
||||
type TrackerBoot,
|
||||
} from "./tracker";
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
export function TrackerProvider({
|
||||
data,
|
||||
children,
|
||||
boot: explicitBoot,
|
||||
}: {
|
||||
data?: Data;
|
||||
boot?: TrackerBoot;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const boot: TrackerBoot = explicitBoot || {
|
||||
appId: String(data?.trackerAppId || ""),
|
||||
encryptedConfig:
|
||||
data?.reportSdkConfig ??
|
||||
data?.appConfig?.reportSdkConfig ??
|
||||
data?.appConfig?.ReportSDKConfig ??
|
||||
data?.appConfig?.reportSDKConfig,
|
||||
appVersion: "1.0.0",
|
||||
pageKey: data?.pageKey,
|
||||
pageName: data?.pageName,
|
||||
userType: data?.isVip ? "vip" : "normal",
|
||||
uid: String(data?.userID || data?.userId || ""),
|
||||
channel: data?.channel,
|
||||
};
|
||||
void loadScript("/static/common/web-sdk-v1.2.0.js?v=huakui-20261013-system")
|
||||
.then(() => initializeTracker(boot))
|
||||
.then(() => {
|
||||
if (!disposed) pageView();
|
||||
});
|
||||
const google = "G-J58C18CTS0";
|
||||
const yandex =
|
||||
data?.isNavPage || data?.requestPath === "/navigation/" ? 112952789 : 112951899;
|
||||
window.dataLayer ||= [];
|
||||
window.gtag ||= (...args) => {
|
||||
window.dataLayer!.push(args);
|
||||
};
|
||||
window.gtag("js", new Date());
|
||||
window.gtag("config", google);
|
||||
void loadScript(`https://www.googletagmanager.com/gtag/js?id=${google}`);
|
||||
window.ym ||= Object.assign(
|
||||
(...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}`);
|
||||
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],[data-register-close],[data-recharge-close],[data-play-coin-close],[data-setting-password-close],[data-drawer-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)
|
||||
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);
|
||||
};
|
||||
}, [data, explicitBoot]);
|
||||
return children;
|
||||
}
|
||||
export function useTracker() {
|
||||
return tracker;
|
||||
}
|
||||
export default TrackerProvider;
|
||||
@@ -0,0 +1,479 @@
|
||||
import { toLegacyPath } from "@/lib/routes";
|
||||
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";
|
||||
}
|
||||
export function inferPage(path: string) {
|
||||
path = toLegacyPath(path);
|
||||
if (path === "/") return { key: "home", name: "首页" };
|
||||
const rules = [
|
||||
["series-play", "series_play", "AI成人剧集播放页"],
|
||||
["series", "series", "AI成人剧集"],
|
||||
["play", "play", "短剧播放页"],
|
||||
["detail", "detail", "短剧详情"],
|
||||
["list", "list", "短剧列表"],
|
||||
["rank", "rank", "排行榜"],
|
||||
["topic-detail", "topic_detail", "专题详情"],
|
||||
["topic", "topic", "专题列表"],
|
||||
["tags", "tags", "标签详情"],
|
||||
["search-mobile", "search", "搜索结果页"],
|
||||
["search", "search", "搜索结果页"],
|
||||
["history", "history", "观看历史"],
|
||||
["favorites", "favorites", "我的收藏"],
|
||||
["profile", "profile", "个人中心"],
|
||||
["home-road", "home_road", "回家的路"],
|
||||
];
|
||||
const rule = rules.find(([prefix]) =>
|
||||
new RegExp(`^/${prefix}(?:/|$)`).test(path),
|
||||
);
|
||||
return rule
|
||||
? { key: rule[1], name: rule[2] }
|
||||
: { key: "internal_other", name: "站内其他页面" };
|
||||
}
|
||||
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: "分享",
|
||||
};
|
||||
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,
|
||||
dramaEvent,
|
||||
dramaStatusChange,
|
||||
dramaUnlock: () =>
|
||||
dramaStatusChange(
|
||||
number(drama?.episode, 1),
|
||||
str(drama?.video_id),
|
||||
"ep_unlock",
|
||||
),
|
||||
externalAd,
|
||||
advertising: (payload: Fields) => track("advertising", payload),
|
||||
keywordSearch: (payload: Fields) => track("keywordSearch", payload),
|
||||
keywordClick: (payload: Fields) => track("keywordClick", payload),
|
||||
getDramaContext: () => ({ ...drama }),
|
||||
getCommonFieldsSync: () => common,
|
||||
getCommonFields: serverContext,
|
||||
serverContext,
|
||||
getCommonField: async (key: string) => {
|
||||
await ready;
|
||||
await commonReady;
|
||||
return common[key] || "";
|
||||
},
|
||||
encodeSDKCommonHeader: (fields = common) =>
|
||||
btoa(
|
||||
unescape(
|
||||
encodeURIComponent(
|
||||
JSON.stringify(
|
||||
Object.fromEntries(
|
||||
commonKeys.map((key) => [key, str(fields[key])]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
getSDKCommonHeader: async () => {
|
||||
await ready;
|
||||
await commonReady;
|
||||
return tracker.encodeSDKCommonHeader();
|
||||
},
|
||||
flush: async () => {
|
||||
try {
|
||||
await window.WebSDK?.flush?.();
|
||||
} catch {
|
||||
/* Analytics must never interrupt playback. */
|
||||
}
|
||||
},
|
||||
setUid: (uid: unknown) => {
|
||||
activeBoot.uid = normalizeUID(uid);
|
||||
window.WebSDK?.setUid?.(activeBoot.uid);
|
||||
},
|
||||
setChannel: (channel: unknown) => {
|
||||
activeBoot.channel = normalizeChannel(channel);
|
||||
window.WebSDK?.setChannel?.(activeBoot.channel);
|
||||
},
|
||||
setContext: (context: Fields) => window.WebSDK?.setContext?.(context),
|
||||
};
|
||||
@@ -0,0 +1,407 @@
|
||||
import { Fragment, type CSSProperties } from "react";
|
||||
import {
|
||||
EncryptedBackground,
|
||||
EncryptedImage,
|
||||
} from "@/components/media/EncryptedImage";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import {
|
||||
array,
|
||||
cardTags,
|
||||
categoryURL,
|
||||
displayName,
|
||||
episodeChip,
|
||||
episodeLabel,
|
||||
episodeStatus,
|
||||
externalProps,
|
||||
first,
|
||||
formatLikeCount,
|
||||
get,
|
||||
itemPlayURL,
|
||||
itemVideoURL,
|
||||
num,
|
||||
str,
|
||||
strings,
|
||||
topicCoverURLs,
|
||||
type CatalogRecord,
|
||||
} from "@/features/catalog/model";
|
||||
|
||||
export function CardTags({
|
||||
item,
|
||||
links = true,
|
||||
}: {
|
||||
item: CatalogRecord;
|
||||
links?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{cardTags(item).map((tag, index) => (
|
||||
<Fragment key={tag.Name}>
|
||||
{index > 0 ? " " : ""}
|
||||
{links && tag.URL ? (
|
||||
<a href={tag.URL} {...externalProps(tag.URL)}>
|
||||
{tag.Name}
|
||||
</a>
|
||||
) : (
|
||||
tag.Name
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
/** Episode chip precedes the theme tags, matching the reference card meta row. */
|
||||
export function DramaCardMeta({ item }: { item: CatalogRecord }) {
|
||||
const chip = episodeChip(item);
|
||||
return (
|
||||
<p className="hk-drama-card__meta hk-drama-card__meta--ep">
|
||||
{chip && (
|
||||
<span
|
||||
className="hk-drama-card__meta-ep"
|
||||
data-status={num(item, "UpdateStatus") === 1 ? "serial" : "done"}
|
||||
>
|
||||
{chip}
|
||||
</span>
|
||||
)}
|
||||
<span className="hk-drama-card__meta-tags">
|
||||
<CardTags item={item} />
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
export function DramaCard({
|
||||
item,
|
||||
recent = false,
|
||||
featured = false,
|
||||
}: {
|
||||
item: CatalogRecord;
|
||||
recent?: boolean;
|
||||
featured?: boolean;
|
||||
}) {
|
||||
const href = itemPlayURL(item),
|
||||
name = displayName(item);
|
||||
return (
|
||||
<article className={`hk-drama-card${featured ? "" : " hk-drama-card--list"}`}>
|
||||
<EncryptedBackground
|
||||
as="a"
|
||||
className="hk-drama-card__cover hk-lazy"
|
||||
href={href}
|
||||
src={str(item, "Cover")}
|
||||
>
|
||||
{recent && get(item, "IsNew") ? (
|
||||
<span className="hk-badge hk-badge--new">上新</span>
|
||||
) : get(item, "IsFeatured") ? (
|
||||
<span className="hk-badge hk-badge--hot">爆剧</span>
|
||||
) : str(item, "Anchor") === "原创" ? (
|
||||
<span className="hk-badge hk-badge--original">原创</span>
|
||||
) : null}
|
||||
</EncryptedBackground>
|
||||
<h3 className="hk-drama-card__title">
|
||||
<a href={href}>{name}</a>
|
||||
</h3>
|
||||
<DramaCardMeta item={item} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
export function SeriesCard({
|
||||
item,
|
||||
home = false,
|
||||
short = false,
|
||||
more = false,
|
||||
}: {
|
||||
item: CatalogRecord;
|
||||
home?: boolean;
|
||||
short?: boolean;
|
||||
more?: boolean;
|
||||
}) {
|
||||
const prefix = home ? "hk-series-card" : "hk-series-list-card",
|
||||
href = short ? itemPlayURL(item) : itemVideoURL(item);
|
||||
const cover = short
|
||||
? first(
|
||||
get(item, "HorizontalCover"),
|
||||
get(item, "Banner"),
|
||||
get(item, "Cover"),
|
||||
)
|
||||
: str(item, "CoverImg");
|
||||
const label = short
|
||||
? num(item, "TotalEpisode") > 0
|
||||
? `全 ${num(item, "TotalEpisode")} 集`
|
||||
: episodeStatus(item)
|
||||
: more || home
|
||||
? num(item, "VideoTime") > 0
|
||||
? `${num(item, "VideoTime")}分钟`
|
||||
: ""
|
||||
: episodeLabel(item);
|
||||
const type = short
|
||||
? num(item, "OperateType") === 2
|
||||
? "漫剧"
|
||||
: "短剧"
|
||||
: more || home
|
||||
? "影集"
|
||||
: num(item, "MediaType") === 2
|
||||
? "动画"
|
||||
: "影集";
|
||||
return (
|
||||
<article className={prefix}>
|
||||
<EncryptedBackground
|
||||
as="a"
|
||||
className={`${prefix}__cover hk-lazy`}
|
||||
href={href}
|
||||
src={cover}
|
||||
>
|
||||
<span className="hk-badge hk-badge--hot">{type}</span>
|
||||
<span className={`${prefix}__play`} aria-hidden="true">
|
||||
<Icon name="play" />
|
||||
</span>
|
||||
</EncryptedBackground>
|
||||
<h3 className="hk-drama-card__title">
|
||||
<a href={href}>{short ? displayName(item) : str(item, "Title")}</a>
|
||||
</h3>
|
||||
<p className="hk-drama-card__meta hk-drama-card__meta--ep">
|
||||
{label && (
|
||||
<span
|
||||
className="hk-drama-card__meta-ep"
|
||||
data-status={short ? (num(item, "UpdateStatus") === 1 ? "serial" : "done") : "duration"}
|
||||
>
|
||||
{!short && <Icon name="clock-dim" className="hk-icon hk-drama-card__meta-icon" />}
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<span className="hk-drama-card__meta-tags">
|
||||
{short ? (
|
||||
<CardTags item={item} />
|
||||
) : more ? (
|
||||
first(
|
||||
get(item, "CreatorName"),
|
||||
strings(get(item, "Keyworld")).join(" "),
|
||||
)
|
||||
) : home ? (
|
||||
str(item, "CreatorName")
|
||||
) : (
|
||||
first(
|
||||
strings(get(item, "TopicNames")).join(" "),
|
||||
get(item, "CreatorName"),
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
export function RankCard({
|
||||
item,
|
||||
rank,
|
||||
home = false,
|
||||
binge = false,
|
||||
className = "",
|
||||
}: {
|
||||
item: CatalogRecord;
|
||||
rank: number;
|
||||
home?: boolean;
|
||||
binge?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const href = itemPlayURL(item),
|
||||
tags = cardTags(item);
|
||||
const heading = (
|
||||
<h3 className="hk-drama-rankings-card__title">
|
||||
<a href={href}>{displayName(item)}</a>
|
||||
</h3>
|
||||
);
|
||||
const meta = (
|
||||
<p className="hk-drama-rankings-card__meta">
|
||||
<CardTags item={item} links={!home} />
|
||||
{home ? (
|
||||
num(item, "TotalEpisode") > 0 && (
|
||||
<span className="hk-desktop-only">
|
||||
{tags.length ? " " : ""}全 {num(item, "TotalEpisode")} 集
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{tags.length ? " " : ""}
|
||||
{episodeStatus(item)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<article className={`hk-drama-rankings-card${className ? ` ${className}` : ""}`}>
|
||||
<div className="hk-drama-rankings-card__rank">
|
||||
<span
|
||||
className={`hk-drama-rankings-card__no${rank <= 3 ? " hk-is-top" : ""}`}
|
||||
style={{ "--hk-rank-digits": Math.max(2, String(rank).length) } as CSSProperties}
|
||||
>
|
||||
{String(rank).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hk-drama-rankings-card__body">
|
||||
<EncryptedBackground
|
||||
as="a"
|
||||
className="hk-drama-rankings-card__cover hk-lazy"
|
||||
href={href}
|
||||
src={str(item, "Cover")}
|
||||
>
|
||||
<span className="hk-drama-card__ep">{episodeStatus(item)}</span>
|
||||
</EncryptedBackground>
|
||||
{home ? (
|
||||
<>
|
||||
{heading}
|
||||
{meta}
|
||||
</>
|
||||
) : (
|
||||
<div className="hk-drama-rankings-card__info">
|
||||
{heading}
|
||||
{meta}
|
||||
<p className="hk-drama-rankings-card__heat hk-mobile-only">
|
||||
<Icon name="trending-up" />
|
||||
{formatLikeCount(get(item, binge ? "CountCollect" : "HeatValue"))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!home && (
|
||||
<a className="hk-drama-rankings-card__play hk-mobile-only" href={href}>
|
||||
<Icon name="play_hollow" />
|
||||
播放
|
||||
</a>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
export function PlayRecommendationCard({ item }: { item: CatalogRecord }) {
|
||||
const href = itemPlayURL(item);
|
||||
return (
|
||||
<article className="hk-drama-card hk-drama-card--play">
|
||||
<EncryptedBackground
|
||||
as="a"
|
||||
className="hk-drama-card__cover hk-lazy"
|
||||
href={href}
|
||||
src={str(item, "Cover")}
|
||||
>
|
||||
</EncryptedBackground>
|
||||
<h3 className="hk-drama-card__title">
|
||||
<a href={href}>{displayName(item)}</a>
|
||||
</h3>
|
||||
<DramaCardMeta item={item} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
export function TopicCard({
|
||||
item,
|
||||
home = false,
|
||||
}: {
|
||||
item: CatalogRecord;
|
||||
home?: boolean;
|
||||
}) {
|
||||
const href = categoryURL(get(item, "Slug"), get(item, "ID"));
|
||||
const info = (
|
||||
<div className="hk-collection-card__info">
|
||||
<p className="hk-eyebrow">
|
||||
<span className="hk-eyebrow__bar" aria-hidden="true" />
|
||||
专题
|
||||
</p>
|
||||
<h3 className="hk-collection-card__title">
|
||||
{home ? <a href={href}>{str(item, "Name")}</a> : str(item, "Name")}
|
||||
</h3>
|
||||
{str(item, "Desc") && (
|
||||
<p className="hk-collection-card__desc">{str(item, "Desc")}</p>
|
||||
)}
|
||||
<p className="hk-collection-card__count">
|
||||
{num(item, "PlayletCount")
|
||||
? `${num(item, "PlayletCount")} 部 持续更新`
|
||||
: "持续更新"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
const images = (
|
||||
home
|
||||
? array(get(item, "List"))
|
||||
.slice(0, 3)
|
||||
.map((value) => ({
|
||||
src: str(value, "Cover"),
|
||||
alt: `${str(value, "Title")}封面`,
|
||||
}))
|
||||
: topicCoverURLs(item).map((src) => ({ src, alt: "专题封面" }))
|
||||
).map((image, index) => (
|
||||
<EncryptedImage
|
||||
key={`${image.src}-${index}`}
|
||||
className="hk-lazy"
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
));
|
||||
return home ? (
|
||||
<article className="hk-collection-card">
|
||||
{info}
|
||||
<a className="hk-collection-card__stack" href={href} aria-hidden="true">
|
||||
{images}
|
||||
</a>
|
||||
</article>
|
||||
) : (
|
||||
<a className="hk-collection-card" href={href}>
|
||||
{info}
|
||||
<div className="hk-collection-card__stack" aria-hidden="true">
|
||||
{images}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
export function RecommendationCard({
|
||||
item,
|
||||
mobile = false,
|
||||
onClick,
|
||||
}: {
|
||||
item: CatalogRecord;
|
||||
mobile?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const prefix = mobile ? "hk-mobile-search" : "hk-search-card",
|
||||
href = itemPlayURL(item),
|
||||
name = displayName(item);
|
||||
return (
|
||||
<article
|
||||
className={mobile ? "hk-mobile-search__card" : "hk-search-card"}
|
||||
data-search-item-id={num(item, "ID")}
|
||||
>
|
||||
<a
|
||||
className={`${prefix}__cover`}
|
||||
href={href}
|
||||
aria-label={name}
|
||||
onClick={onClick}
|
||||
data-search-result-click=""
|
||||
>
|
||||
<EncryptedImage
|
||||
className="hk-lazy"
|
||||
src={str(item, "Cover")}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
mobile ? "hk-mobile-search__cover-shade" : "hk-search-card__scrim"
|
||||
}
|
||||
/>
|
||||
<span className={`${prefix}__episodes`}>{episodeStatus(item)}</span>
|
||||
{!mobile &&
|
||||
(get(item, "IsFeatured") ? (
|
||||
<span className="hk-search-card__badge">爆剧</span>
|
||||
) : str(item, "Anchor") === "原创" ? (
|
||||
<span className="hk-search-card__badge hk-search-card__badge--original">
|
||||
原创
|
||||
</span>
|
||||
) : null)}
|
||||
</a>
|
||||
<h3 className={`${prefix}__title`}>
|
||||
<a href={href} onClick={onClick} data-search-result-click="">
|
||||
{name}
|
||||
</a>
|
||||
</h3>
|
||||
<p className={`${prefix}__meta`}>
|
||||
<CardTags item={item} links={!mobile} />
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { EncryptedImage } from "@/components/media/EncryptedImage";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import { ShareButton } from "./Interactions";
|
||||
import {
|
||||
displayName,
|
||||
first,
|
||||
get,
|
||||
itemPlayURL,
|
||||
num,
|
||||
str,
|
||||
strings,
|
||||
type CatalogRecord,
|
||||
} from "@/features/catalog/model";
|
||||
|
||||
function HeroPoster({ src, alt, active }: {
|
||||
src: string;
|
||||
alt: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const reveal = () => setReady(true);
|
||||
return (
|
||||
<EncryptedImage
|
||||
className="hk-home-hero__poster"
|
||||
src={src}
|
||||
alt={alt}
|
||||
eager={active}
|
||||
loading={active ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
data-hero-loaded={ready ? "1" : undefined}
|
||||
onResolved={reveal}
|
||||
onResolveError={reveal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroReact({ items }: { items: CatalogRecord[] }) {
|
||||
const [index, setIndex] = useState(0),
|
||||
[hover, setHover] = useState(false),
|
||||
[dragging, setDragging] = useState(false),
|
||||
[hidden, setHidden] = useState(false);
|
||||
const gesture = useRef<{ x: number; y: number } | null>(null),
|
||||
suppressClick = useRef(0);
|
||||
const total = items.length;
|
||||
const go = (step: number) =>
|
||||
setIndex((previous) => (previous + step + total) % total);
|
||||
useEffect(() => {
|
||||
if (total < 2 || hover || dragging || hidden) return;
|
||||
const timer = setInterval(
|
||||
() => setIndex((previous) => (previous + 1) % total),
|
||||
3000,
|
||||
);
|
||||
return () => clearInterval(timer);
|
||||
}, [total, hover, dragging, hidden, index]);
|
||||
useEffect(() => {
|
||||
const sync = () => setHidden(document.hidden);
|
||||
sync();
|
||||
document.addEventListener("visibilitychange", sync);
|
||||
return () => document.removeEventListener("visibilitychange", sync);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const finish = (event: PointerEvent) => {
|
||||
const start = gesture.current;
|
||||
gesture.current = null;
|
||||
setDragging(false);
|
||||
if (!start) return;
|
||||
const x = event.clientX - start.x,
|
||||
y = event.clientY - start.y;
|
||||
if (Math.abs(x) >= 50 && Math.abs(x) > Math.abs(y) * 1.2 && total > 1) {
|
||||
suppressClick.current = Date.now() + 400;
|
||||
setIndex((previous) => (previous + (x < 0 ? 1 : -1) + total) % total);
|
||||
}
|
||||
};
|
||||
const cancel = () => {
|
||||
gesture.current = null;
|
||||
setDragging(false);
|
||||
};
|
||||
window.addEventListener("pointerup", finish);
|
||||
window.addEventListener("pointercancel", cancel);
|
||||
window.addEventListener("blur", cancel);
|
||||
return () => {
|
||||
window.removeEventListener("pointerup", finish);
|
||||
window.removeEventListener("pointercancel", cancel);
|
||||
window.removeEventListener("blur", cancel);
|
||||
};
|
||||
}, [total]);
|
||||
if (!total) return null;
|
||||
const current = items[index] || items[0];
|
||||
return (
|
||||
<section
|
||||
className={`hk-home-hero${dragging ? " hk-is-dragging" : ""}`}
|
||||
aria-label="本周主打"
|
||||
data-hero=""
|
||||
style={{ touchAction: "pan-y" }}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
gesture.current = { x: event.clientX, y: event.clientY };
|
||||
setDragging(true);
|
||||
}}
|
||||
onClickCapture={(event) => {
|
||||
if (Date.now() < suppressClick.current) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<EncryptedImage
|
||||
className="hk-home-hero__bg hk-is-active hk-lazy"
|
||||
src={first(get(current, "Banner"), get(current, "Cover"))}
|
||||
alt={`${displayName(current)}横幅`}
|
||||
eager
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="hk-home-hero__shade hk-home-hero__shade--side" aria-hidden="true" />
|
||||
<div className="hk-home-hero__shade hk-home-hero__shade--bottom" aria-hidden="true" />
|
||||
<div className="hk-home-hero__shade hk-home-hero__shade--top" aria-hidden="true" />
|
||||
<div className="hk-home-hero__viewport">
|
||||
{items.map((item, slide) => {
|
||||
const active = slide === index,
|
||||
poster = first(
|
||||
get(item, "HorizontalCover"),
|
||||
get(item, "Banner"),
|
||||
get(item, "Cover"),
|
||||
);
|
||||
return (
|
||||
<article
|
||||
key={`${str(item, "ID")}-${slide}`}
|
||||
className={`hk-home-hero__slide${active ? " hk-is-active" : ""}`}
|
||||
aria-hidden={!active}
|
||||
inert={!active}
|
||||
data-hero-slide={slide}
|
||||
>
|
||||
<div className="hk-home-hero__info">
|
||||
<div className="hk-home-hero__meta">
|
||||
<p className="hk-eyebrow">
|
||||
<span className="hk-eyebrow__bar" aria-hidden="true" />
|
||||
{get(item, "IsNew")
|
||||
? "本周主打 新剧速递"
|
||||
: get(item, "IsFeatured")
|
||||
? "本周主打 独家首发"
|
||||
: get(item, "IsRecommend")
|
||||
? "本周主打 热播推荐"
|
||||
: "本周主打 口碑佳作"}
|
||||
</p>
|
||||
<span className="hk-tag hk-tag--dark">18+</span>
|
||||
</div>
|
||||
<h2 className="hk-home-hero__title">{displayName(item)}</h2>
|
||||
<p className="hk-home-hero__attrs">
|
||||
{strings(get(item, "ThemeNames")).join(" ")}
|
||||
{num(item, "TotalEpisode") > 0
|
||||
? ` 全 ${num(item, "TotalEpisode")} 集`
|
||||
: ""}
|
||||
{str(item, "Anchor") ? ` 主演 ${str(item, "Anchor")}` : ""}
|
||||
</p>
|
||||
<p className="hk-home-hero__desc">{str(item, "Desc")}</p>
|
||||
<div className="hk-home-hero__actions">
|
||||
<a
|
||||
className="hk-button hk-button--primary hk-button--lg"
|
||||
href={itemPlayURL(item)}
|
||||
>
|
||||
<Icon name="play_hollow" />
|
||||
立即播放
|
||||
</a>
|
||||
<a
|
||||
className="hk-button hk-button--ghost hk-button--lg hk-home-hero__trailer"
|
||||
href={itemPlayURL(item)}
|
||||
aria-label="播放预告片"
|
||||
>
|
||||
<Icon name="film" />
|
||||
<span className="hk-desktop-only">预告片</span>
|
||||
</a>
|
||||
<ShareButton
|
||||
className="hk-button hk-button--icon hk-home-hero__share"
|
||||
label="分享本剧"
|
||||
href={itemPlayURL(item)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hk-home-hero__media">
|
||||
<a href={itemPlayURL(item)}>
|
||||
<HeroPoster
|
||||
key={poster}
|
||||
src={poster}
|
||||
alt={displayName(item)}
|
||||
active={active}
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
className="hk-home-hero__play"
|
||||
href={itemPlayURL(item)}
|
||||
aria-label="播放"
|
||||
>
|
||||
<Icon name="play" />
|
||||
</a>
|
||||
<span className="hk-badge hk-badge--hot">
|
||||
{num(item, "OperateType") === 2 ? "漫剧" : "短剧"}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="hk-home-hero__thumbs">
|
||||
<div
|
||||
className="hk-home-hero__thumb-list"
|
||||
role="tablist"
|
||||
aria-label="本周主打剧集"
|
||||
>
|
||||
{items.map((item, slide) => (
|
||||
<button
|
||||
key={`${str(item, "ID")}-${slide}`}
|
||||
className={`hk-home-hero__thumb${slide === index ? " hk-is-active" : ""}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-label={displayName(item)}
|
||||
aria-selected={slide === index}
|
||||
data-hero-index={slide}
|
||||
onClick={() => setIndex(slide)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
go(event.key === "ArrowRight" ? 1 : -1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<EncryptedImage
|
||||
className="hk-lazy"
|
||||
src={first(get(item, "Cover"), get(item, "Banner"))}
|
||||
alt={displayName(item)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="hk-home-hero__pager">
|
||||
<span className="hk-home-hero__pager-label">本周主打 {total} 部</span>
|
||||
<span className="hk-home-hero__pager-track" aria-hidden="true">
|
||||
<span
|
||||
className="hk-home-hero__pager-fill"
|
||||
style={{ width: `${((index + 1) / total) * 100}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="hk-home-hero__pager-index">
|
||||
<span data-hero-current="">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>{" "}
|
||||
/ {String(total).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import { useSite } from "@/components/providers/SiteProvider";
|
||||
import { externalProps, safeHref } from "@/features/catalog/model";
|
||||
|
||||
export function BackLink({
|
||||
className = "page-subheader__btn",
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<a
|
||||
className={className}
|
||||
href="/"
|
||||
aria-label="返回"
|
||||
onClick={(event) => {
|
||||
if (window.history.length > 1) {
|
||||
event.preventDefault();
|
||||
router.back();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children || <Icon name="chevron-left" />}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
export function ShareButton({
|
||||
href,
|
||||
className = "page-subheader__btn",
|
||||
label = "分享",
|
||||
children,
|
||||
}: {
|
||||
href?: string;
|
||||
className?: string;
|
||||
label?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const site = useSite(),
|
||||
pending = useRef(false),
|
||||
[busy, setBusy] = useState(false);
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const target = href
|
||||
? new URL(href, window.location.href).href
|
||||
: window.location.href;
|
||||
if (navigator.clipboard?.writeText)
|
||||
await navigator.clipboard.writeText(target);
|
||||
else {
|
||||
const input = document.createElement("textarea");
|
||||
input.value = target;
|
||||
input.style.position = "fixed";
|
||||
input.style.opacity = "0";
|
||||
document.body.append(input);
|
||||
input.select();
|
||||
const copied = document.execCommand("copy");
|
||||
input.remove();
|
||||
if (!copied) throw new Error("copy failed");
|
||||
}
|
||||
site.notify("链接已复制");
|
||||
} catch {
|
||||
site.notify("复制失败,请手动复制链接");
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children || <Icon name="share" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
export function SubpageHeader({
|
||||
title,
|
||||
label,
|
||||
share = false,
|
||||
overlay = false,
|
||||
}: {
|
||||
title: string;
|
||||
label?: string;
|
||||
share?: boolean;
|
||||
overlay?: boolean;
|
||||
}) {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!overlay) return;
|
||||
const sync = () => setScrolled(window.scrollY > 8);
|
||||
sync();
|
||||
window.addEventListener("scroll", sync, { passive: true });
|
||||
return () => window.removeEventListener("scroll", sync);
|
||||
}, [overlay]);
|
||||
return (
|
||||
<header
|
||||
className={`hk-page-layout-subheader${overlay ? " hk-page-layout-subheader--overlay" : ""} hk-mobile-only${scrolled ? " hk-is-scrolled" : ""}`}
|
||||
aria-label={label || `${title}顶栏`}
|
||||
>
|
||||
<BackLink />
|
||||
<div className="hk-page-layout-subheader__title">{title}</div>
|
||||
{share ? (
|
||||
<ShareButton />
|
||||
) : (
|
||||
<a
|
||||
className="hk-page-layout-subheader__btn"
|
||||
href="/discover/search/"
|
||||
aria-label="搜索"
|
||||
>
|
||||
<Icon name="search_h5" />
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
export interface SortOption {
|
||||
label: string;
|
||||
href: string;
|
||||
active: boolean;
|
||||
}
|
||||
export function SortDropdown({
|
||||
label,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
options: SortOption[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false),
|
||||
ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!ref.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("click", close);
|
||||
document.addEventListener("keydown", escape);
|
||||
return () => {
|
||||
document.removeEventListener("click", close);
|
||||
document.removeEventListener("keydown", escape);
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`hk-collection-detail-sort-dropdown hk-mobile-only${open ? " hk-is-open" : ""}`}
|
||||
>
|
||||
<button
|
||||
className="hk-collection-detail-sort-link"
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{label}
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
className="hk-icon hk-icon--sm hk-collection-detail-sort-link__caret"
|
||||
/>
|
||||
</button>
|
||||
<div className="hk-collection-detail-sort-menu" role="listbox" hidden={!open}>
|
||||
{options.map((option) => (
|
||||
<a
|
||||
key={option.href}
|
||||
className={`hk-collection-detail-sort-menu__item${option.active ? " hk-is-active" : ""}`}
|
||||
href={option.href}
|
||||
role="option"
|
||||
aria-selected={option.active}
|
||||
>
|
||||
{option.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function AppDownloadLink({
|
||||
href,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const site = useSite();
|
||||
return (
|
||||
<a
|
||||
href={safeHref(href || "/app/download/")}
|
||||
className={className}
|
||||
{...externalProps(href)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
site.openDownload();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import {
|
||||
array,
|
||||
externalProps,
|
||||
get,
|
||||
str,
|
||||
type CatalogRecord,
|
||||
} from "@/features/catalog/model";
|
||||
export function Pagination({
|
||||
pagination,
|
||||
}: {
|
||||
pagination: CatalogRecord | undefined;
|
||||
}) {
|
||||
if (!pagination || !get(pagination, "Visible")) return null;
|
||||
const previous = !!get(pagination, "HasPrev"),
|
||||
next = !!get(pagination, "HasNext");
|
||||
const arrow = (forward = false) => (
|
||||
<>
|
||||
<Icon name="chevron-right" />
|
||||
<span className="hk-visually-hidden">{forward ? "下一页" : "上一页"}</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<nav className="hk-pagination" aria-label="分页">
|
||||
{previous ? (
|
||||
<a
|
||||
className="hk-pagination__btn hk-pagination__btn--prev"
|
||||
href={str(pagination, "PrevURL")}
|
||||
{...externalProps(get(pagination, "PrevURL"))}
|
||||
aria-label="上一页"
|
||||
>
|
||||
{arrow()}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
className="hk-pagination__btn hk-pagination__btn--prev hk-is-disabled"
|
||||
aria-disabled="true"
|
||||
>
|
||||
{arrow()}
|
||||
</span>
|
||||
)}
|
||||
{array(get(pagination, "Items")).map((item, index) =>
|
||||
get(item, "Dots") ? (
|
||||
<span key={index} className="hk-pagination__ellipsis" aria-hidden="true">
|
||||
{str(item, "Label")}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
key={index}
|
||||
className={`hk-pagination__page${get(item, "Active") ? " hk-is-active" : ""}`}
|
||||
href={str(item, "URL")}
|
||||
{...externalProps(get(item, "URL"))}
|
||||
aria-current={get(item, "Active") ? "page" : undefined}
|
||||
>
|
||||
{str(item, "Label")}
|
||||
</a>
|
||||
),
|
||||
)}
|
||||
{next ? (
|
||||
<a
|
||||
className="hk-pagination__btn"
|
||||
href={str(pagination, "NextURL")}
|
||||
{...externalProps(get(pagination, "NextURL"))}
|
||||
aria-label="下一页"
|
||||
>
|
||||
{arrow(true)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="hk-pagination__btn hk-is-disabled" aria-disabled="true">
|
||||
{arrow(true)}
|
||||
</span>
|
||||
)}
|
||||
{str(pagination, "TotalText") && (
|
||||
<span className="hk-pagination__total">
|
||||
{str(pagination, "TotalText")}
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import {
|
||||
externalProps,
|
||||
first,
|
||||
get,
|
||||
safeHref,
|
||||
type Data,
|
||||
} from "@/features/catalog/model";
|
||||
export function PlayLinks({ data }: { data: Data }) {
|
||||
const config = data.appConfig,
|
||||
links = [
|
||||
{
|
||||
icon: "link_globe",
|
||||
alt: "最新网址",
|
||||
label: "官方入口",
|
||||
url: first(get(config, "NewURLAddr")),
|
||||
},
|
||||
{
|
||||
icon: "link_refresh-cw",
|
||||
alt: "备用网址",
|
||||
label: "最新地址发布页",
|
||||
url: first(get(config, "NewURLAddr1")),
|
||||
},
|
||||
{
|
||||
icon: "link_shield",
|
||||
alt: "永久地址",
|
||||
label: "备用访问地址",
|
||||
url: first(get(config, "NewURLAddr2")),
|
||||
},
|
||||
{
|
||||
icon: "link_award",
|
||||
alt: "防失联地址",
|
||||
label: "海外永久地址",
|
||||
url: first(get(config, "ForeverURLAddr")),
|
||||
},
|
||||
{
|
||||
icon: "link_smartphone",
|
||||
alt: "App 下载地址",
|
||||
label: "官方 App 下载",
|
||||
url: first(get(config, "OfficialAppAddr"), get(config, "AppAddr")),
|
||||
},
|
||||
{
|
||||
icon: "link_send",
|
||||
alt: "Telegram 群组",
|
||||
label: "官方 Telegram 群",
|
||||
url: first(get(config, "TGGroup"), get(config, "Telegram")),
|
||||
telegram: true,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<section className="hk-playback-links" aria-label="官方入口">
|
||||
{[0, 3].map((offset) => (
|
||||
<div key={offset} className="hk-playback-links__row">
|
||||
{links.slice(offset, offset + 3).map((link) => (
|
||||
<div key={link.icon} className="hk-playback-links__item">
|
||||
<Icon name={link.icon} />
|
||||
<span className="hk-playback-links__label">{link.label}</span>
|
||||
<a
|
||||
className="hk-playback-links__value"
|
||||
href={safeHref(link.url)}
|
||||
{...externalProps(link.url)}
|
||||
>
|
||||
{link.url
|
||||
? link.telegram
|
||||
? "点击加入"
|
||||
: link.url.replace(/^https?:\/\//i, "").replace(/\/$/, "")
|
||||
: "-"}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { EncryptedImage } from "@/components/media/EncryptedImage";
|
||||
import {
|
||||
array,
|
||||
first,
|
||||
get,
|
||||
safeHref,
|
||||
str,
|
||||
type CatalogRecord,
|
||||
} from "@/features/catalog/model";
|
||||
function adProps(item: CatalogRecord, type: "banner" | "icon") {
|
||||
const location = str(item, "Location"),
|
||||
link = str(item, "Link");
|
||||
return {
|
||||
href: safeHref(link),
|
||||
"data-track-ad": "external",
|
||||
"data-ad-clickable": link ? "1" : undefined,
|
||||
"data-ad-id": first(get(item, "AdvertiseCode"), get(item, "ID")),
|
||||
"data-creative-id": "",
|
||||
"data-ad-location": location,
|
||||
"data-ad-slot-key": first(get(item, "AdvertiseLocationCode"), location),
|
||||
"data-ad-slot-name": first(
|
||||
get(item, "AdvertiseLocationName"),
|
||||
location ? `广告位${location}` : "",
|
||||
),
|
||||
"data-ad-title": first(get(item, "Title"), "推荐"),
|
||||
"data-ad-type": type,
|
||||
...(link ? { target: "_blank", rel: "sponsored nofollow noopener" } : {}),
|
||||
};
|
||||
}
|
||||
export function AdBanners({ items }: { items: unknown }) {
|
||||
const ads = array(items);
|
||||
if (!ads.length) return null;
|
||||
return (
|
||||
<section className="hk-promotion-rail" aria-label="推荐">
|
||||
{ads.map((item, index) => (
|
||||
<a
|
||||
key={`${str(item, "ID")}-${index}`}
|
||||
className="hk-promotion-card"
|
||||
{...adProps(item, "banner")}
|
||||
>
|
||||
<EncryptedImage
|
||||
loading="eager"
|
||||
eager
|
||||
className="hk-lazy"
|
||||
src={str(item, "Images")}
|
||||
alt={first(get(item, "Title"), "推荐")}
|
||||
decoding="async"
|
||||
/>
|
||||
<span className="hk-tag hk-tag--dark hk-promotion-card__tag">广告</span>
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function PromoGrid({ items }: { items: unknown }) {
|
||||
const ads = array(items);
|
||||
if (!ads.length) return null;
|
||||
return (
|
||||
<section
|
||||
className="hk-promotion-grid"
|
||||
aria-label="推荐应用"
|
||||
style={{ "--promo-grid-count": ads.length } as CSSProperties}
|
||||
>
|
||||
<div className="hk-promotion-grid__track">
|
||||
{[false, true].map((clone) => (
|
||||
<div
|
||||
key={String(clone)}
|
||||
className="hk-promotion-grid__group"
|
||||
aria-hidden={clone || undefined}
|
||||
>
|
||||
{ads.map((item, index) => (
|
||||
<a
|
||||
key={`${str(item, "ID")}-${index}`}
|
||||
className="hk-promotion-grid__item"
|
||||
{...adProps(item, "icon")}
|
||||
tabIndex={clone ? -1 : undefined}
|
||||
data-ad-clone={clone ? "1" : undefined}
|
||||
>
|
||||
<span className="hk-promotion-grid__thumb">
|
||||
<EncryptedImage
|
||||
loading="eager"
|
||||
eager
|
||||
className="hk-lazy"
|
||||
src={str(item, "Images")}
|
||||
alt={clone ? "" : first(get(item, "Title"), "推荐")}
|
||||
decoding="async"
|
||||
/>
|
||||
</span>
|
||||
<span className="hk-promotion-grid__title">{str(item, "Title")}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Data } from "@/server/services/model";
|
||||
import { navIsActive } from "@/lib/view-helpers";
|
||||
export function MobileTabbar({ data }: { data: Data }) {
|
||||
const path = data.requestPath;
|
||||
if (!path) return null;
|
||||
const active = (url: string) => navIsActive(url, path),
|
||||
home = active("/"),
|
||||
short = active("/dramas/"),
|
||||
series = active("/series/");
|
||||
if (!(home || short || series)) return null;
|
||||
return (
|
||||
<nav className="hk-mobile-tabbar" aria-label="移动端主导航">
|
||||
{[
|
||||
["/", "首页", "home", home, "home"],
|
||||
["/dramas/", "短剧", "film", short, "short"],
|
||||
["/series/", "剧集", "tv", series, "series"],
|
||||
].map(([href, label, icon, isActive, key]) => (
|
||||
<a
|
||||
key={String(key)}
|
||||
className={"hk-mobile-tabbar__item" + (isActive ? " hk-is-active" : "")}
|
||||
href={String(href)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-navigation-key={"mobile_" + key}
|
||||
data-navigation-name={String(label)}
|
||||
>
|
||||
<img
|
||||
fetchPriority="low"
|
||||
src={
|
||||
"/static/images/mobile-tabbar/" +
|
||||
icon +
|
||||
"-" +
|
||||
(isActive ? "active" : "muted") +
|
||||
".svg"
|
||||
}
|
||||
width="22"
|
||||
height="22"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect } from "react";
|
||||
import { presentations } from "@/server/react/presentation";
|
||||
|
||||
const managedBodyClasses = [...new Set(
|
||||
Object.values(presentations).flatMap(({ bodyClass }) => bodyClass.split(/\s+/).filter(Boolean)),
|
||||
)];
|
||||
|
||||
/** Root layouts persist across client navigation; only page-owned presentation state changes here. */
|
||||
export function PagePresentation({ view }: { view: string }) {
|
||||
const presentation = presentations[view] || presentations["404"];
|
||||
const styleTokens = presentation.styleTokens.join(" ");
|
||||
const bodyClass = presentation.bodyClass;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.documentElement.setAttribute("data-hk-page-styles", styleTokens);
|
||||
const activeClasses = new Set(bodyClass.split(/\s+/).filter(Boolean));
|
||||
for (const className of managedBodyClasses)
|
||||
document.body.classList.toggle(className, activeClasses.has(className));
|
||||
// No cleanup: an outgoing page must not erase an incoming page's state.
|
||||
// PWA/age-gate HTML state, temporary body classes and scroll locks have other owners.
|
||||
}, [view, styleTokens, bodyClass]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
import type { Data } from "@/server/services/model";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import { QRCode } from "@/components/ui/QRCode";
|
||||
import { appDownloadURL } from "@/lib/app-download";
|
||||
import { useSite } from "@/components/providers/SiteProvider";
|
||||
import { NavigationLink } from "./SiteHeader";
|
||||
export function SiteFooter({ data }: { data: Data }) {
|
||||
const site = useSite(),
|
||||
config = data.config || {},
|
||||
qrValue = appDownloadURL(data.appConfig) || appDownloadURL(config),
|
||||
appAddr = qrValue || "/app/download/";
|
||||
const download = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
site.openDownload();
|
||||
};
|
||||
const nav = (label: string, items: [string, string, string][]) => (
|
||||
<nav className="hk-footer-col" aria-label={label}>
|
||||
<h3>{label}</h3>
|
||||
{items.map(([href, title, key]) => (
|
||||
<a
|
||||
key={key}
|
||||
href={href}
|
||||
{...(/^https?:/.test(href)
|
||||
? { target: "_blank", rel: "sponsored nofollow noopener" }
|
||||
: {})}
|
||||
data-navigation-key={"footer_" + key}
|
||||
data-navigation-name={title}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
const social = [
|
||||
["brand-x", config.twitterAddr, "X"],
|
||||
[
|
||||
"brand-telegram",
|
||||
config.tgGroup || config.telegram || config.telegramAdd,
|
||||
"Telegram",
|
||||
],
|
||||
].filter(([, url]) => url);
|
||||
return (
|
||||
<footer className="hk-site-footer">
|
||||
<div className="hk-site-footer__inner">
|
||||
<div className="hk-site-footer__top">
|
||||
<div className="hk-site-footer__brand">
|
||||
<div className="hk-brand">
|
||||
<picture>
|
||||
<source
|
||||
media="(max-width:768px)"
|
||||
srcSet="/static/brand/header.svg"
|
||||
/>
|
||||
<img
|
||||
className="hk-brand__logo hk-brand__logo--lg"
|
||||
src="/static/brand/footer.svg"
|
||||
width="124"
|
||||
height="44"
|
||||
alt="花魁短剧"
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
<p className="hk-content-section__subtitle">每一帧都值得细看</p>
|
||||
<div className="hk-social">
|
||||
{social.map(([icon, url, label]) => (
|
||||
<a
|
||||
className="hk-social__item"
|
||||
href={url}
|
||||
key={icon}
|
||||
target="_blank"
|
||||
rel="sponsored nofollow noopener"
|
||||
aria-label={label}
|
||||
data-navigation-key={"footer_" + label.toLowerCase()}
|
||||
data-navigation-name={label}
|
||||
>
|
||||
<Icon name={icon} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hk-site-footer__links">
|
||||
<nav className="hk-footer-col" aria-label="浏览">
|
||||
<h3>浏览</h3>
|
||||
{(data.categoryList || []).map((item: Data, index: number) => (
|
||||
<NavigationLink key={index} item={item} data={data} />
|
||||
))}
|
||||
</nav>
|
||||
{nav("帮助", [
|
||||
["/about/", "关于我们", "about"],
|
||||
["/help/", "常见问题", "help"],
|
||||
["/return-home/", "回家的路", "home_road"],
|
||||
["/feedback/", "意见反馈", "feedback"],
|
||||
[
|
||||
config.customerServiceHost || "/feedback/",
|
||||
"联系我们",
|
||||
"contact",
|
||||
],
|
||||
])}
|
||||
{nav("条款", [
|
||||
["/terms/", "用户条款", "terms"],
|
||||
["/privacy/", "隐私政策", "privacy"],
|
||||
["/2257/", "2257 合规声明", "2257"],
|
||||
["/dmca/", "DMCA 版权声明", "dmca"],
|
||||
])}
|
||||
<a
|
||||
className="hk-footer-download-mobile"
|
||||
data-download-open=""
|
||||
href={appAddr}
|
||||
onClick={download}
|
||||
data-navigation-key="footer_download_mobile"
|
||||
data-navigation-name="下载 App"
|
||||
>
|
||||
下载 App
|
||||
</a>
|
||||
<div className="hk-footer-download">
|
||||
<div className="hk-app-download-qr" aria-label="扫码下载">
|
||||
<QRCode value={qrValue} />
|
||||
</div>
|
||||
<div className="hk-footer-download__copy">
|
||||
<strong>扫码下载 App</strong>
|
||||
<p>iOS / Android 海量短剧每日更新</p>
|
||||
<a
|
||||
className="hk-button hk-button--primary hk-button--sm"
|
||||
data-download-open=""
|
||||
href={appAddr}
|
||||
onClick={download}
|
||||
data-navigation-key="footer_download"
|
||||
data-navigation-name="下载 APP"
|
||||
>
|
||||
<Icon name="download" className="hk-icon hk-icon--sm" />
|
||||
下载 App
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="hk-site-footer__rule" />
|
||||
<div className="hk-site-footer__legal">
|
||||
<p>
|
||||
© 2026 花魁短剧 HUAKUI
|
||||
<span className="hk-site-footer__rights">All rights reserved.</span>
|
||||
<span className="hk-site-footer__mobile-adult">
|
||||
仅限 18 岁以上成年人
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="hk-site-footer__adult">仅限 18 岁以上成年人 </span>
|
||||
本网站所呈现之所有人员,于拍摄当时均已年满 18 岁,符合 18 U.S.C. 第
|
||||
2257 条规定。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
"use client";
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type FormEvent,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import type { Data } from "@/server/services/model";
|
||||
import {
|
||||
navHref,
|
||||
navIsActive,
|
||||
navHasMegaMenu,
|
||||
navIsDownload,
|
||||
navDrawerIcon,
|
||||
listThemeSegment,
|
||||
listUpdateStatus,
|
||||
allListURL,
|
||||
moreListURL,
|
||||
searchResultsURL,
|
||||
} from "@/lib/view-helpers";
|
||||
import {
|
||||
useSearchHistory,
|
||||
useSearchSuggestions,
|
||||
} from "@/features/catalog/search";
|
||||
import { useSite } from "@/components/providers/SiteProvider";
|
||||
import { EncryptedImage } from "@/components/media/EncryptedImage";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import { useDialog } from "@/components/ui/Dialog";
|
||||
function external(href: string) {
|
||||
return /^(https?:\/\/|\/\/)/i.test(href)
|
||||
? { target: "_blank", rel: "sponsored nofollow noopener" }
|
||||
: {};
|
||||
}
|
||||
export function NavigationLink({
|
||||
item,
|
||||
data,
|
||||
className,
|
||||
children,
|
||||
onNavigate,
|
||||
}: {
|
||||
item: Data;
|
||||
data: Data;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const site = useSite(),
|
||||
download = navIsDownload(item.urlValue, item.name),
|
||||
href = download
|
||||
? data.appConfig?.appAddr || "/app/download/"
|
||||
: navHref(item.urlValue);
|
||||
return (
|
||||
<a
|
||||
className={className}
|
||||
href={href}
|
||||
{...external(href)}
|
||||
onClick={
|
||||
download || onNavigate
|
||||
? (event) => {
|
||||
if (download) event.preventDefault();
|
||||
onNavigate?.();
|
||||
if (download) site.openDownload();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
data-download-open={download ? "" : undefined}
|
||||
data-navigation-key={item.urlValue}
|
||||
data-navigation-name={item.name}
|
||||
>
|
||||
{children || item.name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
function navigationChildren(item: Data): Data[] {
|
||||
return Array.isArray(item.translationList)
|
||||
? item.translationList.filter(
|
||||
(child: Data | null) =>
|
||||
child && typeof child.urlValue === "string" && child.urlValue.trim(),
|
||||
)
|
||||
: [];
|
||||
}
|
||||
function NavigationSubmenu({
|
||||
item,
|
||||
items,
|
||||
data,
|
||||
mobile = false,
|
||||
onNavigate,
|
||||
}: {
|
||||
item: Data;
|
||||
items: Data[];
|
||||
data: Data;
|
||||
mobile?: boolean;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false),
|
||||
[popupRight, setPopupRight] = useState(0),
|
||||
id = useId(),
|
||||
ref = useRef<HTMLDivElement>(null),
|
||||
popupRef = useRef<HTMLDivElement>(null),
|
||||
toggleRef = useRef<HTMLButtonElement>(null),
|
||||
openedByHover = useRef(false),
|
||||
hasParentLink =
|
||||
!!String(item.urlValue || "").trim() && navHref(item.urlValue) !== "#",
|
||||
active =
|
||||
(hasParentLink && navIsActive(item.urlValue, data.requestPath)) ||
|
||||
items.some((child) => navIsActive(child.urlValue, data.requestPath)),
|
||||
itemClass =
|
||||
(mobile ? "hk-drawer__nav-item" : "hk-site-navigation__item") +
|
||||
(active ? " hk-is-active" : ""),
|
||||
toggleClass = mobile
|
||||
? "hk-drawer__nav-toggle"
|
||||
: "hk-site-navigation__submenu-toggle";
|
||||
useEffect(() => {
|
||||
if (!open || mobile) return;
|
||||
const position = () => {
|
||||
if (!ref.current || !popupRef.current) return;
|
||||
const right = ref.current.getBoundingClientRect().right,
|
||||
width = popupRef.current.getBoundingClientRect().width,
|
||||
left = Math.min(Math.max(right - width, 12), innerWidth - width - 12);
|
||||
setPopupRight(right - left - width);
|
||||
};
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (!ref.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
toggleRef.current?.focus({ preventScroll: true });
|
||||
setOpen(false);
|
||||
};
|
||||
position();
|
||||
window.addEventListener("resize", position);
|
||||
document.addEventListener("pointerdown", outside);
|
||||
document.addEventListener("keydown", escape);
|
||||
return () => {
|
||||
window.removeEventListener("resize", position);
|
||||
document.removeEventListener("pointerdown", outside);
|
||||
document.removeEventListener("keydown", escape);
|
||||
};
|
||||
}, [open, mobile]);
|
||||
const navigate = () => {
|
||||
if (!mobile) toggleRef.current?.focus({ preventScroll: true });
|
||||
setOpen(false);
|
||||
onNavigate?.();
|
||||
};
|
||||
const hover = (event: React.PointerEvent<HTMLElement>) => {
|
||||
if (!mobile && event.pointerType === "mouse" && !open) {
|
||||
openedByHover.current = true;
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
const label = (
|
||||
<>
|
||||
{mobile && <Icon name={navDrawerIcon(item.urlValue)} />}
|
||||
<span>{item.name}</span>
|
||||
</>
|
||||
);
|
||||
const toggle = (
|
||||
<button
|
||||
ref={toggleRef}
|
||||
type="button"
|
||||
className={
|
||||
(hasParentLink && mobile ? "" : itemClass + " ") +
|
||||
toggleClass +
|
||||
(hasParentLink ? " " + toggleClass + "--icon" : "")
|
||||
}
|
||||
aria-label={hasParentLink ? item.name + "子导航" : undefined}
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onPointerEnter={hover}
|
||||
onClick={() => {
|
||||
if (!mobile && openedByHover.current) {
|
||||
openedByHover.current = false;
|
||||
setOpen(true);
|
||||
} else setOpen((value) => !value);
|
||||
}}
|
||||
>
|
||||
{!hasParentLink && label}
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
className="hk-icon hk-site-navigation__submenu-chevron"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={
|
||||
mobile
|
||||
? "hk-drawer__nav-group"
|
||||
: "hk-site-navigation__item-wrap hk-site-navigation__item-wrap--submenu"
|
||||
}
|
||||
data-navigation-submenu={mobile ? "mobile" : "desktop"}
|
||||
data-open={open}
|
||||
style={
|
||||
mobile
|
||||
? undefined
|
||||
: ({ "--hk-submenu-right": popupRight + "px" } as CSSProperties)
|
||||
}
|
||||
onPointerEnter={hover}
|
||||
onPointerLeave={(event) => {
|
||||
openedByHover.current = false;
|
||||
if (
|
||||
!mobile &&
|
||||
event.pointerType === "mouse" &&
|
||||
!event.currentTarget.querySelector(":focus-visible")
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
if (!mobile && event.target.matches(":focus-visible")) setOpen(true);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (!mobile && !event.currentTarget.contains(event.relatedTarget))
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{hasParentLink ? (
|
||||
<div
|
||||
className={
|
||||
mobile ? "hk-drawer__nav-row" : "hk-site-navigation__submenu-trigger"
|
||||
}
|
||||
onPointerEnter={hover}
|
||||
>
|
||||
<NavigationLink
|
||||
item={item}
|
||||
data={data}
|
||||
className={itemClass}
|
||||
onNavigate={navigate}
|
||||
>
|
||||
{label}
|
||||
</NavigationLink>
|
||||
{toggle}
|
||||
</div>
|
||||
) : toggle}
|
||||
<div
|
||||
ref={popupRef}
|
||||
id={id}
|
||||
className={mobile ? "hk-drawer__submenu" : "hk-site-navigation__submenu"}
|
||||
style={mobile ? undefined : { right: popupRight }}
|
||||
role="group"
|
||||
aria-label={item.name + "子导航"}
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
hidden={mobile && !open}
|
||||
>
|
||||
{items.map((child, index) => (
|
||||
<NavigationLink
|
||||
key={child.id || index}
|
||||
item={child}
|
||||
data={data}
|
||||
className={
|
||||
(mobile
|
||||
? "hk-drawer__submenu-link"
|
||||
: "hk-site-navigation__submenu-link") +
|
||||
(navIsActive(child.urlValue, data.requestPath) ? " hk-is-active" : "")
|
||||
}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function HeaderSearch({ data }: { data: Data }) {
|
||||
const [query, setQuery] = useState(String(data.query || "")),
|
||||
[open, setOpen] = useState(false),
|
||||
[selected, setSelected] = useState(-1);
|
||||
const { history, remember, clear } = useSearchHistory(),
|
||||
{ suggestions, loading } = useSearchSuggestions(query),
|
||||
ref = useRef<HTMLFormElement>(null),
|
||||
input = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const close = (event: globalThis.MouseEvent) => {
|
||||
if (!ref.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
}, []);
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = query.trim();
|
||||
if (value) {
|
||||
remember(value);
|
||||
location.assign(searchResultsURL(value));
|
||||
}
|
||||
};
|
||||
const options = suggestions?.items || [];
|
||||
return (
|
||||
<form
|
||||
ref={ref}
|
||||
className="hk-search hk-desktop-only"
|
||||
action="/search/"
|
||||
role="search"
|
||||
data-path-search=""
|
||||
data-header-search=""
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Icon name="search" />
|
||||
<input
|
||||
ref={input}
|
||||
className="hk-search__input"
|
||||
type="search"
|
||||
placeholder="搜索剧名 / 演员 / 标签"
|
||||
autoComplete="off"
|
||||
data-header-search-input=""
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setOpen(true);
|
||||
setSelected(-1);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={open}
|
||||
aria-controls="header-search-dropdown"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setSelected((old) =>
|
||||
Math.max(
|
||||
-1,
|
||||
Math.min(
|
||||
options.length - 1,
|
||||
old + (event.key === "ArrowDown" ? 1 : -1),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (event.key === "Enter" && selected >= 0 && options[selected]) {
|
||||
event.preventDefault();
|
||||
remember(options[selected].query || query);
|
||||
location.assign(options[selected].url);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="hk-search__clear"
|
||||
type="button"
|
||||
aria-label="清除搜索词"
|
||||
data-header-search-clear=""
|
||||
hidden={!query}
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
input.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
<div
|
||||
className="hk-search-dropdown"
|
||||
id="header-search-dropdown"
|
||||
data-header-search-dropdown=""
|
||||
hidden={!open}
|
||||
>
|
||||
<div
|
||||
className="hk-search-dropdown__idle"
|
||||
data-header-search-idle=""
|
||||
hidden={!!query.trim()}
|
||||
>
|
||||
{history.length > 0 && (
|
||||
<section
|
||||
className="hk-search-dropdown__history"
|
||||
aria-label="搜索历史"
|
||||
data-header-search-history=""
|
||||
>
|
||||
<div className="hk-search-dropdown__head">
|
||||
<h2 className="hk-search-dropdown__label">搜索历史</h2>
|
||||
<button
|
||||
className="hk-search-dropdown__clear"
|
||||
type="button"
|
||||
aria-label="清除搜索历史"
|
||||
data-header-history-clear=""
|
||||
onClick={clear}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="hk-search-dropdown__chips"
|
||||
data-header-history-chips=""
|
||||
>
|
||||
{history.map((value) => (
|
||||
<a
|
||||
className="hk-search-dropdown__chip"
|
||||
key={value}
|
||||
href={searchResultsURL(value)}
|
||||
onClick={() => remember(value)}
|
||||
>
|
||||
{value}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{data.searchTrends?.length > 0 && (
|
||||
<section className="hk-search-dropdown__hot" aria-label="热门搜索">
|
||||
<h2 className="hk-search-dropdown__label">热门搜索</h2>
|
||||
<ol className="hk-search-dropdown__list">
|
||||
{data.searchTrends.map((item: Data) => (
|
||||
<li className="hk-search-dropdown__item" key={item.Title}>
|
||||
<a
|
||||
className="hk-search-dropdown__link"
|
||||
href={searchResultsURL(item.Title)}
|
||||
onClick={() => remember(item.Title)}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"hk-search-dropdown__rank" +
|
||||
(item.Rank <= 3 ? " hk-is-top" : "")
|
||||
}
|
||||
>
|
||||
{item.Rank}
|
||||
</span>
|
||||
<span className="hk-search-dropdown__name">
|
||||
{item.Title}
|
||||
</span>
|
||||
{item.Badge && (
|
||||
<span
|
||||
className={
|
||||
"hk-search-dropdown__badge hk-search-dropdown__badge--" +
|
||||
(item.Badge === "新" ? "new" : "hot")
|
||||
}
|
||||
>
|
||||
{item.Badge}
|
||||
</span>
|
||||
)}
|
||||
<span className="hk-search-dropdown__heat">{item.Heat}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="hk-search-dropdown__suggest"
|
||||
data-header-search-suggest=""
|
||||
hidden={!query.trim()}
|
||||
aria-label="搜索联想"
|
||||
>
|
||||
{loading ? (
|
||||
<p className="hk-search-dropdown__loading">搜索中…</p>
|
||||
) : (
|
||||
<>
|
||||
{suggestions?.feature && (
|
||||
<a
|
||||
className="hk-search-dropdown__feature"
|
||||
href={suggestions.feature.url}
|
||||
onClick={() => remember(query)}
|
||||
>
|
||||
<EncryptedImage
|
||||
className="hk-search-dropdown__feature-cover"
|
||||
src={suggestions.feature.cover}
|
||||
alt={suggestions.feature.title}
|
||||
/>
|
||||
<span className="hk-search-dropdown__feature-copy">
|
||||
<strong>{suggestions.feature.title}</strong>
|
||||
<small>{suggestions.feature.meta}</small>
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
<ul className="hk-search-dropdown__list">
|
||||
{options.map((item, index) => (
|
||||
<li
|
||||
className={
|
||||
"hk-search-dropdown__item" +
|
||||
(selected === index ? " hk-is-active" : "")
|
||||
}
|
||||
key={item.url + index}
|
||||
>
|
||||
<a
|
||||
className="hk-search-dropdown__link"
|
||||
href={item.url}
|
||||
onClick={() => remember(item.query || query)}
|
||||
>
|
||||
<Icon name="search" className="hk-icon hk-icon--sm" />
|
||||
<span className="hk-search-dropdown__name">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.kind && (
|
||||
<span className="hk-search-dropdown__kind">
|
||||
{item.kind}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
function MegaMenu({ data }: { data: Data }) {
|
||||
const theme = listThemeSegment(data.requestPath),
|
||||
status = listUpdateStatus(data.requestPath);
|
||||
return (
|
||||
<div className="hk-site-navigation__mega" role="menu" aria-label="短剧快捷筛选">
|
||||
<div className="hk-site-navigation__mega-col hk-site-navigation__mega-col--themes">
|
||||
<p className="hk-site-navigation__mega-label">题材</p>
|
||||
<div className="hk-site-navigation__mega-chips">
|
||||
{(data.themeList || []).map((item: Data) => (
|
||||
<a
|
||||
key={item.id}
|
||||
className={
|
||||
"hk-site-navigation__mega-chip" +
|
||||
([String(item.id), item.slug].includes(theme)
|
||||
? " hk-is-active"
|
||||
: "")
|
||||
}
|
||||
href={allListURL(item.slug, item.id, 0, 1, 1)}
|
||||
role="menuitem"
|
||||
data-navigation-key={"mega_theme_" + item.id}
|
||||
data-navigation-name={"题材:" + item.name}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hk-site-navigation__mega-col hk-site-navigation__mega-col--status">
|
||||
<p className="hk-site-navigation__mega-label">状态</p>
|
||||
<div className="hk-site-navigation__mega-chips">
|
||||
{["全部", "连载中", "已完结"].map((label, index) => (
|
||||
<a
|
||||
key={label}
|
||||
className={
|
||||
"hk-site-navigation__mega-chip" + (status === index ? " hk-is-active" : "")
|
||||
}
|
||||
href={allListURL("", 0, index, 1, 1)}
|
||||
role="menuitem"
|
||||
data-navigation-key={"mega_status_" + index}
|
||||
data-navigation-name={"状态:" + label}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hk-site-navigation__mega-col hk-site-navigation__mega-col--links">
|
||||
<p className="hk-site-navigation__mega-label">快捷入口</p>
|
||||
{(data.homeConfigPlaylets || []).map((item: Data, index: number) => (
|
||||
<a
|
||||
className="hk-site-navigation__mega-link"
|
||||
key={item.id}
|
||||
href={moreListURL(item.slug, item.id, 0, "", 0, 1)}
|
||||
role="menuitem"
|
||||
data-navigation-key={"mega_section_" + item.id}
|
||||
data-navigation-name={"快捷入口:" + item.name}
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
item.name === "预告片"
|
||||
? "film2"
|
||||
: ["nav_trend", "nav_zap", "nav_star", "nav_grid"][index % 4]
|
||||
}
|
||||
className="hk-icon hk-icon--sm"
|
||||
/>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Drawer({ data, onClose }: { data: Data; onClose: () => void }) {
|
||||
const ref = useDialog(onClose),
|
||||
site = useSite();
|
||||
useEffect(() => {
|
||||
document.body.classList.add("hk-drawer-open");
|
||||
return () => document.body.classList.remove("hk-drawer-open");
|
||||
}, []);
|
||||
const open = (action: () => void) => () => {
|
||||
onClose();
|
||||
action();
|
||||
};
|
||||
return (
|
||||
<div ref={ref} className="hk-drawer hk-is-open" data-drawer="">
|
||||
<div className="hk-drawer__mask" data-drawer-close="" onClick={onClose} />
|
||||
<aside
|
||||
className="hk-drawer__panel"
|
||||
id="site-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="侧边导航"
|
||||
>
|
||||
<div className="hk-drawer__head">
|
||||
<div className="hk-drawer__brand-row">
|
||||
<a className="hk-brand" href="/">
|
||||
<img
|
||||
fetchPriority="low"
|
||||
className="hk-brand__logo"
|
||||
src="/static/brand/mobile.svg"
|
||||
width="88"
|
||||
height="28"
|
||||
alt="花魁短剧"
|
||||
/>
|
||||
</a>
|
||||
<button
|
||||
className="hk-drawer__close"
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
data-drawer-close=""
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="hk-drawer__rule" />
|
||||
<nav className="hk-drawer__nav" aria-label="页面导航">
|
||||
{(data.categoryList || []).map((item: Data, index: number) => {
|
||||
const items = navigationChildren(item);
|
||||
return items.length ? (
|
||||
<NavigationSubmenu
|
||||
key={index}
|
||||
item={item}
|
||||
items={items}
|
||||
data={data}
|
||||
mobile
|
||||
onNavigate={onClose}
|
||||
/>
|
||||
) : (
|
||||
<NavigationLink
|
||||
item={item}
|
||||
data={data}
|
||||
key={index}
|
||||
className={
|
||||
"hk-drawer__nav-item" +
|
||||
(navIsActive(item.urlValue, data.requestPath)
|
||||
? " hk-is-active"
|
||||
: "")
|
||||
}
|
||||
>
|
||||
<Icon name={navDrawerIcon(item.urlValue)} />
|
||||
{item.name}
|
||||
</NavigationLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<hr className="hk-drawer__rule" />
|
||||
<div className="hk-drawer__tags">
|
||||
<p className="hk-drawer__tags-label">题材</p>
|
||||
<div className="hk-drawer__tag-list">
|
||||
{(data.themeList || []).map((item: Data) => (
|
||||
<a
|
||||
className="hk-drawer__tag"
|
||||
key={item.id}
|
||||
href={allListURL(item.slug, item.id, 0, 1, 1)}
|
||||
data-navigation-key={"drawer_theme_" + item.id}
|
||||
data-navigation-name={"题材:" + item.name}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hk-drawer__links">
|
||||
<button
|
||||
className="hk-drawer__link"
|
||||
type="button"
|
||||
data-download-open=""
|
||||
onClick={open(site.openDownload)}
|
||||
>
|
||||
<Icon name="download" />
|
||||
下载 App
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function SiteHeader({ data }: { data: Data }) {
|
||||
const site = useSite(),
|
||||
[drawer, setDrawer] = useState(false),
|
||||
[scrolled, setScrolled] = useState(false),
|
||||
ref = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
const scroll = () => setScrolled(scrollY > 48),
|
||||
measure = () =>
|
||||
document.documentElement.style.setProperty(
|
||||
"--site-header-height",
|
||||
(ref.current?.getBoundingClientRect().height || 72) + "px",
|
||||
);
|
||||
scroll();
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
if (ref.current) observer.observe(ref.current);
|
||||
window.addEventListener("scroll", scroll, { passive: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("scroll", scroll);
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
ref={ref}
|
||||
className={"hk-site-header" + (scrolled ? " hk-is-scrolled" : "")}
|
||||
>
|
||||
<div className="hk-site-header__left">
|
||||
<button
|
||||
className="hk-site-header__icon-btn hk-mobile-only"
|
||||
type="button"
|
||||
aria-label="菜单"
|
||||
data-drawer-open=""
|
||||
aria-expanded={drawer}
|
||||
aria-controls="site-drawer"
|
||||
onClick={() => setDrawer(true)}
|
||||
>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<a
|
||||
className="hk-brand"
|
||||
href="/"
|
||||
data-navigation-key="home"
|
||||
data-navigation-name="首页"
|
||||
>
|
||||
<picture>
|
||||
<source
|
||||
media="(max-width:768px)"
|
||||
srcSet="/static/brand/mobile.svg"
|
||||
/>
|
||||
<img
|
||||
className="hk-brand__logo"
|
||||
src="/static/brand/header.svg"
|
||||
width="104"
|
||||
height="36"
|
||||
alt="花魁短剧"
|
||||
/>
|
||||
</picture>
|
||||
</a>
|
||||
<nav className="hk-site-navigation hk-desktop-only" aria-label="主导航">
|
||||
{(data.categoryList || []).map((item: Data, index: number) => {
|
||||
const items = navigationChildren(item);
|
||||
return items.length ? (
|
||||
<NavigationSubmenu
|
||||
key={index}
|
||||
item={item}
|
||||
items={items}
|
||||
data={data}
|
||||
/>
|
||||
) : navHasMegaMenu(item.urlValue) ? (
|
||||
<div className="hk-site-navigation__item-wrap" key={index}>
|
||||
<a
|
||||
className={
|
||||
"hk-site-navigation__item" +
|
||||
(navIsActive(item.urlValue, data.requestPath)
|
||||
? " hk-is-active"
|
||||
: "")
|
||||
}
|
||||
href={navHref(item.urlValue)}
|
||||
aria-haspopup="true"
|
||||
data-navigation-key={item.urlValue}
|
||||
data-navigation-name={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
<MegaMenu data={data} />
|
||||
</div>
|
||||
) : (
|
||||
<NavigationLink
|
||||
item={item}
|
||||
data={data}
|
||||
key={index}
|
||||
className={
|
||||
"hk-site-navigation__item" +
|
||||
(navIsActive(item.urlValue, data.requestPath)
|
||||
? " hk-is-active"
|
||||
: "")
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="hk-site-header__right">
|
||||
<HeaderSearch data={data} />
|
||||
<a
|
||||
className="hk-site-header__icon-btn hk-mobile-only"
|
||||
href="/discover/search/"
|
||||
aria-label="搜索"
|
||||
data-navigation-key="header_search"
|
||||
data-navigation-name="搜索"
|
||||
>
|
||||
<Icon name="search_h5" />
|
||||
</a>
|
||||
<a
|
||||
className="hk-button hk-button--ghost hk-site-header__download"
|
||||
data-download-open=""
|
||||
href={data.appConfig?.appAddr || "/app/download/"}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
site.openDownload();
|
||||
}}
|
||||
data-navigation-key="header_download"
|
||||
data-navigation-name="下载 APP"
|
||||
>
|
||||
<Icon name="download" />
|
||||
<span>下载 App</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
{drawer && <Drawer data={data} onClose={() => setDrawer(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Data } from "@/server/services/model";
|
||||
import { SiteProvider } from "@/components/providers/SiteProvider";
|
||||
import { TrackerProvider } from "@/components/analytics/TrackerProvider";
|
||||
import { presentations } from "@/server/react/presentation";
|
||||
import { SiteHeader } from "./SiteHeader";
|
||||
import { SiteFooter } from "./SiteFooter";
|
||||
import { MobileTabbar } from "./MobileTabbar";
|
||||
import { PagePresentation } from "./PagePresentation";
|
||||
export function SiteShell({
|
||||
view,
|
||||
data,
|
||||
children,
|
||||
}: {
|
||||
view: string;
|
||||
data: Data;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const presentation = presentations[view] || presentations["404"];
|
||||
return (
|
||||
<SiteProvider data={data} dialogs={presentation.header}>
|
||||
<PagePresentation view={view} />
|
||||
<TrackerProvider data={data}>
|
||||
{presentation.header && <SiteHeader data={data} />} {children}
|
||||
{presentation.footer && <SiteFooter data={data} />}{" "}
|
||||
{presentation.header && <MobileTabbar data={data} />}
|
||||
</TrackerProvider>
|
||||
<noscript>
|
||||
<div>
|
||||
<img
|
||||
src={
|
||||
"https://mc.yandex.ru/watch/" +
|
||||
(data.isNavPage ? "112952789" : "112951899")
|
||||
}
|
||||
style={{ position: "absolute", left: -9999 }}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</noscript>
|
||||
</SiteProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
import {
|
||||
createElement,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ImgHTMLAttributes,
|
||||
type HTMLAttributes,
|
||||
type AnchorHTMLAttributes,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import { IMAGE_PLACEHOLDER, resolveImage } from "@/lib/media/images";
|
||||
import { useImageCDNs } from "./ImageProvider";
|
||||
|
||||
export interface EncryptedImageProps
|
||||
extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> {
|
||||
src?: string;
|
||||
fallback?: string;
|
||||
eager?: boolean;
|
||||
onResolved?: (url: string) => void;
|
||||
onResolveError?: (error: Error) => void;
|
||||
}
|
||||
export function useEncryptedSource(
|
||||
src: string | undefined,
|
||||
eager: boolean,
|
||||
element: React.RefObject<Element | null>,
|
||||
fallback = IMAGE_PLACEHOLDER,
|
||||
) {
|
||||
const bases = useImageCDNs();
|
||||
const [value, setValue] = useState(fallback);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [resolved, setResolved] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let observer: IntersectionObserver | undefined;
|
||||
setValue(fallback);
|
||||
setFailed(false);
|
||||
setResolved(false);
|
||||
setError(null);
|
||||
const load = () =>
|
||||
resolveImage(src || fallback, eager, bases)
|
||||
.then((url) => {
|
||||
if (!disposed) {
|
||||
setValue(url);
|
||||
setResolved(true);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!disposed) {
|
||||
setFailed(true);
|
||||
setValue(fallback);
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Image resolution failed"),
|
||||
);
|
||||
}
|
||||
});
|
||||
if (
|
||||
eager ||
|
||||
!element.current ||
|
||||
typeof IntersectionObserver === "undefined"
|
||||
)
|
||||
void load();
|
||||
else {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
observer?.disconnect();
|
||||
void load();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "180px 0px", threshold: 0.01 },
|
||||
);
|
||||
observer.observe(element.current);
|
||||
}
|
||||
return () => {
|
||||
disposed = true;
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [src, eager, fallback, element, bases]);
|
||||
return {
|
||||
value,
|
||||
failed,
|
||||
resolved,
|
||||
error,
|
||||
fail: () => {
|
||||
setValue(fallback);
|
||||
setFailed(true);
|
||||
setError(new Error("Image decode failed"));
|
||||
},
|
||||
};
|
||||
}
|
||||
export const EncryptedImage = forwardRef<HTMLImageElement, EncryptedImageProps>(
|
||||
function EncryptedImage(
|
||||
{
|
||||
src,
|
||||
fallback = IMAGE_PLACEHOLDER,
|
||||
eager = false,
|
||||
loading = "lazy",
|
||||
onError,
|
||||
onLoad,
|
||||
onResolved,
|
||||
onResolveError,
|
||||
...props
|
||||
},
|
||||
forwardedRef,
|
||||
) {
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
const image = useEncryptedSource(
|
||||
src,
|
||||
eager || loading === "eager" || props.fetchPriority === "high",
|
||||
ref,
|
||||
fallback,
|
||||
);
|
||||
const callbacks = useRef({ onResolved, onResolveError });
|
||||
callbacks.current = { onResolved, onResolveError };
|
||||
const notified = useRef<string | null>(null);
|
||||
const notifyReady = () => {
|
||||
if (image.resolved && !image.failed && notified.current !== image.value) {
|
||||
notified.current = image.value;
|
||||
callbacks.current.onResolved?.(image.value);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
notified.current = null;
|
||||
}, [src]);
|
||||
useEffect(() => {
|
||||
if (image.error) callbacks.current.onResolveError?.(image.error);
|
||||
else if (
|
||||
image.resolved &&
|
||||
ref.current?.complete &&
|
||||
ref.current.naturalWidth > 0
|
||||
)
|
||||
notifyReady();
|
||||
}, [image.resolved, image.error, image.value]);
|
||||
// Low priority disables SSR preload while preserving loading/decryption timing.
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
ref={(node) => {
|
||||
ref.current = node;
|
||||
if (typeof forwardedRef === "function") forwardedRef(node);
|
||||
else if (forwardedRef) forwardedRef.current = node;
|
||||
}}
|
||||
src={image.value}
|
||||
loading={loading}
|
||||
fetchPriority="low"
|
||||
onLoad={(event) => {
|
||||
notifyReady();
|
||||
onLoad?.(event);
|
||||
}}
|
||||
data-decrypt-failed={image.failed ? "1" : undefined}
|
||||
onError={(event) => {
|
||||
if (!image.failed) image.fail();
|
||||
onError?.(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
export interface BackgroundImageProps extends HTMLAttributes<HTMLDivElement> {
|
||||
src?: string;
|
||||
fallback?: string;
|
||||
eager?: boolean;
|
||||
coverVariable?: boolean;
|
||||
}
|
||||
export function BackgroundImage({
|
||||
src,
|
||||
fallback,
|
||||
eager = false,
|
||||
coverVariable = false,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: BackgroundImageProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const image = useEncryptedSource(src, eager, ref, fallback);
|
||||
const imageStyle = {
|
||||
...style,
|
||||
[coverVariable ? "--cover" : "backgroundImage"]:
|
||||
`url(${JSON.stringify(image.value)})`,
|
||||
} as CSSProperties;
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={ref}
|
||||
style={imageStyle}
|
||||
data-decrypt-failed={image.failed ? "1" : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export interface EncryptedBackgroundProps
|
||||
extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
as?: "a" | "div";
|
||||
src?: string;
|
||||
fallback?: string;
|
||||
eager?: boolean;
|
||||
coverVariable?: boolean;
|
||||
}
|
||||
export function EncryptedBackground({
|
||||
as = "div",
|
||||
src,
|
||||
fallback,
|
||||
eager = false,
|
||||
coverVariable = true,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: EncryptedBackgroundProps) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const image = useEncryptedSource(src, eager, ref, fallback);
|
||||
const imageStyle = {
|
||||
...style,
|
||||
[coverVariable ? "--cover" : "backgroundImage"]:
|
||||
`url(${JSON.stringify(image.value)})`,
|
||||
} as CSSProperties;
|
||||
return createElement(
|
||||
as,
|
||||
{
|
||||
...props,
|
||||
ref,
|
||||
style: imageStyle,
|
||||
"data-decrypt-failed": image.failed ? "1" : undefined,
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
export default EncryptedImage;
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
const ImageCDNs = createContext<readonly string[]>([]);
|
||||
export const useImageCDNs = () => useContext(ImageCDNs);
|
||||
|
||||
export function ImageProvider({ bases, children }: { bases: readonly string[]; children: ReactNode }) {
|
||||
return <ImageCDNs.Provider value={bases}>{children}</ImageCDNs.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type Player from "xgplayer";
|
||||
import {
|
||||
isHLSURL,
|
||||
nativeHLS,
|
||||
resolvePlayURL,
|
||||
SHELL_BROWSER,
|
||||
} from "@/lib/media/playback";
|
||||
import { bindPlaybackAnalytics } from "./usePlaybackAnalytics";
|
||||
import "xgplayer/dist/index.min.css";
|
||||
import "@/styles/components/video-player.css";
|
||||
export interface PlayerHandle {
|
||||
play(): void;
|
||||
pause(): void;
|
||||
video(): HTMLVideoElement | null;
|
||||
}
|
||||
export interface VideoPlayerProps {
|
||||
url: string;
|
||||
trackDrama?: boolean;
|
||||
onPlaying?: () => void;
|
||||
onEnded?: () => void;
|
||||
autoPlay?: boolean;
|
||||
}
|
||||
export const VideoPlayer = forwardRef<PlayerHandle, VideoPlayerProps>(
|
||||
function VideoPlayer(
|
||||
{ url, trackDrama = false, onPlaying, onEnded, autoPlay = false },
|
||||
ref,
|
||||
) {
|
||||
const host = useRef<HTMLDivElement>(null),
|
||||
player = useRef<Player | null>(null),
|
||||
pendingPlay = useRef(false),
|
||||
callbacks = useRef({ onPlaying, onEnded });
|
||||
const [error, setError] = useState(""),
|
||||
[retry, setRetry] = useState(0);
|
||||
callbacks.current = { onPlaying, onEnded };
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
play() {
|
||||
if (!player.current) pendingPlay.current = true;
|
||||
void player.current?.play()?.catch(() => undefined);
|
||||
},
|
||||
pause() {
|
||||
player.current?.pause();
|
||||
},
|
||||
video() {
|
||||
return (player.current?.video as HTMLVideoElement) || null;
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
let disposed = false,
|
||||
instance: Player | null = null,
|
||||
unbind = () => undefined as void;
|
||||
setError("");
|
||||
void Promise.all([import("xgplayer"), import("xgplayer-hls")])
|
||||
.then(([{ default: XGPlayer }, { default: HlsPlugin }]) => {
|
||||
if (disposed || !host.current) return;
|
||||
const source = resolvePlayURL(url),
|
||||
probe = document.createElement("video");
|
||||
const usePlugin =
|
||||
isHLSURL(source) && !nativeHLS(probe) && HlsPlugin.isSupported();
|
||||
instance = new XGPlayer({
|
||||
el: host.current,
|
||||
url: source,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
autoplay: autoPlay,
|
||||
playsinline: true,
|
||||
lang: "zh-cn",
|
||||
closeVideoClick: false,
|
||||
closeVideoDblclick: true,
|
||||
playbackRate: [0.5, 0.75, 1, 1.25, 1.5, 2],
|
||||
defaultPlaybackRate: 1,
|
||||
download: false,
|
||||
plugins: usePlugin ? [HlsPlugin] : [],
|
||||
hls: {
|
||||
retryCount: 3,
|
||||
retryDelay: 1000,
|
||||
loadTimeout: 15000,
|
||||
fetchOptions: { mode: "cors", credentials: "same-origin" },
|
||||
},
|
||||
videoAttributes: {
|
||||
playsinline: true,
|
||||
"webkit-playsinline": true,
|
||||
"x5-playsinline": true,
|
||||
"x5-video-player-type": "h5-page",
|
||||
"x-webkit-airplay": "allow",
|
||||
preload: "metadata",
|
||||
},
|
||||
fullscreen: {
|
||||
useCssFullscreen: () => {
|
||||
const nativeFullscreen = document.fullscreenEnabled ||
|
||||
(document as Document & { webkitFullscreenEnabled?: boolean }).webkitFullscreenEnabled;
|
||||
return SHELL_BROWSER.test(navigator.userAgent) ||
|
||||
(!nativeFullscreen && document.documentElement.classList.contains("hk-pwa-standalone"));
|
||||
},
|
||||
},
|
||||
cssFullscreen: false,
|
||||
});
|
||||
player.current = instance;
|
||||
if (pendingPlay.current) {
|
||||
pendingPlay.current = false;
|
||||
void instance.play()?.catch(() => undefined);
|
||||
}
|
||||
const video = instance.video as HTMLVideoElement;
|
||||
unbind = bindPlaybackAnalytics(video, trackDrama);
|
||||
video.addEventListener("playing", () => {
|
||||
setError("");
|
||||
callbacks.current.onPlaying?.();
|
||||
});
|
||||
video.addEventListener("ended", () => callbacks.current.onEnded?.());
|
||||
instance.on("error", () => setError("播放失败,请重试"));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed) setError("播放器加载失败,请重试");
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
unbind();
|
||||
instance?.destroy();
|
||||
if (player.current === instance) player.current = null;
|
||||
};
|
||||
}, [url, trackDrama, autoPlay, retry]);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="hk-video-player"
|
||||
ref={host}
|
||||
data-player-engine="xgplayer"
|
||||
/>
|
||||
{error && (
|
||||
<div className="hk-video-player__error" role="alert">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="hk-button hk-button--primary"
|
||||
onClick={() => {
|
||||
pendingPlay.current = true;
|
||||
setRetry((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
dramaEvent,
|
||||
setDramaContext,
|
||||
tracker,
|
||||
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]);
|
||||
}
|
||||
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);
|
||||
}
|
||||
/** Count wall-clock playing time, excluding buffering and seeking, as the original SDK expects. */
|
||||
export function bindPlaybackAnalytics(
|
||||
video: HTMLVideoElement,
|
||||
enabled: boolean,
|
||||
): () => void {
|
||||
if (!enabled) return () => undefined;
|
||||
let started = 0,
|
||||
elapsed = 0,
|
||||
position = 0,
|
||||
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 emit = (behavior: string, keep = false, forceProgress?: number) => {
|
||||
const played =
|
||||
(elapsed + (started ? Math.max(0, Date.now() - started) : 0)) / 1000;
|
||||
void dramaEvent(behavior, 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);
|
||||
},
|
||||
};
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Data } from "@/server/services/model";
|
||||
import { useDialog, useScrollLock } from "@/components/ui/Dialog";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import { EncryptedImage } from "@/components/media/EncryptedImage";
|
||||
import { appDownloadURL } from "@/lib/app-download";
|
||||
|
||||
export function AgeGate({ onAccepted }: { onAccepted: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
let accepted = false;
|
||||
try {
|
||||
accepted = localStorage.getItem("hg_age_ok") === "1";
|
||||
} catch {}
|
||||
if (accepted) {
|
||||
document.documentElement.classList.add("hk-age-gate-ok");
|
||||
onAccepted();
|
||||
} else setOpen(true);
|
||||
}, [onAccepted]);
|
||||
const ref = useDialog(() => {}, open);
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle("hk-age-gate-locked", open);
|
||||
return () => document.body.classList.remove("hk-age-gate-locked");
|
||||
}, [open]);
|
||||
if (!open) return null;
|
||||
const accept = () => {
|
||||
try {
|
||||
localStorage.setItem("hg_age_ok", "1");
|
||||
} catch {}
|
||||
document.documentElement.classList.add("hk-age-gate-ok");
|
||||
setOpen(false);
|
||||
document.dispatchEvent(new CustomEvent("eighteen:closed"));
|
||||
onAccepted();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="hk-age-gate hk-is-open"
|
||||
data-eighteen=""
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="eighteen-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="hk-age-gate__mask" aria-hidden="true" />
|
||||
<div className="hk-age-gate__dialog">
|
||||
<div className="hk-age-gate__badge" aria-hidden="true">
|
||||
18+
|
||||
</div>
|
||||
<h2 className="hk-age-gate__title" id="eighteen-title">
|
||||
年龄确认
|
||||
</h2>
|
||||
<p className="hk-age-gate__desc">
|
||||
本网站部分内容仅适合成年人观看。点击继续即表示您确认您已年满 18
|
||||
岁,或在您访问本网站时所在的司法管辖区已是成年人。
|
||||
</p>
|
||||
<button
|
||||
className="hk-button hk-button--primary hk-age-gate__enter"
|
||||
type="button"
|
||||
data-eighteen-enter=""
|
||||
onClick={accept}
|
||||
>
|
||||
我已年满 18 岁,进入
|
||||
</button>
|
||||
<button
|
||||
className="hk-age-gate__exit"
|
||||
type="button"
|
||||
data-eighteen-exit=""
|
||||
onClick={() => location.assign("https://www.google.com/")}
|
||||
>
|
||||
我未满 18 岁,退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function DownloadDialog({
|
||||
data,
|
||||
onClose,
|
||||
}: {
|
||||
data: Data;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [mobile, setMobile] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" && matchMedia("(max-width:768px)").matches,
|
||||
);
|
||||
const [platform, setPlatform] = useState<"ios" | "android" | "other">(
|
||||
"other",
|
||||
),
|
||||
[tab, setTab] = useState<"store" | "profile">("store");
|
||||
const ref = useDialog(onClose),
|
||||
guide = data.installGuide || {},
|
||||
fallback =
|
||||
guide.fallbackURL ||
|
||||
appDownloadURL(data.appConfig) ||
|
||||
appDownloadURL(data.config) ||
|
||||
"/app/download/";
|
||||
useEffect(() => {
|
||||
const media = matchMedia("(max-width:768px)"),
|
||||
resize = () => {
|
||||
if (mobile && !media.matches) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setMobile(media.matches);
|
||||
};
|
||||
media.addEventListener("change", resize);
|
||||
setPlatform(
|
||||
/android/i.test(navigator.userAgent)
|
||||
? "android"
|
||||
: /iPhone|iPad|iPod/i.test(navigator.userAgent) ||
|
||||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
|
||||
? "ios"
|
||||
: "other",
|
||||
);
|
||||
return () => media.removeEventListener("change", resize);
|
||||
}, [mobile, onClose]);
|
||||
if (!mobile)
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="hk-app-download-dialog hk-is-open"
|
||||
data-download-pop=""
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="download-pop-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
className="hk-app-download-dialog__mask"
|
||||
data-download-close=""
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="hk-app-download-dialog__panel">
|
||||
<button
|
||||
className="hk-app-download-dialog__close"
|
||||
type="button"
|
||||
data-download-close=""
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
<div className="hk-app-download-dialog__copy">
|
||||
<h2 className="hk-app-download-dialog__title" id="download-pop-title">
|
||||
下载花魁短剧 App
|
||||
</h2>
|
||||
<p className="hk-app-download-dialog__desc">
|
||||
点击后前往下载页,手机打开会自动识别设备并推荐安装方式
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="hk-app-download-dialog__download"
|
||||
href={fallback}
|
||||
target="_blank"
|
||||
rel="sponsored nofollow noopener"
|
||||
>
|
||||
<Icon name="download" className="hk-app-download-dialog__download-icon" />
|
||||
点击下载
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const panel = platform === "android" ? "android" : tab;
|
||||
const description =
|
||||
platform === "ios"
|
||||
? guide.hasIOSStore || guide.hasIOSProfile
|
||||
? "已识别为 iOS 设备,推荐商店版,也可选轻量版"
|
||||
: "已识别为 iOS 设备,可前往官方下载页查看安装方式"
|
||||
: platform === "android"
|
||||
? guide.hasAndroidAPK
|
||||
? "已识别为 Android 设备,下载 APK 直接安装"
|
||||
: "已识别为 Android 设备,可前往官方下载页查看安装方式"
|
||||
: "请选择适合当前设备的安装方式";
|
||||
const notice = (text: string) => (
|
||||
<p className="hk-installation-guide__notice">
|
||||
<Icon name="install/notice" size={14} />
|
||||
<span>{text}</span>
|
||||
</p>
|
||||
);
|
||||
const action = (
|
||||
kind: "store" | "profile" | "android",
|
||||
href: string,
|
||||
label: string,
|
||||
icon: string,
|
||||
recommended = false,
|
||||
) => (
|
||||
<a
|
||||
className="hk-installation-guide__action"
|
||||
data-install-action={kind}
|
||||
data-navigation-key={"install_" + kind}
|
||||
data-navigation-name={label}
|
||||
href={href || fallback}
|
||||
target="_blank"
|
||||
rel="sponsored nofollow noopener"
|
||||
>
|
||||
<Icon name={icon} size={20} />
|
||||
<span>{label}</span>
|
||||
{recommended && <span className="hk-installation-guide__action-badge">推荐</span>}
|
||||
</a>
|
||||
);
|
||||
const steps = (items: [string, string][]) => (
|
||||
<ol className="hk-installation-guide__steps">
|
||||
{items.map(([title, desc], index) => (
|
||||
<li className="hk-installation-guide__step" key={title}>
|
||||
<span className="hk-installation-guide__badge">{index + 1}</span>
|
||||
<div className="hk-installation-guide__step-copy">
|
||||
<h3>{title}</h3>
|
||||
<p>{desc}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="hk-installation-guide hk-is-open"
|
||||
data-install-guide=""
|
||||
data-platform={platform}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="install-guide-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
className="hk-installation-guide__mask"
|
||||
data-install-close=""
|
||||
data-download-close=""
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<section className="hk-installation-guide__sheet">
|
||||
<div className="hk-installation-guide__handle" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
<header className="hk-installation-guide__head">
|
||||
<div className="hk-installation-guide__heading">
|
||||
<h2 id="install-guide-title">下载花魁短剧 App</h2>
|
||||
<p id="install-guide-description" data-install-description="">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="hk-installation-guide__close"
|
||||
type="button"
|
||||
data-install-close=""
|
||||
onClick={onClose}
|
||||
aria-label="关闭安装引导"
|
||||
>
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
</header>
|
||||
{platform !== "android" && (
|
||||
<div
|
||||
className="hk-installation-guide__tabs"
|
||||
role="tablist"
|
||||
aria-label="iOS 安装方式"
|
||||
data-install-tabs=""
|
||||
>
|
||||
{(["store", "profile"] as const).map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
id={"install-guide-" + kind + "-tab"}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === kind}
|
||||
aria-controls={"install-guide-" + kind}
|
||||
tabIndex={tab === kind ? 0 : -1}
|
||||
data-install-tab={kind}
|
||||
onClick={() => setTab(kind)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
["ArrowLeft", "ArrowRight", "Home", "End"].includes(
|
||||
event.key,
|
||||
)
|
||||
) {
|
||||
event.preventDefault();
|
||||
const next =
|
||||
event.key === "Home"
|
||||
? "store"
|
||||
: event.key === "End"
|
||||
? "profile"
|
||||
: tab === "store"
|
||||
? "profile"
|
||||
: "store";
|
||||
setTab(next);
|
||||
document
|
||||
.getElementById("install-guide-" + next + "-tab")
|
||||
?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{kind === "store" ? "商店版" : "轻量版"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="hk-installation-guide__content"
|
||||
id="install-guide-store"
|
||||
role="tabpanel"
|
||||
aria-labelledby="install-guide-store-tab"
|
||||
data-install-panel="store"
|
||||
hidden={panel !== "store"}
|
||||
>
|
||||
<div className="hk-installation-guide__store-card">
|
||||
<span className="hk-installation-guide__store-icon">
|
||||
<Icon name="brand-apple" size={20} />
|
||||
</span>
|
||||
<div className="hk-installation-guide__store-copy">
|
||||
<div className="hk-installation-guide__store-title">
|
||||
花魁短剧 App Store 版
|
||||
{guide.hasIOSStore && (
|
||||
<span className="hk-installation-guide__recommended">推荐</span>
|
||||
)}
|
||||
</div>
|
||||
<p>
|
||||
{guide.hasIOSStore
|
||||
? "通过 App Store 安装,随系统自动更新,与轻量版账号内容互通"
|
||||
: "前往官方下载页,查看商店版是否可用及当前设备的安装方式"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hk-installation-guide__step hk-installation-guide__store-tip">
|
||||
<span className="hk-installation-guide__badge hk-installation-guide__badge--tip">
|
||||
提示
|
||||
</span>
|
||||
<div className="hk-installation-guide__step-copy">
|
||||
<h3>所在地区未上架时</h3>
|
||||
<p>请切换到轻量版,用 Safari 安装描述文件</p>
|
||||
</div>
|
||||
</div>
|
||||
{notice("需登录 App Store 账号;若显示「不可用」,请返回选择轻量版")}
|
||||
{action(
|
||||
"store",
|
||||
guide.iosStoreURL,
|
||||
guide.hasIOSStore ? "前往 App Store" : "前往下载页",
|
||||
"install/external-link",
|
||||
guide.hasIOSStore,
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="hk-installation-guide__content"
|
||||
id="install-guide-profile"
|
||||
role="tabpanel"
|
||||
aria-labelledby="install-guide-profile-tab"
|
||||
data-install-panel="profile"
|
||||
hidden={panel !== "profile"}
|
||||
>
|
||||
{steps([
|
||||
[
|
||||
guide.hasIOSProfile ? "允许下载描述文件" : "前往官方下载页",
|
||||
guide.hasIOSProfile
|
||||
? "用 Safari 打开本页,点击「安装轻量版」,弹窗选择「允许」"
|
||||
: "用 Safari 打开本页,点击「前往下载页」,查看轻量版是否可用及安装说明",
|
||||
],
|
||||
[
|
||||
"安装描述文件",
|
||||
"前往 设置 / 已下载描述文件,选择「安装」并输入密码",
|
||||
],
|
||||
[
|
||||
"返回桌面打开",
|
||||
"桌面出现花魁短剧图标即安装完成,建议保存账号凭证",
|
||||
],
|
||||
])}
|
||||
{notice(
|
||||
"仅支持 Safari;若在微信 / Telegram 内打开,请先点右上角「在浏览器中打开」",
|
||||
)}
|
||||
{action(
|
||||
"profile",
|
||||
guide.iosProfileURL,
|
||||
guide.hasIOSProfile ? "安装轻量版" : "前往下载页",
|
||||
"brand-apple",
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="hk-installation-guide__content"
|
||||
id="install-guide-android"
|
||||
data-install-panel="android"
|
||||
hidden={panel !== "android"}
|
||||
>
|
||||
{steps([
|
||||
[
|
||||
guide.hasAndroidAPK ? "下载 APK 安装包" : "前往官方下载页",
|
||||
guide.hasAndroidAPK
|
||||
? "点击「下载 APK」,下载完成后在通知栏或下载列表点击打开"
|
||||
: "点击「前往下载页」,查看 Android 安装包及适用于当前设备的安装说明",
|
||||
],
|
||||
[
|
||||
"允许安装未知来源",
|
||||
"系统提示「禁止安装来源不明的应用」时,点击「设置」开启允许",
|
||||
],
|
||||
[
|
||||
"打开花魁短剧",
|
||||
"安装完成后点击「打开」,或在桌面找到花魁短剧图标",
|
||||
],
|
||||
])}
|
||||
{notice("如系统提示风险,请核实安装包来源后再继续")}
|
||||
{action(
|
||||
"android",
|
||||
guide.androidAPKURL,
|
||||
guide.hasAndroidAPK ? "下载 APK" : "前往下载页",
|
||||
"brand-android",
|
||||
)}
|
||||
</div>
|
||||
{guide.deepLinkURL && (
|
||||
<button
|
||||
className="hk-installation-guide__launch"
|
||||
type="button"
|
||||
data-install-launch=""
|
||||
onClick={() => location.assign(guide.deepLinkURL)}
|
||||
>
|
||||
已安装?打开花魁短剧 App
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface Ad {
|
||||
id?: string;
|
||||
advertiseCode?: string;
|
||||
images?: string;
|
||||
link?: string;
|
||||
title?: string;
|
||||
location?: string;
|
||||
advertiseLocationCode?: string;
|
||||
advertiseLocationName?: string;
|
||||
}
|
||||
function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const ref = useDialog(onClose, ready);
|
||||
const id = ad.advertiseCode || ad.id || "";
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, 12000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose]);
|
||||
useEffect(() => {
|
||||
if (ready && ref.current)
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("external-ad:track", {
|
||||
detail: {
|
||||
element: ref.current.querySelector("[data-track-ad]"),
|
||||
eventType: "show",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [ready, ref]);
|
||||
const image = (
|
||||
<EncryptedImage
|
||||
eager
|
||||
className="hk-advertisement-dialog-image"
|
||||
src={ad.images || ""}
|
||||
alt={ad.title || ""}
|
||||
onResolved={() => setReady(true)}
|
||||
onResolveError={onClose}
|
||||
onError={onClose}
|
||||
/>
|
||||
);
|
||||
const click = () =>
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("external-ad:track", {
|
||||
detail: {
|
||||
element: ref.current?.querySelector("[data-track-ad]"),
|
||||
eventType: "click",
|
||||
},
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={"hk-advertisement-dialog-mask" + (ready ? " hk-is-open" : "")}
|
||||
data-dialog-ad=""
|
||||
style={
|
||||
!ready
|
||||
? { display: "flex", visibility: "hidden", pointerEvents: "none" }
|
||||
: undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="hk-advertisement-dialog-panel"
|
||||
data-dialog-ad-item=""
|
||||
data-dialog-ad-id={id}
|
||||
data-track-ad="external-dialog"
|
||||
data-ad-id={id}
|
||||
data-creative-id=""
|
||||
data-ad-location={ad.location}
|
||||
data-ad-slot-key={ad.advertiseLocationCode || ad.location || ""}
|
||||
data-ad-slot-name={
|
||||
ad.advertiseLocationName || "弹窗广告位" + (ad.location || "")
|
||||
}
|
||||
data-ad-type="interstitial"
|
||||
data-ad-clickable={ad.link ? "1" : undefined}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ad.title || "广告"}
|
||||
>
|
||||
<div className="hk-advertisement-dialog-card">
|
||||
<div className="hk-advertisement-dialog-cover">
|
||||
{ad.link ? (
|
||||
<a
|
||||
className="hk-advertisement-dialog-media"
|
||||
href={ad.link}
|
||||
target="_blank"
|
||||
rel="sponsored nofollow noopener"
|
||||
data-dialog-ad-link=""
|
||||
onClick={click}
|
||||
>
|
||||
{image}
|
||||
</a>
|
||||
) : (
|
||||
<div className="hk-advertisement-dialog-media">{image}</div>
|
||||
)}
|
||||
<span className="hk-tag hk-tag--dark hk-advertisement-dialog-tag">广告</span>
|
||||
<button
|
||||
className="hk-advertisement-dialog-close"
|
||||
type="button"
|
||||
data-dialog-ad-close=""
|
||||
onClick={onClose}
|
||||
aria-label="关闭广告"
|
||||
>
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="hk-advertisement-dialog-foot">
|
||||
<div className="hk-advertisement-dialog-copy">
|
||||
<p className="hk-advertisement-dialog-title">{ad.title || "精彩推荐"}</p>
|
||||
<p className="hk-advertisement-dialog-desc">每日展示一次</p>
|
||||
</div>
|
||||
{ad.link && (
|
||||
<a
|
||||
className="hk-advertisement-dialog-cta"
|
||||
href={ad.link}
|
||||
target="_blank"
|
||||
rel="sponsored nofollow noopener"
|
||||
data-dialog-ad-link=""
|
||||
onClick={click}
|
||||
>
|
||||
<Icon
|
||||
name="play_hollow"
|
||||
size={18}
|
||||
className="hk-advertisement-dialog-cta__icon"
|
||||
/>
|
||||
立即观看
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function AdvertisementQueue({
|
||||
data,
|
||||
enabled,
|
||||
}: {
|
||||
data: Data;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const ads: Ad[] = data.appConfig?.dialogList || [];
|
||||
const signature = "2|" + ads.map((ad) => ad.advertiseCode || ad.id).join("|");
|
||||
const [index, setIndex] = useState(-1);
|
||||
useEffect(() => {
|
||||
if (!enabled || !ads.length) return;
|
||||
try {
|
||||
if (sessionStorage.getItem("dialogAdDismissed") === signature) return;
|
||||
const saved = JSON.parse(
|
||||
sessionStorage.getItem("dialogAdProgress") || "null",
|
||||
);
|
||||
setIndex(saved?.signature === signature ? saved.index : 0);
|
||||
} catch {
|
||||
setIndex(0);
|
||||
}
|
||||
}, [enabled, signature, ads.length]);
|
||||
const close = useRef(() => {});
|
||||
close.current = () => {
|
||||
const next = index + 1;
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"dialogAdProgress",
|
||||
JSON.stringify({ signature, index: next }),
|
||||
);
|
||||
if (next >= ads.length)
|
||||
sessionStorage.setItem("dialogAdDismissed", signature);
|
||||
} catch {}
|
||||
setIndex(next);
|
||||
};
|
||||
const stableClose = useRef(() => close.current()).current;
|
||||
if (!enabled || index < 0 || index >= ads.length) return null;
|
||||
return <AdItem key={index} ad={ads[index]} onClose={stableClose} />;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Data } from "@/server/services/model";
|
||||
import { getCookie, getDeviceID, getChannel } from "@/lib/api";
|
||||
import { AgeGate, DownloadDialog, AdvertisementQueue } from "./SiteDialogs";
|
||||
import { ImageProvider } from "@/components/media/ImageProvider";
|
||||
import { imageCDNBases, validProfileImage } from "@/lib/media/image-source";
|
||||
export interface SiteUser {
|
||||
userId?: number | string;
|
||||
nickName?: string;
|
||||
accountNo?: string;
|
||||
avatar?: string;
|
||||
email?: string;
|
||||
recharge?: number;
|
||||
isRegister?: boolean | number;
|
||||
type?: number;
|
||||
hasPassword?: boolean;
|
||||
privacyMode?: boolean;
|
||||
introduction?: string;
|
||||
}
|
||||
interface SiteContext {
|
||||
user: SiteUser | null;
|
||||
openDownload: () => void;
|
||||
notify: (message: string) => void;
|
||||
}
|
||||
const Context = createContext<SiteContext | null>(null);
|
||||
export function useSite() {
|
||||
const value = useContext(Context);
|
||||
if (!value) throw new Error("SiteProvider is required");
|
||||
return value;
|
||||
}
|
||||
function displayUser(user: SiteUser | null): SiteUser | null {
|
||||
if (!user) return null;
|
||||
const avatar = String(user.avatar || "").trim();
|
||||
return {
|
||||
...user,
|
||||
isRegister:
|
||||
user.isRegister === true || user.isRegister === 1 || user.type === 1,
|
||||
avatar: validProfileImage(avatar),
|
||||
};
|
||||
}
|
||||
function serverUser(data: Data): SiteUser | null {
|
||||
return displayUser(
|
||||
data.isLoggedIn
|
||||
? {
|
||||
userId: data.userID || data.userId,
|
||||
nickName: data.userName,
|
||||
avatar: data.avatar,
|
||||
recharge: data.recharge,
|
||||
isRegister: data.isRegistered,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
function cookieUser(): SiteUser | null {
|
||||
if (!getCookie("token") && !getCookie("_token_")) return null;
|
||||
return {
|
||||
userId: getCookie("userId"),
|
||||
nickName: getCookie("nickName"),
|
||||
accountNo: getCookie("accountNo"),
|
||||
avatar: getCookie("avatar"),
|
||||
email: getCookie("email"),
|
||||
recharge: Number(getCookie("recharge") || 0),
|
||||
isRegister: getCookie("isRegister") === "1",
|
||||
type: Number(getCookie("userType") || 0),
|
||||
};
|
||||
}
|
||||
export function SiteProvider({
|
||||
data,
|
||||
children,
|
||||
dialogs = true,
|
||||
}: {
|
||||
data: Data;
|
||||
children: ReactNode;
|
||||
dialogs?: boolean;
|
||||
}) {
|
||||
const imageBasesKey = JSON.stringify(
|
||||
data.imageCDNs || imageCDNBases(data.config?.imgCdn, data.config?.imgAppUsCdn),
|
||||
);
|
||||
const imageBases = useMemo<readonly string[]>(
|
||||
() => JSON.parse(imageBasesKey),
|
||||
[imageBasesKey],
|
||||
);
|
||||
const [user, setUser] = useState<SiteUser | null>(() => serverUser(data));
|
||||
const snapshot = JSON.stringify([
|
||||
!!data.isLoggedIn,
|
||||
data.userID || data.userId,
|
||||
data.userName,
|
||||
data.avatar,
|
||||
data.recharge,
|
||||
!!data.isRegistered,
|
||||
]),
|
||||
previousSnapshot = useRef(snapshot);
|
||||
useEffect(() => {
|
||||
if (previousSnapshot.current !== snapshot) {
|
||||
previousSnapshot.current = snapshot;
|
||||
setUser(serverUser(data));
|
||||
}
|
||||
}, [snapshot, data]);
|
||||
const [modal, setModal] = useState<"download" | null>(null),
|
||||
[toast, setToast] = useState(""),
|
||||
[ageAccepted, setAgeAccepted] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const notify = useCallback((message: string) => {
|
||||
setToast(message);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setToast(""), 2600);
|
||||
}, []);
|
||||
const openDownload = useCallback(() => setModal("download"), []),
|
||||
acceptAge = useCallback(() => setAgeAccepted(true), []);
|
||||
useEffect(() => {
|
||||
getDeviceID();
|
||||
getChannel();
|
||||
const login = () => setUser(displayUser(cookieUser())),
|
||||
logout = () => setUser(null);
|
||||
document.addEventListener("auth:login", login);
|
||||
document.addEventListener("auth:logout", logout);
|
||||
return () => {
|
||||
document.removeEventListener("auth:login", login);
|
||||
document.removeEventListener("auth:logout", logout);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
};
|
||||
}, []);
|
||||
const value = useMemo(
|
||||
() => ({ user, openDownload, notify }),
|
||||
[user, openDownload, notify],
|
||||
);
|
||||
return (
|
||||
<ImageProvider bases={imageBases}>
|
||||
<Context.Provider value={value}>
|
||||
{children}
|
||||
{dialogs && (
|
||||
<>
|
||||
<AgeGate onAccepted={acceptAge} />
|
||||
<AdvertisementQueue
|
||||
data={data}
|
||||
enabled={ageAccepted && modal === null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{modal === "download" && (
|
||||
<DownloadDialog data={data} onClose={() => setModal(null)} />
|
||||
)}
|
||||
{toast && (
|
||||
<div
|
||||
className="hk-site-toast hk-is-visible"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-toast=""
|
||||
>
|
||||
<div className="hk-site-toast__panel">
|
||||
<p className="hk-site-toast__text">{toast}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Context.Provider>
|
||||
</ImageProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { pwaConfig } from "@/lib/pwa/config";
|
||||
|
||||
/** No install popups or navigation changes; the browser owns installation. */
|
||||
export function PwaProvider() {
|
||||
useEffect(() => {
|
||||
const modes = [
|
||||
window.matchMedia("(display-mode: fullscreen)"),
|
||||
window.matchMedia("(display-mode: standalone)"),
|
||||
];
|
||||
const updateDisplayMode = () => {
|
||||
const iosStandalone = (navigator as Navigator & { standalone?: boolean }).standalone;
|
||||
document.documentElement.classList.toggle(
|
||||
"hk-pwa-standalone",
|
||||
iosStandalone === true || modes.some((mode) => mode.matches),
|
||||
);
|
||||
};
|
||||
updateDisplayMode();
|
||||
for (const mode of modes) mode.addEventListener("change", updateDisplayMode);
|
||||
|
||||
if (window.isSecureContext && "serviceWorker" in navigator) {
|
||||
// A failed registration must never block page, authentication or playback.
|
||||
void navigator.serviceWorker.register(pwaConfig.worker, {
|
||||
scope: pwaConfig.scope,
|
||||
updateViaCache: "none",
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
return () => {
|
||||
for (const mode of modes) mode.removeEventListener("change", updateDisplayMode);
|
||||
document.documentElement.classList.remove("hk-pwa-standalone");
|
||||
};
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
||||
let locks = 0;
|
||||
let savedOverflow = "";
|
||||
export function useScrollLock(active = true) {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
if (locks++ === 0) {
|
||||
savedOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
return () => {
|
||||
if (--locks === 0) document.body.style.overflow = savedOverflow;
|
||||
};
|
||||
}, [active]);
|
||||
}
|
||||
interface Layer {
|
||||
element: HTMLElement;
|
||||
close: RefObject<() => void>;
|
||||
prior: HTMLElement | null;
|
||||
parent: Layer | undefined;
|
||||
order: number;
|
||||
}
|
||||
const layers = new Set<Layer>();
|
||||
let order = 0,
|
||||
current: Layer | undefined,
|
||||
movingFocus = false;
|
||||
const visible = (element: HTMLElement) =>
|
||||
element.isConnected &&
|
||||
!!element.getClientRects().length &&
|
||||
getComputedStyle(element).visibility !== "hidden";
|
||||
const zIndex = (element: HTMLElement) => {
|
||||
for (
|
||||
let node: HTMLElement | null = element;
|
||||
node;
|
||||
node = node.parentElement
|
||||
) {
|
||||
const value = Number(getComputedStyle(node).zIndex);
|
||||
if (Number.isFinite(value) && getComputedStyle(node).zIndex !== "auto")
|
||||
return value;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
function topLayer(): Layer | undefined {
|
||||
return [...layers]
|
||||
.filter((layer) => visible(layer.element))
|
||||
.sort(
|
||||
(a, b) => zIndex(b.element) - zIndex(a.element) || b.order - a.order,
|
||||
)[0];
|
||||
}
|
||||
function focusable(layer: Layer) {
|
||||
return [
|
||||
...layer.element.querySelectorAll<HTMLElement>(
|
||||
"button:not(:disabled),a[href],input:not(:disabled),select:not(:disabled),textarea:not(:disabled),[tabindex]",
|
||||
),
|
||||
].filter(
|
||||
(element) =>
|
||||
element.tabIndex >= 0 && visible(element) && !element.closest("[inert]"),
|
||||
);
|
||||
}
|
||||
function focus(layer: Layer, preferred?: HTMLElement | null) {
|
||||
if (movingFocus) return;
|
||||
movingFocus = true;
|
||||
const target =
|
||||
preferred && visible(preferred) && layer.element.contains(preferred)
|
||||
? preferred
|
||||
: focusable(layer)[0] || layer.element;
|
||||
if (target === layer.element && !target.hasAttribute("tabindex"))
|
||||
target.tabIndex = -1;
|
||||
target.focus({ preventScroll: true });
|
||||
movingFocus = false;
|
||||
}
|
||||
function restoreTarget(layer: Layer): HTMLElement | null {
|
||||
for (
|
||||
let candidate: Layer | undefined = layer;
|
||||
candidate;
|
||||
candidate = candidate.parent
|
||||
)
|
||||
if (candidate.prior?.isConnected && visible(candidate.prior))
|
||||
return candidate.prior;
|
||||
return null;
|
||||
}
|
||||
function syncTop(preferred?: HTMLElement | null) {
|
||||
const top = topLayer();
|
||||
current = top;
|
||||
if (top && (!top.element.contains(document.activeElement) || preferred))
|
||||
focus(top, preferred);
|
||||
}
|
||||
function keydown(event: KeyboardEvent) {
|
||||
const top = topLayer();
|
||||
if (!top) return;
|
||||
current = top;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
top.close.current();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const items = focusable(top),
|
||||
first = items[0],
|
||||
last = items.at(-1),
|
||||
outside = !top.element.contains(document.activeElement);
|
||||
if (!first) {
|
||||
event.preventDefault();
|
||||
focus(top);
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey && (document.activeElement === first || outside)) {
|
||||
event.preventDefault();
|
||||
focus(top, last);
|
||||
} else if (!event.shiftKey && (document.activeElement === last || outside)) {
|
||||
event.preventDefault();
|
||||
focus(top, first);
|
||||
}
|
||||
}
|
||||
function focusin() {
|
||||
if (!movingFocus) syncTop();
|
||||
}
|
||||
/** One keyboard/focus owner follows the highest visible layer, including the age gate. */
|
||||
export function useDialog(onClose: () => void, active = true) {
|
||||
const ref = useRef<HTMLDivElement>(null),
|
||||
close = useRef(onClose);
|
||||
close.current = onClose;
|
||||
useScrollLock(active);
|
||||
useEffect(() => {
|
||||
if (!active || !ref.current) return;
|
||||
const layer: Layer = {
|
||||
element: ref.current,
|
||||
close,
|
||||
prior: document.activeElement as HTMLElement | null,
|
||||
parent: current,
|
||||
order: ++order,
|
||||
};
|
||||
if (!layers.size) {
|
||||
document.addEventListener("keydown", keydown, true);
|
||||
document.addEventListener("focusin", focusin, true);
|
||||
}
|
||||
layers.add(layer);
|
||||
syncTop();
|
||||
return () => {
|
||||
const wasTop = current === layer;
|
||||
layers.delete(layer);
|
||||
if (wasTop) {
|
||||
const target = restoreTarget(layer);
|
||||
current = topLayer();
|
||||
if (current) focus(current, target);
|
||||
else target?.focus({ preventScroll: true });
|
||||
} else syncTop();
|
||||
if (!layers.size) {
|
||||
current = undefined;
|
||||
document.removeEventListener("keydown", keydown, true);
|
||||
document.removeEventListener("focusin", focusin, true);
|
||||
}
|
||||
};
|
||||
}, [active]);
|
||||
return ref;
|
||||
}
|
||||
export function Dialog({
|
||||
children,
|
||||
onClose,
|
||||
className,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
label: string;
|
||||
}) {
|
||||
const ref = useDialog(onClose);
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ImgHTMLAttributes } from "react";
|
||||
export interface IconProps
|
||||
extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> {
|
||||
name: string;
|
||||
size?: number;
|
||||
}
|
||||
export function Icon({
|
||||
name,
|
||||
size,
|
||||
className = "icon",
|
||||
alt = "",
|
||||
...props
|
||||
}: IconProps) {
|
||||
const src = name.startsWith("/")
|
||||
? name
|
||||
: "/static/images/" + name + (/\.(svg|png|webp)$/.test(name) ? "" : ".svg");
|
||||
// Low priority prevents React SSR from automatically preloading the image.
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
className={className}
|
||||
alt={alt}
|
||||
width={size}
|
||||
height={size}
|
||||
decoding="async"
|
||||
{...props}
|
||||
fetchPriority="low"
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default Icon;
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import QR from "qrcode";
|
||||
export function QRCode({
|
||||
value,
|
||||
size = 104,
|
||||
}: {
|
||||
value: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
if (value && ref.current)
|
||||
void QR.toCanvas(ref.current, value, {
|
||||
width: size,
|
||||
margin: 1,
|
||||
color: { dark: "#000000", light: "#ffffff" },
|
||||
}).catch(() => {});
|
||||
}, [value, size]);
|
||||
return value ? (
|
||||
<canvas
|
||||
ref={ref}
|
||||
width={size}
|
||||
height={size}
|
||||
role="img"
|
||||
aria-label="扫码下载 App"
|
||||
data-qr={value}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
fetchPriority="low"
|
||||
src="/static/brand/placeholder.svg"
|
||||
alt="扫码下载"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import { EncryptedImage } from "@/components/media/EncryptedImage";
|
||||
import {
|
||||
DramaCard,
|
||||
RankCard,
|
||||
SeriesCard,
|
||||
TopicCard,
|
||||
} from "@/components/catalog/Cards";
|
||||
import { Pagination } from "@/components/catalog/Pagination";
|
||||
import { AdBanners } from "@/components/catalog/Promotions";
|
||||
import { SortDropdown, SubpageHeader } from "@/components/catalog/Interactions";
|
||||
import {
|
||||
adSlice,
|
||||
allListURL,
|
||||
array,
|
||||
first,
|
||||
get,
|
||||
moreListURL,
|
||||
moreSeriesListURL,
|
||||
num,
|
||||
record,
|
||||
seriesListURL,
|
||||
str,
|
||||
tagPageURL,
|
||||
topicDetailURL,
|
||||
topicListURL,
|
||||
itemPlayURL,
|
||||
type CatalogPageProps,
|
||||
type CatalogRecord,
|
||||
} from "./model";
|
||||
|
||||
function Page({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="hk-page-layout hk-drama-browse-page">
|
||||
<main className="hk-page-main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function ChannelHero({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
label,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
label?: string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={`hk-drama-browse-hero${className ? ` ${className}` : ""}`}
|
||||
aria-label={label || `${eyebrow}频道`}
|
||||
>
|
||||
<div className="hk-drama-browse-hero__intro">
|
||||
<p className="hk-eyebrow hk-drama-browse-hero__eyebrow">
|
||||
<span className="hk-eyebrow__bar" aria-hidden="true" />
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h1 className="hk-drama-browse-hero__title">{title}</h1>
|
||||
{description !== undefined && (
|
||||
<p className="hk-drama-browse-hero__desc">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function FilterRow({
|
||||
label,
|
||||
children,
|
||||
className = "",
|
||||
mobileLabel = false,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
mobileLabel?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`hk-filter-row${className ? ` ${className}` : ""}`}>
|
||||
<span className={`hk-filter-row__label${mobileLabel ? "" : " hk-desktop-only"}`}>
|
||||
{label}
|
||||
</span>
|
||||
<div className="hk-filter-row__options">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Chip({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a className={`hk-filter-chip${active ? " hk-is-active" : ""}`} href={href}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
function SeoDescription({
|
||||
data,
|
||||
fallback,
|
||||
}: {
|
||||
data: CatalogRecord;
|
||||
fallback: string;
|
||||
}) {
|
||||
return first(get(get(data, "seoInfo"), "Description"), fallback);
|
||||
}
|
||||
function banners(data: CatalogRecord, config = false): unknown {
|
||||
return get(get(data, config ? "appConfig" : "advitise"), "DetailBannerList");
|
||||
}
|
||||
export function CatalogGrid({
|
||||
items,
|
||||
ads,
|
||||
series = false,
|
||||
more = false,
|
||||
recent = false,
|
||||
desktopEvery,
|
||||
mobileEvery,
|
||||
}: {
|
||||
items: CatalogRecord[];
|
||||
ads?: unknown;
|
||||
series?: boolean;
|
||||
more?: boolean;
|
||||
recent?: boolean;
|
||||
desktopEvery?: number;
|
||||
mobileEvery?: number;
|
||||
}) {
|
||||
const pc = desktopEvery || (series && !more ? 8 : 12),
|
||||
h5 = mobileEvery || (series && !more ? 4 : 6),
|
||||
promo = series ? "hk-series-list-promo" : "hk-drama-browse-mid-promo";
|
||||
return (
|
||||
<div className={series ? "hk-content-grid--series-list" : "hk-content-grid--list"}>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={`${str(item, "ID")}-${index}`}>
|
||||
{series ? (
|
||||
<SeriesCard item={item} more={more} />
|
||||
) : (
|
||||
<DramaCard item={item} recent={recent} />
|
||||
)}
|
||||
{index < items.length - 1 && array(ads).length > 0 && (
|
||||
<>
|
||||
{(index + 1) % pc === 0 && (
|
||||
<div className={`${promo} hk-desktop-only`}>
|
||||
<AdBanners
|
||||
items={adSlice(
|
||||
ads,
|
||||
(Math.floor((index + 1) / pc) - 1) * 2,
|
||||
2,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(index + 1) % h5 === 0 && (
|
||||
<div className={`${promo} hk-mobile-only`}>
|
||||
<AdBanners
|
||||
items={adSlice(
|
||||
ads,
|
||||
(Math.floor((index + 1) / h5) - 1) * 2,
|
||||
2,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Results({
|
||||
data,
|
||||
title,
|
||||
empty,
|
||||
ads,
|
||||
series = false,
|
||||
more = false,
|
||||
recent = false,
|
||||
showCount = true,
|
||||
alwaysPagination = false,
|
||||
}: {
|
||||
data: CatalogRecord;
|
||||
title: string;
|
||||
empty: string;
|
||||
ads?: unknown;
|
||||
series?: boolean;
|
||||
more?: boolean;
|
||||
recent?: boolean;
|
||||
showCount?: boolean;
|
||||
alwaysPagination?: boolean;
|
||||
}) {
|
||||
const items = array(get(data, "list"));
|
||||
return (
|
||||
<section className="hk-drama-browse-body" aria-label={title}>
|
||||
<h2 className="hk-visually-hidden">{title}</h2>
|
||||
{showCount && <p className="hk-drama-browse-count">{str(data, "listCount")}</p>}
|
||||
{items.length ? (
|
||||
<>
|
||||
<CatalogGrid
|
||||
items={items}
|
||||
ads={ads}
|
||||
series={series}
|
||||
more={more}
|
||||
recent={recent}
|
||||
/>
|
||||
<Pagination pagination={record(get(data, "pagination"))} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="hk-drama-browse-count">{empty}</p>
|
||||
{alwaysPagination && (
|
||||
<Pagination pagination={record(get(data, "pagination"))} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function ListPage({ data }: CatalogPageProps) {
|
||||
const url = (theme: unknown, id: unknown, status: unknown, sort: unknown) =>
|
||||
allListURL(theme, id, status, sort, 1);
|
||||
return (
|
||||
<Page>
|
||||
<ChannelHero
|
||||
eyebrow="短剧"
|
||||
title={first(data.pageHeading, "全部短剧")}
|
||||
description="海量竖屏原创短剧,每日 18:00 上新"
|
||||
/>
|
||||
<section className="hk-drama-browse-filters" aria-label="筛选">
|
||||
<FilterRow label="题材">
|
||||
<Chip
|
||||
active={!data.themeSlug && !data.themeId}
|
||||
href={url("", 0, data.updateStatus, data.sortType)}
|
||||
>
|
||||
全部
|
||||
</Chip>
|
||||
{array(data.themeList).map((item) => (
|
||||
<Chip
|
||||
key={str(item, "ID")}
|
||||
active={
|
||||
(!!data.themeSlug && data.themeSlug === str(item, "Slug")) ||
|
||||
(data.themeId > 0 && data.themeId === num(item, "ID"))
|
||||
}
|
||||
href={url(
|
||||
get(item, "Slug"),
|
||||
get(item, "ID"),
|
||||
data.updateStatus,
|
||||
data.sortType,
|
||||
)}
|
||||
>
|
||||
{str(item, "Name")}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterRow>
|
||||
<FilterRow label="状态">
|
||||
{["全部", "连载中", "已完结"].map((label, value) => (
|
||||
<Chip
|
||||
key={value}
|
||||
active={data.updateStatus === value}
|
||||
href={url(data.themeSlug, data.themeId, value, data.sortType)}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterRow>
|
||||
<FilterRow label="排序">
|
||||
{["最新", "最热", "收藏最多"].map((label, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
active={data.sortType === index + 1}
|
||||
href={url(
|
||||
data.themeSlug,
|
||||
data.themeId,
|
||||
data.updateStatus,
|
||||
index + 1,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterRow>
|
||||
</section>
|
||||
<Results
|
||||
data={data}
|
||||
title="短剧列表"
|
||||
empty="暂无短剧"
|
||||
ads={banners(data)}
|
||||
alwaysPagination
|
||||
/>
|
||||
<div className="hk-drama-browse-bottom-promo">
|
||||
<AdBanners items={adSlice(banners(data), 2, 2)} />
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
export function SeriesPage({ data }: CatalogPageProps) {
|
||||
return (
|
||||
<Page>
|
||||
<ChannelHero
|
||||
eyebrow="剧集"
|
||||
title={first(data.pageHeading, "剧集与动画")}
|
||||
description={
|
||||
<SeoDescription data={data} fallback="横版观看的影集与动画作品" />
|
||||
}
|
||||
/>
|
||||
<section className="hk-drama-browse-filters" aria-label="筛选">
|
||||
<FilterRow label="类型">
|
||||
{["全部", "影集", "动画"].map((label, type) => (
|
||||
<Chip
|
||||
key={type}
|
||||
active={data.mediaType === type}
|
||||
href={seriesListURL(type, data.topicId, data.sortType, 1)}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterRow>
|
||||
<FilterRow label="题材">
|
||||
<Chip
|
||||
active={!data.topicId}
|
||||
href={seriesListURL(data.mediaType, 0, data.sortType, 1)}
|
||||
>
|
||||
全部
|
||||
</Chip>
|
||||
{array(data.topicList).map((item) => (
|
||||
<Chip
|
||||
key={str(item, "ID")}
|
||||
active={data.topicId === num(item, "ID")}
|
||||
href={seriesListURL(
|
||||
data.mediaType,
|
||||
get(item, "ID"),
|
||||
data.sortType,
|
||||
1,
|
||||
)}
|
||||
>
|
||||
{first(
|
||||
get(item, "DisplayName"),
|
||||
get(item, "Name"),
|
||||
get(item, "Title"),
|
||||
)}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterRow>
|
||||
<FilterRow label="排序" className="hk-desktop-only" mobileLabel>
|
||||
{["最新", "最热", "收藏最多"].map((label, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
active={data.sortType === index + 1}
|
||||
href={seriesListURL(data.mediaType, data.topicId, index + 1, 1)}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterRow>
|
||||
</section>
|
||||
<Results
|
||||
data={data}
|
||||
title="剧集与动画作品列表"
|
||||
empty="暂无剧集"
|
||||
ads={banners(data)}
|
||||
series
|
||||
/>
|
||||
{array(banners(data)).length > 0 && (
|
||||
<div className="hk-drama-browse-bottom-promo">
|
||||
<AdBanners items={adSlice(banners(data), 2, 2)} />
|
||||
</div>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
function Breadcrumb({
|
||||
name,
|
||||
topic = false,
|
||||
}: {
|
||||
name: string;
|
||||
topic?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<nav className="hk-breadcrumb hk-desktop-only" aria-label="面包屑">
|
||||
<a className="hk-breadcrumb__item" href="/">
|
||||
首页
|
||||
</a>
|
||||
<Icon name="chevron-right" className="hk-breadcrumb__sep" />
|
||||
<a className="hk-breadcrumb__item" href={topic ? "/collections/" : "/dramas/"}>
|
||||
{topic ? "专题" : "短剧"}
|
||||
</a>
|
||||
<Icon name="chevron-right" className="hk-breadcrumb__sep" />
|
||||
<span className="hk-breadcrumb__item hk-is-current">{name}</span>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
export function TagsPage({ data }: CatalogPageProps) {
|
||||
const title = first(data.tagName, "标签");
|
||||
return (
|
||||
<>
|
||||
<SubpageHeader title={title} label="标签页顶栏" />
|
||||
<Page>
|
||||
<section className="hk-drama-browse-hero hk-drama-browse-hero--tags" aria-label="标签频道">
|
||||
<Breadcrumb name={title} />
|
||||
<div className="hk-drama-browse-hero__intro">
|
||||
<p className="hk-eyebrow hk-drama-browse-hero__eyebrow">
|
||||
<span className="hk-eyebrow__bar" aria-hidden="true" />
|
||||
标签
|
||||
</p>
|
||||
<h1 className="hk-drama-browse-hero__title">
|
||||
{first(data.tagName, "标签短剧")}
|
||||
</h1>
|
||||
<p className="hk-drama-browse-hero__desc">
|
||||
{first(data.heroDesc, get(data.seoInfo, "Description"))}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section className="hk-drama-browse-filters" aria-label="筛选">
|
||||
{array(data.relatedTags).length > 0 && (
|
||||
<FilterRow label="相关">
|
||||
{array(data.relatedTags).map((item) => (
|
||||
<Chip
|
||||
key={str(item, "ID")}
|
||||
href={tagPageURL(get(item, "Slug"), get(item, "ID"), 1, 1)}
|
||||
>
|
||||
{str(item, "Name")}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterRow>
|
||||
)}
|
||||
<FilterRow label="排序">
|
||||
{["最新", "最热", "收藏最多"].map((label, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
active={data.sortType === index + 1}
|
||||
href={tagPageURL(data.tagSlug, data.tagId, index + 1, 1)}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterRow>
|
||||
</section>
|
||||
<Results
|
||||
data={data}
|
||||
title={`${first(data.tagName, "标签短剧")}作品列表`}
|
||||
empty="暂无作品"
|
||||
/>
|
||||
</Page>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function RankPage({ data }: CatalogPageProps) {
|
||||
const title = first(data.tabTitle, "热播榜"),
|
||||
items = array(data.list);
|
||||
return (
|
||||
<>
|
||||
<SubpageHeader title={title} label="排行榜顶栏" />
|
||||
<Page>
|
||||
<section className="hk-drama-browse-hero" aria-label="排行榜频道">
|
||||
<div className="hk-drama-browse-hero__intro">
|
||||
<p className="hk-eyebrow hk-drama-browse-hero__eyebrow">
|
||||
<span className="hk-eyebrow__bar" aria-hidden="true" />
|
||||
排行榜
|
||||
</p>
|
||||
<h1 className="hk-drama-browse-hero__title">{title}</h1>
|
||||
<p className="hk-drama-browse-hero__desc hk-desktop-only">
|
||||
<SeoDescription
|
||||
data={data}
|
||||
fallback="每小时更新一次,数据来自全站播放、收藏与解锁"
|
||||
/>
|
||||
</p>
|
||||
<p className="hk-drama-browse-hero__desc hk-mobile-only">每小时更新一次</p>
|
||||
</div>
|
||||
<nav className="hk-drama-rankings-tabs" aria-label="榜单类型">
|
||||
{array(data.rankTabs).map((item) => (
|
||||
<a
|
||||
key={str(item, "URL")}
|
||||
className={`hk-drama-rankings-tabs__item${get(item, "Active") ? " hk-is-active" : ""}`}
|
||||
href={str(item, "URL")}
|
||||
>
|
||||
{str(item, "Label")}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</section>
|
||||
<section
|
||||
className="hk-drama-browse-body hk-drama-browse-body--rank"
|
||||
aria-label={`${title}列表`}
|
||||
>
|
||||
<h2 className="hk-visually-hidden">{title}作品列表</h2>
|
||||
{items.length ? (
|
||||
<>
|
||||
<div className="hk-content-grid--rank-list">
|
||||
{items.map((item, index) => (
|
||||
<RankCard
|
||||
key={str(item, "ID")}
|
||||
item={item}
|
||||
rank={num(data, "rankOffset") + index + 1}
|
||||
binge={!!data.isBinge}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Pagination pagination={record(data.pagination)} />
|
||||
</>
|
||||
) : (
|
||||
<p className="hk-drama-browse-count">暂无榜单数据</p>
|
||||
)}
|
||||
</section>
|
||||
</Page>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function PopularRecommendPage({ data }: CatalogPageProps) {
|
||||
const title = first(data.tabTitle, "热播榜");
|
||||
return (
|
||||
<>
|
||||
<SubpageHeader title={title} label="热播榜顶栏" />
|
||||
<Page>
|
||||
<ChannelHero
|
||||
eyebrow="热播榜"
|
||||
title={title}
|
||||
label="热播榜"
|
||||
description={
|
||||
<SeoDescription
|
||||
data={data}
|
||||
fallback="根据播放、收藏与解锁综合排序"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<section className="hk-drama-browse-filters" aria-label="筛选">
|
||||
<FilterRow label="榜单">
|
||||
{array(data.rankTabs).map((item) => (
|
||||
<Chip
|
||||
key={str(item, "URL")}
|
||||
active={!!get(item, "Active")}
|
||||
href={str(item, "URL")}
|
||||
>
|
||||
{str(item, "Label")}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterRow>
|
||||
</section>
|
||||
<Results
|
||||
data={data}
|
||||
title={`${title}作品列表`}
|
||||
empty="暂无榜单数据"
|
||||
ads={banners(data, true)}
|
||||
/>
|
||||
</Page>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function RecentlyAddedPage({ data }: CatalogPageProps) {
|
||||
return (
|
||||
<>
|
||||
<SubpageHeader title="最近上新" />
|
||||
<Page>
|
||||
<ChannelHero
|
||||
eyebrow="最近上新"
|
||||
title="最近上新"
|
||||
label="最近上新"
|
||||
description={
|
||||
<SeoDescription
|
||||
data={data}
|
||||
fallback="按上线时间排序,每日 18:00 更新"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Results
|
||||
data={data}
|
||||
title="最近上新作品列表"
|
||||
empty="暂无上新短剧"
|
||||
ads={banners(data, true)}
|
||||
recent
|
||||
/>
|
||||
</Page>
|
||||
</>
|
||||
);
|
||||
}
|
||||
function MorePage({
|
||||
data,
|
||||
video = false,
|
||||
}: CatalogPageProps & { video?: boolean }) {
|
||||
const title = first(data.configName, video ? "更多视频" : "更多短剧"),
|
||||
url = video ? moreSeriesListURL : moreListURL;
|
||||
return (
|
||||
<>
|
||||
<SubpageHeader
|
||||
title={title}
|
||||
label={video ? "更多视频顶栏" : "更多列表顶栏"}
|
||||
/>
|
||||
<Page>
|
||||
<ChannelHero
|
||||
eyebrow={video ? "视频" : "首页精选"}
|
||||
title={title}
|
||||
label={video ? "更多视频" : "更多列表"}
|
||||
description={first(
|
||||
data.configDesc,
|
||||
video ? "横版观看的影集作品,持续更新" : "精选短剧合集,持续更新",
|
||||
)}
|
||||
/>
|
||||
<section className="hk-drama-browse-filters" aria-label="筛选">
|
||||
<FilterRow label="排序">
|
||||
{array(data.timeTabs).map((item) => (
|
||||
<Chip
|
||||
key={str(item, "URL")}
|
||||
active={!!get(item, "Active")}
|
||||
href={str(item, "URL")}
|
||||
>
|
||||
{str(item, "Label")}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterRow>
|
||||
<FilterRow label="题材">
|
||||
<Chip
|
||||
active={!data.themeSlug && !data.themeId}
|
||||
href={url(
|
||||
data.configSlug,
|
||||
data.configId,
|
||||
data.timeRange,
|
||||
"",
|
||||
0,
|
||||
1,
|
||||
)}
|
||||
>
|
||||
全部
|
||||
</Chip>
|
||||
{array(data.themeList).map((item) => (
|
||||
<Chip
|
||||
key={str(item, "ID")}
|
||||
active={
|
||||
(!!data.themeSlug && data.themeSlug === str(item, "Slug")) ||
|
||||
(data.themeId > 0 && data.themeId === num(item, "ID"))
|
||||
}
|
||||
href={url(
|
||||
data.configSlug,
|
||||
data.configId,
|
||||
data.timeRange,
|
||||
get(item, "Slug"),
|
||||
get(item, "ID"),
|
||||
1,
|
||||
)}
|
||||
>
|
||||
{str(item, "Name")}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterRow>
|
||||
</section>
|
||||
<Results
|
||||
data={data}
|
||||
title={`${first(data.configName, video ? "精选视频" : "精选短剧")}作品列表`}
|
||||
empty={video ? "暂无视频" : "暂无短剧"}
|
||||
ads={banners(data)}
|
||||
series={video}
|
||||
more
|
||||
/>
|
||||
{(video ? array(banners(data)).length > 0 : !!data.advitise) && (
|
||||
<div className="hk-drama-browse-bottom-promo">
|
||||
<AdBanners items={adSlice(banners(data), video ? 2 : 0, 2)} />
|
||||
</div>
|
||||
)}
|
||||
</Page>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function MoreListPage(props: CatalogPageProps) {
|
||||
return <MorePage {...props} />;
|
||||
}
|
||||
export function MoreSeriesListPage(props: CatalogPageProps) {
|
||||
return <MorePage {...props} video />;
|
||||
}
|
||||
export function TopicPage({ data }: CatalogPageProps) {
|
||||
return (
|
||||
<>
|
||||
<SubpageHeader title="专题剧单" label="专题顶栏" />
|
||||
<Page>
|
||||
<ChannelHero
|
||||
eyebrow="专题"
|
||||
title="专题剧单"
|
||||
description={
|
||||
<SeoDescription
|
||||
data={data}
|
||||
fallback="按口味打包的剧单,编辑每周更新"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<section className="hk-drama-browse-filters hk-desktop-only" aria-label="筛选">
|
||||
<FilterRow label="排序" mobileLabel>
|
||||
{["最新", "最热"].map((label, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
active={data.sortType === index + 1}
|
||||
href={topicListURL(index + 1, 1)}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterRow>
|
||||
</section>
|
||||
<section className="hk-drama-browse-body" aria-label="专题列表">
|
||||
<h2 className="hk-visually-hidden">专题列表</h2>
|
||||
{array(data.list).length ? (
|
||||
<div className="hk-collection-grid--list">
|
||||
{array(data.list).map((item) => (
|
||||
<TopicCard key={str(item, "ID")} item={item} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="hk-drama-browse-count">暂无专题</p>
|
||||
)}
|
||||
<Pagination pagination={record(data.pagination)} />
|
||||
</section>
|
||||
</Page>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function TopicDetailPage({ data }: CatalogPageProps) {
|
||||
const items = array(data.list),
|
||||
title = first(data.name, "专题剧单"),
|
||||
count = data.playletCount || data.totalCount || 0,
|
||||
options = ["编辑排序", "最新", "最热"].map((label, index) => ({
|
||||
label,
|
||||
href: topicDetailURL(data.slug, data.topicId, index + 1, 1),
|
||||
active: data.sortType === index + 1,
|
||||
}));
|
||||
return (
|
||||
<>
|
||||
<section className="hk-collection-detail-hero" aria-label="专题头图">
|
||||
<EncryptedImage
|
||||
className="hk-collection-detail-hero__bg hk-lazy"
|
||||
src={data.heroCover}
|
||||
alt={`${first(data.name, "专题")}背景`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="hk-collection-detail-hero__mask" aria-hidden="true" />
|
||||
<SubpageHeader title="专题" label="专题详情顶栏" overlay share />
|
||||
<div className="hk-page-layout hk-collection-detail-hero__inner">
|
||||
<div className="hk-collection-detail-hero__content">
|
||||
<Breadcrumb name={first(data.name, "专题")} topic />
|
||||
<p className="hk-eyebrow hk-mobile-only">
|
||||
<span className="hk-eyebrow__bar" aria-hidden="true" />
|
||||
专题
|
||||
</p>
|
||||
<h1 className="hk-collection-detail-hero__title">{title}</h1>
|
||||
{first(data.desc, get(data.seoInfo, "Description")) && (
|
||||
<p className="hk-collection-detail-hero__desc">
|
||||
{first(data.desc, get(data.seoInfo, "Description"))}
|
||||
</p>
|
||||
)}
|
||||
<p className="hk-collection-detail-hero__meta hk-desktop-only">
|
||||
{count ? `${count} 部作品` : "专题作品"} 持续更新
|
||||
</p>
|
||||
<p className="hk-collection-detail-hero__meta hk-mobile-only">
|
||||
{count} 部 持续更新
|
||||
</p>
|
||||
<div className="hk-collection-detail-hero__actions">
|
||||
<a
|
||||
className="hk-button hk-button--primary hk-button--lg hk-collection-detail-hero__play"
|
||||
href={items.length ? itemPlayURL(items[0]) : "/collections/"}
|
||||
>
|
||||
<Icon name="play_hollow" />
|
||||
{items.length ? "从第 1 部开始看" : "浏览专题"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{Array.isArray(data.covers) && data.covers.length > 0 && (
|
||||
<div className="hk-collection-detail-hero__stack" aria-hidden="true">
|
||||
{data.covers.map((cover: string, index: number) => (
|
||||
<EncryptedImage
|
||||
key={`${cover}-${index}`}
|
||||
className="hk-lazy"
|
||||
src={cover}
|
||||
alt={`${first(data.name, "专题")}封面`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<div className="hk-page-layout">
|
||||
<main className="hk-page-main">
|
||||
<section className="hk-collection-detail-section" aria-label="专题作品">
|
||||
<div className="hk-collection-detail-section__head">
|
||||
<div className="hk-collection-detail-section__left">
|
||||
<h2 className="hk-collection-detail-section__title">专题作品</h2>
|
||||
<span className="hk-collection-detail-section__count">{count} 部</span>
|
||||
<nav className="hk-collection-detail-sort hk-desktop-only" aria-label="排序">
|
||||
{options.map((option) => (
|
||||
<a
|
||||
key={option.href}
|
||||
className={`hk-collection-detail-sort__item${option.active ? " hk-is-active" : ""}`}
|
||||
href={option.href}
|
||||
>
|
||||
{option.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<SortDropdown
|
||||
label={first(data.sortLabel, "编辑排序")}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
{items.length ? (
|
||||
<>
|
||||
<div className="hk-content-grid--list">
|
||||
{items.map((item) => (
|
||||
<DramaCard key={str(item, "ID")} item={item} />
|
||||
))}
|
||||
</div>
|
||||
<Pagination pagination={record(data.pagination)} />
|
||||
</>
|
||||
) : (
|
||||
<p className="hk-drama-browse-count">暂无作品</p>
|
||||
)}
|
||||
</section>
|
||||
{array(data.moreTopics).length > 0 && (
|
||||
<section
|
||||
className="hk-collection-detail-section hk-collection-detail-section--more"
|
||||
aria-label="更多专题"
|
||||
>
|
||||
<div className="hk-collection-detail-section__head hk-collection-detail-section__head--padded">
|
||||
<div className="hk-collection-detail-section__left">
|
||||
<h2 className="hk-collection-detail-section__title">更多专题</h2>
|
||||
</div>
|
||||
<a className="hk-content-section__more" href="/collections/">
|
||||
全部专题
|
||||
<Icon name="chevron-right" className="hk-icon hk-icon--sm" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="hk-collection-grid--list hk-collection-grid--more">
|
||||
{array(data.moreTopics).map((item) => (
|
||||
<TopicCard key={str(item, "ID")} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import { EncryptedImage } from "@/components/media/EncryptedImage";
|
||||
import { BackLink } from "@/components/catalog/Interactions";
|
||||
import { RecommendationCard } from "@/components/catalog/Cards";
|
||||
import { Pagination } from "@/components/catalog/Pagination";
|
||||
import { track } from "@/lib/tracker";
|
||||
import {
|
||||
array,
|
||||
first,
|
||||
get,
|
||||
num,
|
||||
record,
|
||||
safeHref,
|
||||
searchResultsURL,
|
||||
str,
|
||||
type CatalogPageProps,
|
||||
} from "./model";
|
||||
import {
|
||||
rememberSearch,
|
||||
useSearchHistory,
|
||||
useSearchSuggestions,
|
||||
type SearchSuggestions,
|
||||
} from "./search";
|
||||
|
||||
export function Highlight({
|
||||
text,
|
||||
keyword,
|
||||
className = "mobile-search__highlight",
|
||||
}: {
|
||||
text: string;
|
||||
keyword: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const index = text.toLowerCase().indexOf(keyword.trim().toLowerCase());
|
||||
return !keyword.trim() || index < 0 ? (
|
||||
<>{text}</>
|
||||
) : (
|
||||
<>
|
||||
{text.slice(0, index)}
|
||||
<span className={className}>
|
||||
{text.slice(index, index + keyword.trim().length)}
|
||||
</span>
|
||||
{text.slice(index + keyword.trim().length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function SearchSuggestionResults({
|
||||
suggestions,
|
||||
keyword,
|
||||
loading,
|
||||
remember = rememberSearch,
|
||||
}: {
|
||||
suggestions: SearchSuggestions | null;
|
||||
keyword: string;
|
||||
loading: boolean;
|
||||
remember?: (keyword: string) => void;
|
||||
}) {
|
||||
const feature = suggestions?.feature,
|
||||
items = suggestions?.items || [];
|
||||
if (loading)
|
||||
return (
|
||||
<p className="hk-mobile-search__suggest-empty" role="status">
|
||||
搜索中…
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{feature?.url && (
|
||||
<article className="hk-mobile-search__suggestion-feature">
|
||||
<a
|
||||
className="hk-mobile-search__feature-cover"
|
||||
href={safeHref(feature.url)}
|
||||
aria-label={feature.title}
|
||||
onClick={() => remember(feature.title)}
|
||||
>
|
||||
<EncryptedImage
|
||||
src={feature.cover}
|
||||
alt={feature.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
<div className="hk-mobile-search__feature-copy">
|
||||
<h2 className="hk-mobile-search__feature-title">
|
||||
<Highlight text={feature.title} keyword={keyword} />
|
||||
</h2>
|
||||
{feature.meta && (
|
||||
<p className="hk-mobile-search__feature-meta">{feature.meta}</p>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
className="hk-mobile-search__feature-play"
|
||||
href={safeHref(feature.url)}
|
||||
onClick={() => remember(feature.title)}
|
||||
>
|
||||
<Icon name="play_hollow" />
|
||||
播放
|
||||
</a>
|
||||
</article>
|
||||
)}
|
||||
{items.map((item, index) => (
|
||||
<a
|
||||
key={`${item.url}-${index}`}
|
||||
className="hk-mobile-search__suggestion"
|
||||
href={safeHref(item.url)}
|
||||
onClick={() => remember(item.query || item.title || keyword)}
|
||||
>
|
||||
<Icon name="search" className="hk-mobile-search__suggestion-icon" />
|
||||
<span className="hk-mobile-search__suggestion-text">
|
||||
<Highlight
|
||||
text={item.title || item.query || ""}
|
||||
keyword={keyword}
|
||||
/>
|
||||
</span>
|
||||
<span className="hk-mobile-search__suggestion-type">{item.kind}</span>
|
||||
</a>
|
||||
))}
|
||||
{!feature && !items.length && (
|
||||
<p className="hk-mobile-search__suggest-empty">
|
||||
暂无相关结果,可直接搜索“{keyword}”
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
function SearchHeader({
|
||||
query,
|
||||
onQuery,
|
||||
mobile,
|
||||
onSearch,
|
||||
}: {
|
||||
query: string;
|
||||
onQuery: (value: string) => void;
|
||||
mobile?: boolean;
|
||||
onSearch: (keyword: string) => void;
|
||||
}) {
|
||||
const input = useRef<HTMLInputElement>(null),
|
||||
prefix = mobile ? "hk-mobile-search" : "hk-search-h5-header";
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
onSearch(query);
|
||||
location.assign(searchResultsURL(query));
|
||||
}
|
||||
return (
|
||||
<header
|
||||
className={`${prefix}${mobile ? "__header" : ""}`}
|
||||
aria-label="搜索顶栏"
|
||||
>
|
||||
<BackLink className={`${prefix}__back`}>
|
||||
<Icon name="chevron-left" />
|
||||
</BackLink>
|
||||
<form
|
||||
className={`${prefix}__form`}
|
||||
action="/search/"
|
||||
role="search"
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Icon name="search" className={`${prefix}__search-icon`} />
|
||||
<input
|
||||
ref={input}
|
||||
className={`${prefix}__input`}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => onQuery(event.target.value)}
|
||||
placeholder="搜索剧名 / 演员 / 标签"
|
||||
aria-label="搜索剧名、演员或标签"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
className={`${prefix}__clear`}
|
||||
type="button"
|
||||
aria-label="清除搜索词"
|
||||
hidden={!query.trim()}
|
||||
onClick={() => {
|
||||
onQuery("");
|
||||
input.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</form>
|
||||
<a className={`${prefix}__cancel`} href="/">
|
||||
取消
|
||||
</a>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
export function SearchPage({ data }: CatalogPageProps) {
|
||||
const [query, setQuery] = useState(str(data, "query")),
|
||||
keyword = str(data, "query"),
|
||||
works = array(data.works);
|
||||
useEffect(() => setQuery(keyword), [keyword]);
|
||||
useEffect(() => {
|
||||
if (keyword && num(data, "pageNum") <= 1)
|
||||
void track("keywordSearch", {
|
||||
keyword,
|
||||
search_result_count: num(data, "resultCount"),
|
||||
search_content_type: "",
|
||||
search_trace_id: "",
|
||||
search_id: "",
|
||||
});
|
||||
}, [keyword, data.pageNum, data.resultCount]);
|
||||
return (
|
||||
<>
|
||||
<SearchHeader
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
onSearch={rememberSearch}
|
||||
/>
|
||||
<h1 className="hk-visually-hidden">搜索“{keyword}”</h1>
|
||||
<main
|
||||
className="hk-search-main"
|
||||
data-search-results=""
|
||||
data-search-keyword={keyword}
|
||||
data-search-result-count={num(data, "resultCount")}
|
||||
data-search-page={num(data, "pageNum")}
|
||||
data-search-page-size={num(data, "pageSize")}
|
||||
>
|
||||
{data.hasResults ? (
|
||||
<>
|
||||
<section className="hk-search-heading">
|
||||
<div className="hk-search-heading__intro">
|
||||
<p className="hk-search-heading__kicker">
|
||||
<span />
|
||||
搜索
|
||||
</p>
|
||||
<h2>“{keyword}”</h2>
|
||||
<p className="hk-search-heading__description">
|
||||
找到{" "}
|
||||
<span data-result-count="">{num(data, "resultCount")}</span>{" "}
|
||||
部相关作品
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section className="hk-search-results" aria-label="搜索结果">
|
||||
<p className="hk-search-results__mobile-count">
|
||||
找到{" "}
|
||||
<span data-result-count="">{num(data, "resultCount")}</span>{" "}
|
||||
部相关作品
|
||||
</p>
|
||||
<div className="hk-search-card-grid" data-search-grid="">
|
||||
{works.map((item, index) => (
|
||||
<RecommendationCard
|
||||
key={`${str(item, "ID")}-${index}`}
|
||||
item={item}
|
||||
onClick={() => {
|
||||
void track("keywordClick", {
|
||||
keyword,
|
||||
click_item_id: str(item, "ID"),
|
||||
click_item_type_key: "short_drama",
|
||||
click_item_type_name: "短剧",
|
||||
click_position:
|
||||
(Math.max(1, num(data, "pageNum")) - 1) *
|
||||
Math.max(1, num(data, "pageSize")) +
|
||||
index +
|
||||
1,
|
||||
search_trace_id: "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Pagination pagination={record(data.pagination)} />
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<section className="hk-search-heading hk-search-heading--empty">
|
||||
<div className="hk-search-heading__intro">
|
||||
<p className="hk-search-heading__kicker">
|
||||
<span />
|
||||
搜索
|
||||
</p>
|
||||
<h2>“{keyword}”</h2>
|
||||
<p className="hk-search-heading__description">
|
||||
没有找到相关作品,试试更短的关键词或从热门推荐里挑一部
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section className="hk-search-empty" aria-label="没有搜索结果">
|
||||
<div className="hk-search-empty__icon">
|
||||
<Icon name="search" />
|
||||
</div>
|
||||
<h2>没有找到相关作品</h2>
|
||||
<p>换个关键词试试,或按题材浏览全部短剧</p>
|
||||
<a className="hk-button hk-button--primary" href="/dramas/">
|
||||
浏览全部短剧
|
||||
</a>
|
||||
</section>
|
||||
{array(data.recommended).length > 0 && (
|
||||
<section className="hk-search-recommendations" aria-label="热门推荐">
|
||||
<div className="hk-search-recommendations__heading">
|
||||
<h2>大家都在看</h2>
|
||||
<a href="/dramas/">
|
||||
<span className="hk-search-recommendations__more-label hk-search-recommendations__more-label--pc">
|
||||
查看全部
|
||||
</span>
|
||||
<span className="hk-search-recommendations__more-label hk-search-recommendations__more-label--h5">
|
||||
全部
|
||||
</span>
|
||||
<Icon name="chevron-right" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="hk-search-card-grid">
|
||||
{array(data.recommended).map((item, index) => (
|
||||
<RecommendationCard
|
||||
key={`${str(item, "ID")}-${index}`}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function SearchMobilePage({ data }: CatalogPageProps) {
|
||||
const [query, setQuery] = useState(str(data, "query")),
|
||||
{ suggestions, loading } = useSearchSuggestions(query),
|
||||
{ history, remember, clear } = useSearchHistory(),
|
||||
hasQuery = !!query.trim();
|
||||
return (
|
||||
<>
|
||||
<SearchHeader
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
mobile
|
||||
onSearch={remember}
|
||||
/>
|
||||
<main className="hk-mobile-search__main">
|
||||
<h1 className="hk-visually-hidden">搜索短剧</h1>
|
||||
<div className="hk-mobile-search__default" hidden={hasQuery}>
|
||||
<section
|
||||
className="hk-mobile-search__history"
|
||||
aria-label="搜索历史"
|
||||
hidden={!history.length}
|
||||
>
|
||||
<div className="hk-mobile-search__section-head">
|
||||
<h2 className="hk-mobile-search__section-label">搜索历史</h2>
|
||||
<button
|
||||
className="hk-mobile-search__history-clear"
|
||||
type="button"
|
||||
aria-label="清除搜索历史"
|
||||
onClick={clear}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="hk-mobile-search__chips">
|
||||
{history.map((term) => (
|
||||
<a
|
||||
key={term}
|
||||
className="hk-mobile-search__chip"
|
||||
href={searchResultsURL(term)}
|
||||
onClick={() => remember(term)}
|
||||
>
|
||||
{term}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{array(data.trends).length > 0 && (
|
||||
<section className="hk-mobile-search__trending" aria-label="热门搜索">
|
||||
<h2 className="hk-mobile-search__section-label">热门搜索</h2>
|
||||
<ol className="hk-mobile-search__trend-list">
|
||||
{array(data.trends).map((item, index) => (
|
||||
<li
|
||||
key={`${str(item, "Title")}-${index}`}
|
||||
className="hk-mobile-search__trend"
|
||||
>
|
||||
<a
|
||||
href={searchResultsURL(get(item, "Title"))}
|
||||
onClick={() => remember(str(item, "Title"))}
|
||||
>
|
||||
<span className="hk-mobile-search__trend-rank">
|
||||
{num(item, "Rank")}
|
||||
</span>
|
||||
<span className="hk-mobile-search__trend-name">
|
||||
{str(item, "Title")}
|
||||
</span>
|
||||
{str(item, "Badge") && (
|
||||
<span
|
||||
className={`hk-mobile-search__trend-badge${str(item, "Badge") === "新" ? " hk-mobile-search__trend-badge--new" : ""}`}
|
||||
>
|
||||
{str(item, "Badge")}
|
||||
</span>
|
||||
)}
|
||||
<span className="hk-mobile-search__trend-heat">
|
||||
{str(item, "Heat")}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
{array(data.recommended).length > 0 && (
|
||||
<section
|
||||
className="hk-mobile-search__recommendations"
|
||||
aria-label="猜你想搜"
|
||||
>
|
||||
<h2 className="hk-mobile-search__recommend-title">猜你想搜</h2>
|
||||
<div className="hk-mobile-search__recommend-grid">
|
||||
{array(data.recommended).map((item, index) => (
|
||||
<RecommendationCard
|
||||
key={`${str(item, "ID")}-${index}`}
|
||||
item={item}
|
||||
mobile
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<section
|
||||
className="hk-mobile-search__suggestions"
|
||||
aria-label="搜索联想"
|
||||
hidden={!hasQuery}
|
||||
>
|
||||
<div className="hk-mobile-search__suggestion-list">
|
||||
<SearchSuggestionResults
|
||||
suggestions={suggestions}
|
||||
keyword={query}
|
||||
loading={loading}
|
||||
remember={remember}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Fragment, type CSSProperties } from "react";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import { EncryptedBackground } from "@/components/media/EncryptedImage";
|
||||
import {
|
||||
DramaCard,
|
||||
RankCard,
|
||||
SeriesCard,
|
||||
TopicCard,
|
||||
} from "@/components/catalog/Cards";
|
||||
import { AdBanners, PromoGrid } from "@/components/catalog/Promotions";
|
||||
import { AppDownloadLink } from "@/components/catalog/Interactions";
|
||||
import { HeroReact } from "@/components/catalog/HeroReact";
|
||||
import {
|
||||
adSlice,
|
||||
array,
|
||||
first,
|
||||
get,
|
||||
moreListURL,
|
||||
moreSeriesListURL,
|
||||
num,
|
||||
str,
|
||||
tagPageURL,
|
||||
displayName,
|
||||
type CatalogPageProps,
|
||||
type CatalogRecord,
|
||||
} from "./model";
|
||||
|
||||
function BlockHeading({
|
||||
title,
|
||||
subtitle,
|
||||
href,
|
||||
more = "查看全部",
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
href: string;
|
||||
more?: string;
|
||||
}) {
|
||||
return (
|
||||
<header className="hk-content-section__head">
|
||||
<div className="hk-content-section__titles">
|
||||
<h2 className="hk-content-section__title">{title}</h2>
|
||||
{subtitle && <p className="hk-content-section__subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
<a className="hk-content-section__more" href={href}>
|
||||
{more}
|
||||
<Icon name="chevron-right" className="hk-icon hk-icon--sm" />
|
||||
</a>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
export function HomePage({ data }: CatalogPageProps) {
|
||||
const announcements = array(data.announcements),
|
||||
configs = array(data.homeConfigData),
|
||||
hot = array(data.hotRankPlaylets);
|
||||
let adIndex = 0;
|
||||
return (
|
||||
<>
|
||||
<h1 className="hk-visually-hidden">花魁短剧</h1>
|
||||
<HeroReact items={array(data.homeBannerData)} />
|
||||
<div className="hk-page-layout">
|
||||
<main className="hk-page-main">
|
||||
{announcements.length > 0 && (
|
||||
<section className="hk-site-announcement" aria-label="公告">
|
||||
<div className="hk-site-announcement__label">
|
||||
<Icon name="bell" />
|
||||
公告
|
||||
</div>
|
||||
<div className="hk-site-announcement__track">
|
||||
<div className="hk-site-announcement__marquee">
|
||||
{[false, true].map((clone) => (
|
||||
<div
|
||||
key={String(clone)}
|
||||
className="hk-site-announcement__marquee-group"
|
||||
aria-hidden={clone || undefined}
|
||||
>
|
||||
{announcements.map((item, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 && (
|
||||
<span
|
||||
className="hk-site-announcement__sep"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<p>
|
||||
{str(item, "Title")
|
||||
? `${str(item, "Title")}:`
|
||||
: ""}
|
||||
{str(item, "Content")}
|
||||
</p>
|
||||
</Fragment>
|
||||
))}
|
||||
<span className="hk-site-announcement__sep" aria-hidden="true" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<PromoGrid items={get(data.advitise, "NineGridList")} />
|
||||
<nav className="hk-drama-categories" aria-label="短剧标签">
|
||||
<a className="hk-drama-categories__item hk-is-active" href="/dramas/">
|
||||
全部
|
||||
</a>
|
||||
{array(data.tagOptions).map((item) => (
|
||||
<a
|
||||
key={str(item, "ID")}
|
||||
className="hk-drama-categories__item"
|
||||
href={tagPageURL(get(item, "Slug"), get(item, "ID"), 1, 1)}
|
||||
>
|
||||
{str(item, "Name")}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
{configs.map((config, configIndex) => {
|
||||
const type = num(config, "Type"),
|
||||
rank =
|
||||
type === 1 &&
|
||||
str(config, "Name") === "热播榜" &&
|
||||
hot.length > 0,
|
||||
sectionType = rank
|
||||
? "rank"
|
||||
: type === 1
|
||||
? "featured"
|
||||
: type === 2
|
||||
? "series"
|
||||
: "topic";
|
||||
if (![1, 2, 3].includes(type)) return null;
|
||||
const href = rank
|
||||
? "/rankings/"
|
||||
: type === 1
|
||||
? moreListURL(
|
||||
get(config, "Slug"),
|
||||
get(config, "ID"),
|
||||
0,
|
||||
"",
|
||||
0,
|
||||
1,
|
||||
)
|
||||
: type === 2
|
||||
? moreSeriesListURL(
|
||||
get(config, "Slug"),
|
||||
get(config, "ID"),
|
||||
0,
|
||||
"",
|
||||
0,
|
||||
1,
|
||||
)
|
||||
: "/collections/";
|
||||
const ads =
|
||||
type !== 3
|
||||
? adSlice(get(data.advitise, "DetailBannerList"), adIndex, 2)
|
||||
: [];
|
||||
if (type !== 3) adIndex += 2;
|
||||
return (
|
||||
<Fragment key={`${str(config, "ID")}-${configIndex}`}>
|
||||
<section
|
||||
className={`hk-content-section hk-content-section--${sectionType}`}
|
||||
data-id={num(config, "ID")}
|
||||
data-type={type}
|
||||
aria-label={rank ? "热播榜" : undefined}
|
||||
>
|
||||
<BlockHeading
|
||||
title={str(config, "Name")}
|
||||
subtitle={rank ? undefined : str(config, "Desc")}
|
||||
href={href}
|
||||
more={
|
||||
rank ? "完整榜单" : type === 3 ? "全部专题" : "查看全部"
|
||||
}
|
||||
/>
|
||||
{rank ? (
|
||||
<div className="hk-content-grid hk-content-grid--rank">
|
||||
{hot.map((item, index) => (
|
||||
<RankCard
|
||||
key={`${str(item, "ID")}-${index}`}
|
||||
item={item}
|
||||
rank={index + 1}
|
||||
home
|
||||
className={index >= 6 ? "hk-desktop-only" : ""}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : type === 1 ? (
|
||||
array(get(config, "List")).length > 0 && (
|
||||
<div className="hk-content-grid hk-content-grid--6">
|
||||
{array(get(config, "List")).map((item, index) => (
|
||||
<DramaCard
|
||||
key={`${str(item, "ID")}-${index}`}
|
||||
item={item}
|
||||
featured
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : type === 2 ? (
|
||||
array(get(config, "VideoList")).length > 0 ? (
|
||||
<div className="hk-content-grid hk-content-grid--series">
|
||||
{array(get(config, "VideoList")).map((item, index) => (
|
||||
<SeriesCard
|
||||
key={`${str(item, "ID")}-${index}`}
|
||||
item={item}
|
||||
home
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
array(get(config, "List")).length > 0 && (
|
||||
<div className="hk-content-grid hk-content-grid--series">
|
||||
{array(get(config, "List")).map((item, index) => (
|
||||
<SeriesCard
|
||||
key={`${str(item, "ID")}-${index}`}
|
||||
item={item}
|
||||
home
|
||||
short
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
array(get(config, "ThemeList")).length > 0 && (
|
||||
<div className="hk-collection-grid">
|
||||
{array(get(config, "ThemeList")).map((item, index) => (
|
||||
<TopicCard
|
||||
key={`${str(item, "ID")}-${index}`}
|
||||
item={item}
|
||||
home
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
{type !== 3 && <AdBanners items={ads} />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<section className="hk-app-cta">
|
||||
<div className="hk-app-cta__copy">
|
||||
<p className="hk-eyebrow">
|
||||
<span className="hk-eyebrow__bar" aria-hidden="true" />
|
||||
花魁短剧客户端
|
||||
</p>
|
||||
<h2 className="hk-content-section__title">
|
||||
在 App 上继续追剧,离线缓存随时看
|
||||
</h2>
|
||||
<p className="hk-content-section__subtitle">竖屏沉浸播放 离线缓存 弹幕互动</p>
|
||||
<p className="hk-app-cta__hint">
|
||||
点击后前往下载页,手机打开会自动识别设备并推荐安装方式
|
||||
</p>
|
||||
</div>
|
||||
<AppDownloadLink
|
||||
className="hk-app-cta__download"
|
||||
href={first(get(data.appConfig, "AppAddr"), "/app/download/")}
|
||||
>
|
||||
<Icon name="download" className="hk-app-cta__download-icon" />
|
||||
点击下载
|
||||
</AppDownloadLink>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { HomePage } from "./HomePage";
|
||||
import {
|
||||
ListPage,
|
||||
SeriesPage,
|
||||
TagsPage,
|
||||
RankPage,
|
||||
PopularRecommendPage,
|
||||
RecentlyAddedPage,
|
||||
TopicPage,
|
||||
TopicDetailPage,
|
||||
MoreListPage,
|
||||
MoreSeriesListPage,
|
||||
} from "./BrowsePages";
|
||||
import { SearchPage, SearchMobilePage } from "./DiscoveryPages";
|
||||
export {
|
||||
HomePage,
|
||||
ListPage,
|
||||
SeriesPage,
|
||||
TagsPage,
|
||||
RankPage,
|
||||
PopularRecommendPage,
|
||||
RecentlyAddedPage,
|
||||
TopicPage,
|
||||
TopicDetailPage,
|
||||
MoreListPage,
|
||||
MoreSeriesListPage,
|
||||
SearchPage,
|
||||
SearchMobilePage,
|
||||
};
|
||||
export const catalogPages = {
|
||||
Index: HomePage,
|
||||
List: ListPage,
|
||||
Series: SeriesPage,
|
||||
Tags: TagsPage,
|
||||
Rank: RankPage,
|
||||
PopularRecommend: PopularRecommendPage,
|
||||
RecentlyAdded: RecentlyAddedPage,
|
||||
Topic: TopicPage,
|
||||
TopicDetail: TopicDetailPage,
|
||||
MoreList: MoreListPage,
|
||||
MoreSeriesList: MoreSeriesListPage,
|
||||
Search: SearchPage,
|
||||
SearchMobile: SearchMobilePage,
|
||||
} as const;
|
||||
export type CatalogPageName = keyof typeof catalogPages;
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
chapterPlayURL,
|
||||
playURL,
|
||||
videoPlayURL,
|
||||
updateStatusText,
|
||||
} from "@/lib/view-helpers";
|
||||
export * from "@/lib/view-helpers";
|
||||
import type { Data } from "@/server/services/model";
|
||||
export type { Data };
|
||||
export interface CatalogPageProps {
|
||||
data: Data;
|
||||
}
|
||||
export type CatalogRecord = Record<string, unknown>;
|
||||
export const record = (value: unknown): CatalogRecord =>
|
||||
value && typeof value === "object" ? (value as CatalogRecord) : {};
|
||||
export const text = (value: unknown): string =>
|
||||
value == null ? "" : String(value);
|
||||
export const trim = (value: unknown): string => text(value).trim();
|
||||
export const number = (value: unknown): number => Number(value) || 0;
|
||||
export const array = (value: unknown): CatalogRecord[] =>
|
||||
Array.isArray(value) ? value.map(record) : [];
|
||||
export function get(value: unknown, key: string): unknown {
|
||||
const item = record(value);
|
||||
if (item[key] !== undefined) return item[key];
|
||||
const alias = Object.keys(item).find(
|
||||
(name) => name.toLowerCase() === key.toLowerCase(),
|
||||
);
|
||||
return alias ? item[alias] : undefined;
|
||||
}
|
||||
export const str = (value: unknown, key: string): string =>
|
||||
text(get(value, key));
|
||||
export const num = (value: unknown, key: string): number =>
|
||||
number(get(value, key));
|
||||
export const strings = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.map(text).filter(Boolean) : [];
|
||||
export const first = (...values: unknown[]): string =>
|
||||
text(values.find((value) => trim(value) !== "") ?? "");
|
||||
export function safeHref(value: unknown): string {
|
||||
const raw = trim(value);
|
||||
return !raw || /^(?:javascript|data|vbscript):/i.test(raw) ? "#" : raw;
|
||||
}
|
||||
export function externalProps(
|
||||
value: unknown,
|
||||
siteURL?: unknown,
|
||||
): { target?: "_blank"; rel?: string } {
|
||||
const href = trim(value);
|
||||
if (!/^(?:https?:)?\/\//i.test(href)) return {};
|
||||
if (trim(siteURL)) {
|
||||
try {
|
||||
if (
|
||||
new URL(href, "https://relative.invalid").hostname ===
|
||||
new URL(trim(siteURL)).hostname
|
||||
)
|
||||
return {};
|
||||
} catch {
|
||||
/* Invalid site configuration cannot make an external URL internal. */
|
||||
}
|
||||
}
|
||||
return { target: "_blank", rel: "sponsored nofollow noopener" };
|
||||
}
|
||||
export const episodeLabel = (item: unknown): string =>
|
||||
num(item, "VideoCount") > 0
|
||||
? `全 ${num(item, "VideoCount")} 集`
|
||||
: num(item, "VideoTime") > 0
|
||||
? `${num(item, "VideoTime")}分钟`
|
||||
: "";
|
||||
export const episodeStatus = (item: unknown): string =>
|
||||
num(item, "UpdateStatus") === 1
|
||||
? "连载中"
|
||||
: num(item, "TotalEpisode") > 0
|
||||
? `全 ${num(item, "TotalEpisode")} 集`
|
||||
: updateStatusText(get(item, "UpdateStatus"));
|
||||
/** Card meta chip: serialised dramas show progress, finished ones the total. */
|
||||
export const episodeChip = (item: unknown): string =>
|
||||
num(item, "UpdateStatus") === 1
|
||||
? num(item, "TotalEpisode") > 0
|
||||
? `连载 · ${num(item, "TotalEpisode")} 集`
|
||||
: "连载中"
|
||||
: num(item, "TotalEpisode") > 0
|
||||
? `完结 · ${num(item, "TotalEpisode")} 集`
|
||||
: updateStatusText(get(item, "UpdateStatus"));
|
||||
export const displayName = (item: unknown): string =>
|
||||
first(get(item, "PlayName"), get(item, "Title"));
|
||||
export const itemPlayURL = (item: unknown): string =>
|
||||
playURL(get(item, "Slug"), get(item, "ID"));
|
||||
export const itemVideoURL = (item: unknown): string =>
|
||||
videoPlayURL(get(item, "Slug"), get(item, "ID"));
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiRequest } from "@/lib/api";
|
||||
import { searchResultsURL } from "./model";
|
||||
export const SEARCH_HISTORY_KEY = "hgdj_search_history";
|
||||
export interface SearchSuggestion {
|
||||
title: string;
|
||||
query?: string;
|
||||
kind?: string;
|
||||
url: string;
|
||||
}
|
||||
export interface SearchFeature {
|
||||
title: string;
|
||||
cover: string;
|
||||
url: string;
|
||||
meta?: string;
|
||||
}
|
||||
export interface SearchSuggestions {
|
||||
keyword?: string;
|
||||
feature: SearchFeature | null;
|
||||
items: SearchSuggestion[];
|
||||
}
|
||||
export function readSearchHistory(): string[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(
|
||||
localStorage.getItem(SEARCH_HISTORY_KEY) || "[]",
|
||||
);
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
.filter(
|
||||
(item): item is string => typeof item === "string" && !!item.trim(),
|
||||
)
|
||||
.slice(0, 10)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export function rememberSearch(keyword: string): void {
|
||||
const value = keyword.trim();
|
||||
if (!value) return;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
SEARCH_HISTORY_KEY,
|
||||
JSON.stringify(
|
||||
[value, ...readSearchHistory().filter((item) => item !== value)].slice(
|
||||
0,
|
||||
10,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
/* Storage is optional in private browsing. */
|
||||
}
|
||||
}
|
||||
export function useSearchHistory() {
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
useEffect(() => setHistory(readSearchHistory()), []);
|
||||
const remember = useCallback((keyword: string) => {
|
||||
rememberSearch(keyword);
|
||||
setHistory(readSearchHistory());
|
||||
}, []);
|
||||
const clear = useCallback(() => {
|
||||
try {
|
||||
localStorage.removeItem(SEARCH_HISTORY_KEY);
|
||||
} catch {
|
||||
/* Storage may be disabled. */
|
||||
}
|
||||
setHistory([]);
|
||||
}, []);
|
||||
return { history, remember, clear };
|
||||
}
|
||||
export function useSearchSuggestions(keyword: string) {
|
||||
const [suggestions, setSuggestions] = useState<SearchSuggestions | null>(
|
||||
null,
|
||||
),
|
||||
[loading, setLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
const query = keyword.trim(),
|
||||
controller = new AbortController();
|
||||
if (!query) {
|
||||
setSuggestions(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setSuggestions(null);
|
||||
const timer = setTimeout(() => {
|
||||
void apiRequest<SearchSuggestions>("/api/web/search/suggest", undefined, {
|
||||
params: { keyword: query },
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((value) => {
|
||||
if (!controller.signal.aborted)
|
||||
setSuggestions(value || { feature: null, items: [] });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted)
|
||||
setSuggestions({
|
||||
feature: null,
|
||||
items: [
|
||||
{
|
||||
title: query,
|
||||
kind: "搜索",
|
||||
query,
|
||||
url: searchResultsURL(query),
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
}, 120);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [keyword]);
|
||||
return { suggestions, loading };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { PageProps } from "@/features/content/model";
|
||||
import { value, external } from "@/features/content/model";
|
||||
import { DownloadLink } from "./DownloadLink";
|
||||
export function AboutPage({ data }: PageProps) {
|
||||
const appAddr = value(data.appConfig, "AppAddr", "") || "/app/download/";
|
||||
return (
|
||||
<>
|
||||
<header className="hk-page-layout-subheader hk-mobile-only" aria-label="关于我们页顶栏">
|
||||
<a className="hk-page-layout-subheader__btn" href="/" aria-label="返回首页">
|
||||
<img
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="hk-icon"
|
||||
src="/static/images/chevron-left.svg?v=huakui-20261010"
|
||||
alt="返回"
|
||||
/>
|
||||
</a>
|
||||
<div className="hk-page-layout-subheader__title">关于我们</div>
|
||||
<span className="hk-page-layout-subheader__btn" aria-hidden="true"></span>
|
||||
</header>
|
||||
<main className="hk-about-main">
|
||||
<section className="hk-about-intro" aria-labelledby="about-title">
|
||||
<div className="hk-about-intro__heading">
|
||||
<p className="hk-about-kicker">
|
||||
<span className="hk-about-kicker__bar" aria-hidden="true"></span>
|
||||
关于我们
|
||||
</p>
|
||||
<h1 className="hk-about-intro__title" id="about-title">
|
||||
不是所有短剧,
|
||||
<br className="hk-about-intro__title-break" />
|
||||
都能被称作作品。
|
||||
</h1>
|
||||
<p className="hk-about-intro__subtitle">
|
||||
花魁短剧,因为专业,所以领先。
|
||||
</p>
|
||||
</div>
|
||||
<div className="hk-about-story">
|
||||
<div className="hk-about-story__copy">
|
||||
<p className="hk-about-story__paragraph">
|
||||
别人拼命追赶风口,我们定义创造标准。每一支作品都经过精心制作,分镜反复推敲,为一句台词的语气重新生成十次,演员的表情、字幕与剪辑都调整到满意为止。这就是我们的匠人精神,也是花魁品质唯一的定义。
|
||||
</p>
|
||||
<p className="hk-about-story__paragraph hk-about-story__paragraph--desktop">
|
||||
我们用 AI
|
||||
帮助提升作品的品质,把不可能的画面化为可能。题材多元,都会、悬疑、古装、奇幻同步推进;每日更新,精彩不断。短剧与剧集双轨并行,实拍与动画齐发。我们只做一件事:把每一支作品,做到值得被记得。
|
||||
</p>
|
||||
<p className="hk-about-story__paragraph hk-about-story__paragraph--mobile">
|
||||
我们用 AI
|
||||
帮助提升作品的品质,把不可能的画面化为可能。题材多元,每日更新,短剧与剧集双轨并行,实拍与动画齐发。我们只做一件事:把每一支作品,做到值得被记得。
|
||||
</p>
|
||||
<p className="hk-about-story__slogan">
|
||||
花魁一开,好剧连播,每一帧都值得细看。
|
||||
</p>
|
||||
<div className="hk-about-story__tags" aria-label="花魁特色">
|
||||
<span className="hk-about-story__tag">短剧与剧集双轨并行</span>
|
||||
<span className="hk-about-story__tag">实拍与动画齐发</span>
|
||||
<span className="hk-about-story__tag">AI 协力制作</span>
|
||||
<span className="hk-about-story__tag">每日更新</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hk-about-story__visual">
|
||||
<img
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
src="/static/images/about/about-hero.png?v=huakui-20261010"
|
||||
alt="花魁短剧作品画面拼贴"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
className="hk-about-features"
|
||||
aria-labelledby="about-features-title"
|
||||
>
|
||||
<div className="hk-about-features__heading">
|
||||
<h2 className="hk-about-features__title" id="about-features-title">
|
||||
把最好看的留给你
|
||||
</h2>
|
||||
<span className="hk-about-features__subtitle">花魁特色</span>
|
||||
</div>
|
||||
<div className="hk-about-features__grid">
|
||||
<article className="hk-about-feature">
|
||||
<strong className="hk-about-feature__number">01</strong>
|
||||
<div className="hk-about-feature__copy">
|
||||
<h3 className="hk-about-feature__title">多元齐全</h3>
|
||||
<p className="hk-about-feature__description">
|
||||
多元的题材、海量的短剧与剧集,我们把最好看的留给你。
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<article className="hk-about-feature">
|
||||
<strong className="hk-about-feature__number">02</strong>
|
||||
<div className="hk-about-feature__copy">
|
||||
<h3 className="hk-about-feature__title">天天上新</h3>
|
||||
<p className="hk-about-feature__description">
|
||||
持续更新的内容节奏,每天打开都有新惊喜,追剧永不断档。
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<article className="hk-about-feature">
|
||||
<strong className="hk-about-feature__number">03</strong>
|
||||
<div className="hk-about-feature__copy">
|
||||
<h3 className="hk-about-feature__title">沉浸体验</h3>
|
||||
<p className="hk-about-feature__description">
|
||||
高清画质、震撼音效,每个动作、每个画面,全心投入剧情。
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<section className="hk-about-app" aria-labelledby="about-app-title">
|
||||
<div className="hk-about-app__card">
|
||||
<div className="hk-about-app__copy">
|
||||
<p className="hk-about-app__kicker">
|
||||
<span className="hk-about-kicker__bar" aria-hidden="true"></span>
|
||||
花魁短剧客户端
|
||||
</p>
|
||||
<h2 className="hk-about-app__title" id="about-app-title">
|
||||
把花魁装进口袋,随时随地追剧
|
||||
</h2>
|
||||
<p className="hk-about-app__desc-desktop">
|
||||
iOS / Android 双端 竖屏沉浸播放 离线缓存随时看
|
||||
</p>
|
||||
<p className="hk-about-app__desc-mobile">
|
||||
竖屏沉浸播放 离线缓存 弹幕互动
|
||||
</p>
|
||||
<p className="hk-about-app__note-desktop">
|
||||
点击后前往下载页,手机打开会自动识别设备并推荐安装方式
|
||||
</p>
|
||||
</div>
|
||||
<div className="hk-about-app__actions">
|
||||
<DownloadLink
|
||||
className="hk-about-app__button hk-is-primary"
|
||||
href={appAddr}
|
||||
{...external(appAddr)}
|
||||
>
|
||||
<img
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
src="/static/images/download.svg?v=huakui-20261010"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>点击下载</span>
|
||||
</DownloadLink>
|
||||
</div>
|
||||
<p className="hk-about-app__note-mobile">
|
||||
点击后前往下载页,手机打开会自动识别设备并推荐安装方式
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
import type { PageProps } from "@/features/content/model";
|
||||
import { value } from "@/features/content/model";
|
||||
import { useLegalNavigation } from "./useLegalNavigation";
|
||||
|
||||
export function CompliancePage({ data }: PageProps) {
|
||||
const config = data.config || {};
|
||||
const contact =
|
||||
value(config, "TGGroup") ||
|
||||
value(config, "Telegram") ||
|
||||
value(config, "TelegramAdd") ||
|
||||
"https://t.me/huangguosan";
|
||||
const ref = useLegalNavigation();
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<header
|
||||
className="hk-page-layout-subheader hk-mobile-only"
|
||||
aria-label="2257 合规声明页顶栏"
|
||||
>
|
||||
<a className="hk-page-layout-subheader__btn" href="/" aria-label="返回首页">
|
||||
<img
|
||||
fetchPriority="low"
|
||||
decoding="async"
|
||||
className="hk-icon"
|
||||
src="/static/images/chevron-left.svg?v=huakui-20261010"
|
||||
alt="返回"
|
||||
/>
|
||||
</a>
|
||||
<span className="hk-page-layout-subheader__title">2257 合规声明</span>
|
||||
<span className="hk-page-layout-subheader__btn" aria-hidden="true"></span>
|
||||
</header>
|
||||
|
||||
<main className="hk-legal-main">
|
||||
<div className="hk-legal-hero">
|
||||
<p className="hk-legal-kicker">
|
||||
<span className="hk-legal-kicker__bar" aria-hidden="true"></span>2257
|
||||
合规声明
|
||||
</p>
|
||||
<h1 className="hk-legal-title">花魁短剧 2257 合规声明</h1>
|
||||
<p className="hk-legal-dates">
|
||||
生效日期 2026 年 9 月 1 日 最近更新 2026 年 9 月 1 日
|
||||
</p>
|
||||
</div>
|
||||
<div className="hk-legal-layout">
|
||||
<div className="hk-legal-toc-slot">
|
||||
<nav className="hk-legal-toc" aria-label="2257 合规声明目录">
|
||||
<span className="hk-legal-toc__label">目录</span>
|
||||
<div className="hk-legal-toc__list">
|
||||
<a className="hk-legal-toc__item hk-is-active" href="#compliance-1">
|
||||
<span className="hk-legal-toc__number">01</span>
|
||||
<span>年龄限制</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#compliance-2">
|
||||
<span className="hk-legal-toc__number">02</span>
|
||||
<span>内容合规原则</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#compliance-3">
|
||||
<span className="hk-legal-toc__number">03</span>
|
||||
<span>反馈与处理</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="hk-legal-content">
|
||||
<p className="hk-legal-intro">
|
||||
花魁短剧重视成人内容相关页面的年龄限制、合规展示和用户访问责任。本声明用于说明本站对年龄门槛、内容合规、用户责任以及投诉反馈的基本处理原则。
|
||||
</p>
|
||||
<section className="hk-legal-section" id="compliance-1">
|
||||
<h2 className="hk-legal-section__title">1. 年龄限制</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
1.1
|
||||
访问本站成人相关内容的用户,应确认自己已经达到所在地法律规定的法定年龄。若用户未达到法定年龄,或所在地区禁止访问此类内容,请立即停止访问并关闭相关页面。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
1.2
|
||||
本站不会主动面向未成年人推广成人内容。用户进入相关页面,即表示已经理解页面属性,并承诺遵守所在地法律法规。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="compliance-2">
|
||||
<h2 className="hk-legal-section__title">2. 内容合规原则</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
2.1
|
||||
本站会尽量对内容标题、简介、封面、标签和题材类型进行规范整理。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
2.2
|
||||
涉及成人属性的内容,会尽量放入对应分类,避免与非成人内容混淆。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
2.3
|
||||
如发现疑似违规、侵权、错误分类或不适合展示的内容,可通过官方入口反馈。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="compliance-3">
|
||||
<h2 className="hk-legal-section__title">3. 反馈与处理</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
3.1
|
||||
如果您是权利人、授权代理人或相关当事人,认为页面内容存在合规风险,请准备作品名称、页面位置、作品
|
||||
ID、问题说明和证明材料,再通过以下入口提交。
|
||||
</p>
|
||||
|
||||
<p className="hk-legal-section__meta">
|
||||
<span>官方处理入口</span>
|
||||
<a
|
||||
href={contact}
|
||||
target="_blank"
|
||||
rel="sponsored nofollow noopener"
|
||||
>
|
||||
{contact}
|
||||
</a>
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
3.2
|
||||
收到有效材料后,我们会根据实际情况进行核查,并采取下架、修改、屏蔽或其他合理处理方式。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
import type { PageProps } from "@/features/content/model";
|
||||
import { value } from "@/features/content/model";
|
||||
import { useLegalNavigation } from "./useLegalNavigation";
|
||||
|
||||
export function DMCAPage({ data }: PageProps) {
|
||||
const config = data.config || {};
|
||||
const contact =
|
||||
value(config, "TGGroup") ||
|
||||
value(config, "Telegram") ||
|
||||
value(config, "TelegramAdd") ||
|
||||
"https://t.me/huangguosan";
|
||||
const ref = useLegalNavigation();
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<header
|
||||
className="hk-page-layout-subheader hk-mobile-only"
|
||||
aria-label="DMCA 版权声明页顶栏"
|
||||
>
|
||||
<a className="hk-page-layout-subheader__btn" href="/" aria-label="返回首页">
|
||||
<img
|
||||
fetchPriority="low"
|
||||
decoding="async"
|
||||
className="hk-icon"
|
||||
src="/static/images/chevron-left.svg?v=huakui-20261010"
|
||||
alt="返回"
|
||||
/>
|
||||
</a>
|
||||
<span className="hk-page-layout-subheader__title">DMCA 版权声明</span>
|
||||
<span className="hk-page-layout-subheader__btn" aria-hidden="true"></span>
|
||||
</header>
|
||||
|
||||
<main className="hk-legal-main">
|
||||
<div className="hk-legal-hero">
|
||||
<p className="hk-legal-kicker">
|
||||
<span className="hk-legal-kicker__bar" aria-hidden="true"></span>DMCA
|
||||
版权声明
|
||||
</p>
|
||||
<h1 className="hk-legal-title">花魁短剧 DMCA 版权声明</h1>
|
||||
<p className="hk-legal-dates">
|
||||
生效日期 2026 年 9 月 1 日 最近更新 2026 年 9 月 1 日
|
||||
</p>
|
||||
</div>
|
||||
<div className="hk-legal-layout">
|
||||
<div className="hk-legal-toc-slot">
|
||||
<nav className="hk-legal-toc" aria-label="DMCA 版权声明目录">
|
||||
<span className="hk-legal-toc__label">目录</span>
|
||||
<div className="hk-legal-toc__list">
|
||||
<a className="hk-legal-toc__item hk-is-active" href="#dmca-1">
|
||||
<span className="hk-legal-toc__number">01</span>
|
||||
<span>版权投诉适用范围</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#dmca-2">
|
||||
<span className="hk-legal-toc__number">02</span>
|
||||
<span>投诉材料建议包含</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#dmca-3">
|
||||
<span className="hk-legal-toc__number">03</span>
|
||||
<span>处理流程</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#dmca-4">
|
||||
<span className="hk-legal-toc__number">04</span>
|
||||
<span>反通知说明</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="hk-legal-content">
|
||||
<p className="hk-legal-intro">
|
||||
花魁短剧尊重原创作品、版权方、平台方和相关权利人的合法权益。如果您认为本站展示、整理或索引的某项内容侵犯了您的版权或其他合法权益,可以按照本页面说明提交版权投诉材料。
|
||||
</p>
|
||||
<section className="hk-legal-section" id="dmca-1">
|
||||
<h2 className="hk-legal-section__title">1. 版权投诉适用范围</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
1.1
|
||||
版权投诉可用于反馈疑似未经授权展示、错误引用、侵权封面、侵权标题、侵权简介、错误资源归属或其他可能侵犯权利人合法权益的内容。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="dmca-2">
|
||||
<h2 className="hk-legal-section__title">2. 投诉材料建议包含</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
2.1 权利人或授权代理人的姓名、联系方式与身份证明。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
2.2 授权代理人提交投诉时,需要提供有效授权文件。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
2.3 被投诉内容的剧名、作品 ID、页面位置、具体集数或截图。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
2.4
|
||||
能够证明权利归属的材料,例如作品登记信息、授权协议、发布证明或其他有效文件。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
2.5
|
||||
投诉内容真实、准确、完整的声明,并说明愿意承担不实投诉带来的责任。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="dmca-3">
|
||||
<h2 className="hk-legal-section__title">3. 处理流程</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
3.1
|
||||
收到有效材料后,花魁短剧会尽快进行初步核查。若材料完整且问题明确,我们会根据情况采取删除、下架、屏蔽、修改信息或限制展示等处理。若材料不完整,我们可能需要投诉方补充信息。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="dmca-4">
|
||||
<h2 className="hk-legal-section__title">4. 反通知说明</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
4.1
|
||||
如果内容提供方认为相关内容被误删、误下架或误判,可以提交反通知材料,说明权利来源、授权依据和恢复理由。
|
||||
</p>
|
||||
|
||||
<p className="hk-legal-section__meta">
|
||||
<span>版权处理入口</span>
|
||||
<a
|
||||
href={contact}
|
||||
target="_blank"
|
||||
rel="sponsored nofollow noopener"
|
||||
>
|
||||
{contact}
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
import type { AnchorHTMLAttributes } from "react";
|
||||
import { useSite } from "@/components/providers/SiteProvider";
|
||||
export function DownloadLink({
|
||||
onClick,
|
||||
...props
|
||||
}: AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
const site = useSite();
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
data-download-open=""
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
event.preventDefault();
|
||||
site.openDownload();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import type { PageProps } from "@/features/content/model";
|
||||
import { value } from "@/features/content/model";
|
||||
import { ApiError, apiRequest } from "@/lib/api";
|
||||
import { useSite } from "@/components/providers/SiteProvider";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
const types = [
|
||||
{ key: "play", label: "播放问题", id: 2 },
|
||||
{ key: "account", label: "账号与支付", id: 3 },
|
||||
{ key: "report", label: "内容举报", id: 4 },
|
||||
{ key: "suggest", label: "功能建议", id: 1 },
|
||||
{ key: "other", label: "其他", id: 4 },
|
||||
];
|
||||
interface Screenshot {
|
||||
file: File;
|
||||
preview: string;
|
||||
url?: string;
|
||||
uploading?: boolean;
|
||||
error?: boolean;
|
||||
}
|
||||
export function FeedbackPage({ data }: PageProps) {
|
||||
const site = useSite(),
|
||||
[type, setType] = useState("play"),
|
||||
[content, setContent] = useState(""),
|
||||
[contact, setContact] = useState(""),
|
||||
[files, setFiles] = useState<Screenshot[]>([]),
|
||||
[busy, setBusy] = useState(false),
|
||||
[status, setStatus] = useState("");
|
||||
const input = useRef<HTMLInputElement>(null),
|
||||
fileRef = useRef(files);
|
||||
fileRef.current = files;
|
||||
useEffect(
|
||||
() => () =>
|
||||
fileRef.current.forEach((item) => URL.revokeObjectURL(item.preview)),
|
||||
[],
|
||||
);
|
||||
const config = data.config || {},
|
||||
email = value(config, "GmailAddr", ""),
|
||||
telegram = value(config, "TelegramURL", ""),
|
||||
handle = value(config, "TelegramHandle", "");
|
||||
function addFiles(selected: FileList | null) {
|
||||
if (!selected) return;
|
||||
let rejected = false,
|
||||
full = false;
|
||||
const next = [...files];
|
||||
Array.from(selected).forEach((file) => {
|
||||
if (
|
||||
!/^(image\/(jpeg|png|gif))$/i.test(file.type) &&
|
||||
!/\.(jpe?g|png|gif)$/i.test(file.name)
|
||||
) {
|
||||
rejected = true;
|
||||
return;
|
||||
}
|
||||
if (next.length >= 3) {
|
||||
full = true;
|
||||
return;
|
||||
}
|
||||
next.push({ file, preview: URL.createObjectURL(file) });
|
||||
});
|
||||
setFiles(next);
|
||||
if (input.current) input.current.value = "";
|
||||
if (rejected) setStatus("仅支持 jpg / png / gif 格式");
|
||||
else if (full) setStatus("最多上传 3 张图片");
|
||||
}
|
||||
function remove(index: number) {
|
||||
URL.revokeObjectURL(files[index].preview);
|
||||
setFiles(files.filter((_, i) => i !== index));
|
||||
}
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
const text = content.trim();
|
||||
if (!text) {
|
||||
setStatus("请填写问题描述");
|
||||
return;
|
||||
}
|
||||
if (Array.from(text).length > 500) {
|
||||
setStatus("问题描述最多 500 字");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setStatus("正在提交…");
|
||||
try {
|
||||
const screenshots: string[] = [];
|
||||
for (const item of files) {
|
||||
if (!item.url) {
|
||||
item.uploading = true;
|
||||
item.error = false;
|
||||
setFiles([...files]);
|
||||
const body = new FormData();
|
||||
body.append("file", item.file, item.file.name);
|
||||
try {
|
||||
const result = await apiRequest<string | Record<string, unknown>>(
|
||||
"/api/upload/image",
|
||||
body,
|
||||
);
|
||||
const url =
|
||||
typeof result === "string"
|
||||
? result.trim()
|
||||
: String(
|
||||
result.sourceNameFs ||
|
||||
result.url ||
|
||||
result.path ||
|
||||
result.id ||
|
||||
"",
|
||||
).trim();
|
||||
if (!url) throw new Error("上传成功但未返回图片地址");
|
||||
item.url = url;
|
||||
} catch (error) {
|
||||
item.error = true;
|
||||
throw new Error(
|
||||
error instanceof ApiError && error.message.trim()
|
||||
? `图片上传失败:${error.message.trim()}`
|
||||
: "图片上传失败,请稍后重试",
|
||||
);
|
||||
} finally {
|
||||
item.uploading = false;
|
||||
setFiles([...files]);
|
||||
}
|
||||
}
|
||||
screenshots.push(item.url!);
|
||||
}
|
||||
await apiRequest("/api/feedback/add", {
|
||||
type: types.find((item) => item.key === type)!.id,
|
||||
content: text,
|
||||
contact: contact.trim(),
|
||||
screenshots,
|
||||
});
|
||||
files.forEach((item) => URL.revokeObjectURL(item.preview));
|
||||
setFiles([]);
|
||||
setContent("");
|
||||
setContact("");
|
||||
setType("play");
|
||||
setStatus("反馈提交成功");
|
||||
site.notify("反馈提交成功");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "提交失败,请稍后重试";
|
||||
setStatus(message);
|
||||
site.notify(message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<header className="hk-page-layout-subheader hk-mobile-only" aria-label="意见反馈顶栏">
|
||||
<a className="hk-page-layout-subheader__btn" href="/" aria-label="返回">
|
||||
<Icon name="chevron-left" alt="返回" />
|
||||
</a>
|
||||
<div className="hk-page-layout-subheader__title">意见反馈</div>
|
||||
<span className="hk-page-layout-subheader__btn" aria-hidden="true" />
|
||||
</header>
|
||||
<div className="hk-page-layout hk-feedback-page">
|
||||
<main className="hk-page-main hk-feedback-main">
|
||||
<section className="hk-feedback-hero" aria-label="意见反馈">
|
||||
<p className="hk-eyebrow">
|
||||
<span className="hk-eyebrow__bar" aria-hidden="true" />
|
||||
意见反馈
|
||||
</p>
|
||||
<h1 className="hk-feedback-hero__title">告诉我们哪里可以更好</h1>
|
||||
<p className="hk-feedback-hero__desc">
|
||||
播放、账号、支付、内容举报或功能建议,我们都会认真处理。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-feedback-layout" aria-label="反馈表单">
|
||||
<form
|
||||
className="hk-feedback-form"
|
||||
action="#"
|
||||
method="post"
|
||||
data-feedback-form=""
|
||||
onSubmit={submit}
|
||||
>
|
||||
<div className="hk-feedback-form__field">
|
||||
<span className="hk-feedback-form__label">问题类型</span>
|
||||
<div
|
||||
className="hk-feedback-form__types"
|
||||
role="radiogroup"
|
||||
aria-label="问题类型"
|
||||
>
|
||||
{types.map((item) => (
|
||||
<label
|
||||
className={
|
||||
"hk-feedback-type" +
|
||||
(type === item.key ? " hk-is-active" : "")
|
||||
}
|
||||
key={item.key}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="type"
|
||||
value={item.key}
|
||||
checked={type === item.key}
|
||||
disabled={busy}
|
||||
onChange={() => setType(item.key)}
|
||||
/>
|
||||
{item.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="hk-feedback-form__field">
|
||||
<span className="hk-feedback-form__label">问题描述</span>
|
||||
<span className="hk-feedback-form__textarea-wrap">
|
||||
<textarea
|
||||
className="hk-feedback-form__textarea"
|
||||
name="content"
|
||||
maxLength={1000}
|
||||
rows={6}
|
||||
placeholder="请描述遇到的问题、发生的页面与操作步骤,越具体越好"
|
||||
data-feedback-content=""
|
||||
required
|
||||
value={content}
|
||||
onChange={(event) =>
|
||||
setContent(
|
||||
Array.from(event.target.value).slice(0, 500).join(""),
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
<span className="hk-feedback-form__count">
|
||||
<span data-feedback-count="">
|
||||
{Array.from(content).length}
|
||||
</span>{" "}
|
||||
/ 500
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="hk-feedback-form__field">
|
||||
<span className="hk-feedback-form__label">
|
||||
截图(选填,最多 3 张)
|
||||
</span>
|
||||
<div className="hk-feedback-uploads" data-feedback-uploads="">
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.gif,image/jpeg,image/png,image/gif"
|
||||
multiple
|
||||
hidden
|
||||
data-feedback-file=""
|
||||
disabled={busy}
|
||||
onChange={(event) => addFiles(event.target.files)}
|
||||
/>
|
||||
{files.map((item, index) => (
|
||||
<div
|
||||
className={
|
||||
"hk-feedback-upload hk-feedback-upload--preview" +
|
||||
(item.uploading ? " hk-is-uploading" : "") +
|
||||
(item.error ? " hk-is-error" : "")
|
||||
}
|
||||
key={item.preview}
|
||||
>
|
||||
<img src={item.preview} alt={item.file.name || "截图"} />
|
||||
{item.error && (
|
||||
<span className="hk-feedback-upload__error">上传失败</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="hk-feedback-upload__remove"
|
||||
aria-label="删除截图"
|
||||
disabled={busy}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Icon name="close" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{files.length < 3 && (
|
||||
<button
|
||||
type="button"
|
||||
className="hk-feedback-upload hk-feedback-upload--add"
|
||||
disabled={busy}
|
||||
onClick={() => input.current?.click()}
|
||||
>
|
||||
<Icon name="plus-dim" alt="添加图片" />
|
||||
<span>上传截图</span>
|
||||
</button>
|
||||
)}
|
||||
{Array.from(
|
||||
{ length: Math.max(0, 2 - files.length) },
|
||||
(_, index) => (
|
||||
<button
|
||||
className="hk-feedback-upload hk-feedback-upload--placeholder"
|
||||
key={index}
|
||||
type="button"
|
||||
aria-label="添加截图"
|
||||
disabled={busy}
|
||||
onClick={() => input.current?.click()}
|
||||
>
|
||||
<Icon name="image-dim" alt="" />
|
||||
<span />
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<label className="hk-feedback-form__field">
|
||||
<span className="hk-feedback-form__label">联系方式(选填)</span>
|
||||
<span className="hk-feedback-form__control">
|
||||
<Icon name="mail-dim" alt="联系邮箱" />
|
||||
<input
|
||||
className="hk-feedback-form__input"
|
||||
type="text"
|
||||
name="contact"
|
||||
autoComplete="email"
|
||||
placeholder="邮箱或手机号,方便我们回复你"
|
||||
value={contact}
|
||||
disabled={busy}
|
||||
onChange={(event) => setContact(event.target.value)}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<div className="hk-feedback-form__foot">
|
||||
<button
|
||||
className="hk-button hk-button--primary hk-feedback-form__submit"
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
>
|
||||
<Icon name="send" alt="发送" />
|
||||
{busy ? "提交中…" : "提交反馈"}
|
||||
</button>
|
||||
<p className="hk-feedback-form__hint">
|
||||
提交后我们会通过你留下的联系方式回复处理进度
|
||||
</p>
|
||||
</div>
|
||||
{status && (
|
||||
<p role="status" className="hk-feedback-form__hint">
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
<aside className="hk-feedback-aside" aria-label="帮助与联系">
|
||||
<div className="hk-feedback-aside__card hk-desktop-only">
|
||||
<h2 className="hk-feedback-aside__title">先看看这些是否有帮助</h2>
|
||||
{[
|
||||
"花魁短剧网页版可以看全集吗?",
|
||||
"网页版需要注册或登录吗?",
|
||||
"内容多久更新一次?",
|
||||
"如何联系客服或洽谈合作?",
|
||||
].map((text) => (
|
||||
<a className="hk-feedback-aside__link" href="/help/" key={text}>
|
||||
<Icon name="feedback/help-circle" alt="帮助" />
|
||||
{text}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className="hk-feedback-aside__card hk-feedback-aside__card--muted">
|
||||
<h2 className="hk-feedback-aside__title">其他联系方式</h2>
|
||||
{email && (
|
||||
<p className="hk-feedback-aside__row">
|
||||
<Icon name="mail-dim" alt="联系邮箱" />
|
||||
<a
|
||||
href={"mailto:" + email}
|
||||
rel="sponsored nofollow noopener"
|
||||
>
|
||||
{email}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{telegram && (
|
||||
<p className="hk-feedback-aside__row">
|
||||
<Icon name="send" alt="发送" />
|
||||
<a
|
||||
href={telegram}
|
||||
target="_blank"
|
||||
rel="sponsored nofollow noopener"
|
||||
>
|
||||
Telegram{handle && " " + handle}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<p className="hk-feedback-aside__row">
|
||||
<Icon name="clock-dim" alt="发生时间" />
|
||||
<span>工作日 10:00 至 22:00</span>
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import { items, value, type PageProps } from "@/features/content/model";
|
||||
import { DownloadLink } from "./DownloadLink";
|
||||
export function HelpPage({ data }: PageProps) {
|
||||
const [open, setOpen] = useState<number | null>(0),
|
||||
questions = items(data, "faqItems");
|
||||
return (
|
||||
<>
|
||||
<header className="hk-page-layout-subheader hk-mobile-only" aria-label="常见问题页顶栏">
|
||||
<a className="hk-page-layout-subheader__btn" href="/" aria-label="返回首页">
|
||||
<Icon name="chevron-left" alt="返回" />
|
||||
</a>
|
||||
<div className="hk-page-layout-subheader__title">常见问题</div>
|
||||
<span className="hk-page-layout-subheader__btn" aria-hidden="true" />
|
||||
</header>
|
||||
<main className="hk-help-main">
|
||||
<div className="hk-help-heading">
|
||||
<p className="hk-help-kicker">
|
||||
<span aria-hidden="true" />
|
||||
常见问题
|
||||
</p>
|
||||
<h1>关于花魁,你想知道的</h1>
|
||||
<p className="hk-help-heading__intro">
|
||||
找不到答案?欢迎通过意见反馈或邮箱联系我们。
|
||||
</p>
|
||||
</div>
|
||||
<div className="hk-help-content">
|
||||
<section
|
||||
className="hk-help-faq"
|
||||
aria-label="常见问题列表"
|
||||
data-help-faq=""
|
||||
>
|
||||
{questions.map((question, index) => (
|
||||
<details
|
||||
className="hk-help-faq__item"
|
||||
key={index}
|
||||
open={open === index}
|
||||
onToggle={(event) => {
|
||||
if (event.currentTarget.open) setOpen(index);
|
||||
else if (open === index) setOpen(null);
|
||||
}}
|
||||
>
|
||||
<summary>
|
||||
{value(question, "Question")}
|
||||
<span className="hk-help-faq__chevron" aria-hidden="true" />
|
||||
</summary>
|
||||
<p>{value(question, "Answer")}</p>
|
||||
</details>
|
||||
))}
|
||||
</section>
|
||||
<aside className="hk-help-aside">
|
||||
<section
|
||||
className="hk-help-contact"
|
||||
aria-labelledby="help-contact-title"
|
||||
>
|
||||
<h2 id="help-contact-title">还有其他问题?</h2>
|
||||
<p>工作日 10:00 至 22:00 在线,通常 1 个工作日内回复。</p>
|
||||
<a className="hk-help-contact__button" href="/feedback/">
|
||||
<Icon name="send" />
|
||||
提交意见反馈
|
||||
</a>
|
||||
<a
|
||||
className="hk-help-contact__email"
|
||||
href="mailto:support@huakuidj.com"
|
||||
rel="sponsored nofollow noopener"
|
||||
>
|
||||
<Icon name="mail-dim" />
|
||||
support@huakuidj.com
|
||||
</a>
|
||||
</section>
|
||||
<section className="hk-help-app" aria-labelledby="help-app-title">
|
||||
<h2 id="help-app-title">在 App 上体验更多</h2>
|
||||
<p>离线缓存、弹幕互动、随时随地看</p>
|
||||
<DownloadLink
|
||||
href={value(data.appConfig, "AppAddr") || "/app/download/"}
|
||||
target="_blank"
|
||||
rel="sponsored nofollow noopener"
|
||||
>
|
||||
<Icon name="download" />
|
||||
下载 App
|
||||
</DownloadLink>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
function fallbackCopy(text: string): boolean {
|
||||
const focused = document.activeElement;
|
||||
const selection = window.getSelection();
|
||||
const ranges = selection
|
||||
? Array.from({ length: selection.rangeCount }, (_, index) =>
|
||||
selection.getRangeAt(index).cloneRange(),
|
||||
)
|
||||
: [];
|
||||
const input = document.createElement("textarea");
|
||||
input.value = text;
|
||||
input.readOnly = true;
|
||||
input.style.cssText = "position:fixed;inset:0;opacity:0;pointer-events:none";
|
||||
document.body.appendChild(input);
|
||||
try {
|
||||
input.focus();
|
||||
input.select();
|
||||
input.setSelectionRange(0, text.length);
|
||||
return document.execCommand("copy");
|
||||
} finally {
|
||||
input.remove();
|
||||
if (focused instanceof HTMLElement) focused.focus({ preventScroll: true });
|
||||
selection?.removeAllRanges();
|
||||
ranges.forEach((range) => selection?.addRange(range));
|
||||
}
|
||||
}
|
||||
|
||||
export function HomeRoadCopyButton({
|
||||
value,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
value?: string;
|
||||
className: string;
|
||||
children: string;
|
||||
}) {
|
||||
const [status, setStatus] = useState("");
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => () => {
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
}, []);
|
||||
|
||||
async function copy() {
|
||||
let text = window.location.href;
|
||||
try {
|
||||
if (value) {
|
||||
const absolute = new URL(value, window.location.href);
|
||||
text = /^https?:\/\//i.test(value) ? value : absolute.href;
|
||||
}
|
||||
} catch {
|
||||
setStatus("复制失败,请手动复制");
|
||||
return;
|
||||
}
|
||||
let copied = false;
|
||||
if (window.isSecureContext && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied = true;
|
||||
} catch {
|
||||
// Clipboard permission can fail even on HTTPS; retain the HTTP fallback.
|
||||
}
|
||||
}
|
||||
if (!copied) {
|
||||
try {
|
||||
copied = fallbackCopy(text);
|
||||
} catch {
|
||||
copied = false;
|
||||
}
|
||||
}
|
||||
setStatus(copied ? "已复制" : "复制失败,请手动复制");
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
resetTimer.current = setTimeout(() => setStatus(""), 1600);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
type="button"
|
||||
onClick={copy}
|
||||
aria-label={status ? `${children}:${status}` : children}
|
||||
aria-live="polite"
|
||||
>
|
||||
{status || children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { items, value, external, type PageProps } from "@/features/content/model";
|
||||
import { brandAssets } from "@/lib/brand-assets";
|
||||
import { toPublicURL } from "@/lib/routes";
|
||||
import { HomeRoadCopyButton } from "./HomeRoadCopyButton";
|
||||
|
||||
const icon = (name: string) => `/static/images/home-road/${name}.svg`;
|
||||
const displayURL = (url: string) =>
|
||||
url.replace(/^https?:\/\//i, "").replace(/\/+$/, "") || "/";
|
||||
|
||||
const fallbackFAQ = [
|
||||
{
|
||||
Question: "回家的路页面有什么作用?",
|
||||
Answer: "集中展示最新地址、永久入口、备用入口和保存方式,方便访问异常时快速返回花魁短剧。",
|
||||
},
|
||||
{
|
||||
Question: "建议保存哪个入口?",
|
||||
Answer: "优先收藏当前推荐入口,并额外保存永久入口或备用入口。",
|
||||
},
|
||||
{
|
||||
Question: "入口打不开怎么办?",
|
||||
Answer: "先切换网络,再尝试永久入口或备用入口;仍无法访问时返回本页查看最新地址。",
|
||||
},
|
||||
{
|
||||
Question: "如何确认入口是否安全?",
|
||||
Answer: "只从本页、站内导航或花魁短剧官方提示进入,不要从陌生弹窗、未知短链跳转。",
|
||||
},
|
||||
];
|
||||
|
||||
export function HomeRoadPage({ data }: PageProps) {
|
||||
const config = data.appConfig || {};
|
||||
const url = (raw: unknown): string => {
|
||||
const text = String(raw || "").trim();
|
||||
if (!/^https?:\/\//i.test(text) && !/^\/(?!\/)/.test(text)) return "";
|
||||
if (/[\u0000-\u0020\\]/.test(text)) return "";
|
||||
try {
|
||||
new URL(text, "https://home-road.invalid");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
return toPublicURL(text, data.siteURL);
|
||||
};
|
||||
const configuredURL = (name: string) => url(value(config, name, ""));
|
||||
const mainURL = configuredURL("NewURLAddr") || configuredURL("ForeverURLAddr") || "/";
|
||||
const shareURL = url(data.canonicalURL) ||
|
||||
(url(data.siteURL) ? `${url(data.siteURL).replace(/\/+$/, "")}/return-home/` : "/return-home/");
|
||||
const routeURLs = [
|
||||
...items(config, "NavPageList").map((entry) => url(value(entry, "URL", ""))),
|
||||
...["NewURLAddr", "NewURLAddr1", "NewURLAddr2"].map(configuredURL),
|
||||
].filter(Boolean).slice(0, 3);
|
||||
while (routeURLs.length < 3) routeURLs.push(mainURL);
|
||||
const permanent = [
|
||||
{ label: "永久地址", url: configuredURL("ForeverURLAddr") },
|
||||
{ label: "永久中转页", url: configuredURL("NewURLAddr") },
|
||||
{ label: "最新中转页(免翻墙)", url: configuredURL("NewURLAddr1") },
|
||||
].filter((entry) => entry.url);
|
||||
const email = String(value(config, "GmailAddr", "")).trim().replace(/^mailto:/i, "");
|
||||
const contacts = [
|
||||
{
|
||||
title: "✉ 官方联系邮箱",
|
||||
description: "通过官方邮箱联系花魁短剧,获取最新地址与入口信息。",
|
||||
action: `${email} →`,
|
||||
url: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? `mailto:${email}` : "",
|
||||
},
|
||||
{
|
||||
title: "➤ TG 官方交流群",
|
||||
description: "获取域名变更通知的首选渠道,实时播报并加入用户互助社区。",
|
||||
action: "立即订阅最新动态 →",
|
||||
url: configuredURL("TGGroup"),
|
||||
},
|
||||
{
|
||||
title: "◆ 官方商务合作",
|
||||
description: "联系官方商务合作,洽谈商务事宜,精诚合作,财富共赢。",
|
||||
action: "立即联系 →",
|
||||
url: configuredURL("Telegram"),
|
||||
},
|
||||
{
|
||||
title: "𝕏 推特社区",
|
||||
description: "关注官方推特社区,第一时间获取地址变更与官方公告。",
|
||||
action: "快速访问 →",
|
||||
url: configuredURL("TwitterAddr"),
|
||||
},
|
||||
].filter((entry) => entry.url);
|
||||
const faqItems = items(data, "faqItems");
|
||||
|
||||
return (
|
||||
<main className="hk-home-road-page">
|
||||
<nav className="hk-home-road-breadcrumb" aria-label="面包屑">
|
||||
<a href="/">首页</a><span aria-hidden="true">/</span><span aria-current="page">回家的路</span>
|
||||
</nav>
|
||||
|
||||
<section className="hk-home-road-hero" aria-labelledby="homeRoadTitle">
|
||||
<div className="hk-home-road-official">
|
||||
<img src={icon("official")} width="20" height="20" alt="" />
|
||||
<span>官方权威发布 · 永久有效入口</span>
|
||||
</div>
|
||||
<img className="hk-home-road-hero-logo" src={brandAssets.navigationLogo} width="366" height="114" alt="花魁短剧" />
|
||||
<h1 id="homeRoadTitle">花魁短剧 · 官方最新地址发布页</h1>
|
||||
<p>实时同步最新可用线路与备用入口,帮助你在不同网络环境下快速返回。</p>
|
||||
<div className="hk-home-road-hero-actions">
|
||||
<a className="hk-home-road-btn hk-home-road-btn--primary" href={mainURL} {...external(mainURL)}>立即进入主站</a>
|
||||
<HomeRoadCopyButton className="hk-home-road-btn hk-home-road-btn--secondary" value={shareURL}>复制分享链接</HomeRoadCopyButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="hk-home-road-panel hk-home-road-routes" aria-labelledby="homeRoadRoutes">
|
||||
<div className="hk-home-road-section-heading">
|
||||
<img src={icon("rocket")} width="48" height="48" alt="" />
|
||||
<h2 id="homeRoadRoutes">国内可用地址</h2><span>(直连无需代理)</span>
|
||||
</div>
|
||||
<p className="hk-home-road-section-desc">优先选择适合当前网络的线路,无法打开时可尝试备用地址。</p>
|
||||
<div className="hk-home-road-route-grid">
|
||||
{routeURLs.map((routeURL, index) => (
|
||||
<article className="hk-home-road-route-card" key={index}>
|
||||
<div className="hk-home-road-route-head"><h3>备用地址 {String(index + 1).padStart(2, "0")}</h3><span>● 极速</span></div>
|
||||
<div className="hk-home-road-route-url" title={routeURL}>{displayURL(routeURL)}</div>
|
||||
<div className="hk-home-road-route-actions">
|
||||
<a className="hk-home-road-small-btn hk-home-road-small-btn--primary" href={routeURL} {...external(routeURL)}>立即进入</a>
|
||||
<HomeRoadCopyButton className="hk-home-road-small-btn" value={routeURL}>复制地址</HomeRoadCopyButton>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="hk-home-road-panel hk-home-road-permanent" aria-labelledby="homeRoadPermanent">
|
||||
<div className="hk-home-road-section-heading">
|
||||
<img src={icon("link")} width="48" height="48" alt="" />
|
||||
<h2 id="homeRoadPermanent">永久访问地址</h2><span>建议收藏</span>
|
||||
</div>
|
||||
<p className="hk-home-road-section-desc">同时保存永久入口和备用入口,主域名变更时仍可返回。</p>
|
||||
<div className="hk-home-road-permanent-list">
|
||||
{permanent.length ? permanent.map((entry) => (
|
||||
<article className="hk-home-road-permanent-row" key={entry.label}>
|
||||
<div className="hk-home-road-permanent-copy"><span>{entry.label}</span><strong title={entry.url}>{displayURL(entry.url)}</strong></div>
|
||||
<HomeRoadCopyButton className="hk-home-road-save-btn" value={entry.url}>复制并保存</HomeRoadCopyButton>
|
||||
</article>
|
||||
)) : <p className="hk-home-road-section-desc">永久入口正在更新,请先使用上方可用地址。</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="hk-home-road-panel hk-home-road-contact" aria-labelledby="homeRoadContact">
|
||||
<div className="hk-home-road-section-heading">
|
||||
<img src={icon("shield")} width="48" height="48" alt="" />
|
||||
<h2 id="homeRoadContact">防失联方式</h2><span>建议至少保存 2 种</span>
|
||||
</div>
|
||||
<p className="hk-home-road-section-desc">通过以下方式获取最新地址或接收线路变更提醒。</p>
|
||||
<div className="hk-home-road-contact-grid">
|
||||
{contacts.map((contact) => (
|
||||
<a className="hk-home-road-contact-card" href={contact.url} key={contact.title} {...external(contact.url)}>
|
||||
<h3>{contact.title}</h3><p>{contact.description}</p><span>{contact.action}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
{!contacts.length && <p className="hk-home-road-section-desc">官方联系方式正在更新,请收藏本页获取最新入口。</p>}
|
||||
</section>
|
||||
|
||||
<section className="hk-home-road-panel hk-home-road-troubleshoot" aria-labelledby="homeRoadTroubleshoot">
|
||||
<div className="hk-home-road-section-heading">
|
||||
<img src={icon("troubleshoot")} width="48" height="48" alt="" />
|
||||
<h2 id="homeRoadTroubleshoot">访问异常排查</h2><span>安全提示</span>
|
||||
</div>
|
||||
<p className="hk-home-road-section-desc">遇到打不开、跳转慢或入口失效时,按顺序完成排查。</p>
|
||||
<div className="hk-home-road-step-grid">
|
||||
<article><h3><b>01</b>打不开页面</h3><p>先切换网络,再尝试永久入口或备用入口。</p></article>
|
||||
<article><h3><b>02</b>地址跳转异常</h3><p>确认地址拼写完整,不要从陌生弹窗进入。</p></article>
|
||||
<article><h3><b>03</b>入口失效</h3><p>返回本页查看最新入口,或从收藏页重新进入。</p></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="hk-home-road-panel hk-home-road-devices" aria-labelledby="homeRoadDevices">
|
||||
<div className="hk-home-road-section-heading">
|
||||
<img src={icon("devices")} width="48" height="48" alt="" />
|
||||
<h2 id="homeRoadDevices">设备使用与兼容性建议</h2>
|
||||
</div>
|
||||
<p className="hk-home-road-section-desc">建议使用主流浏览器访问,以获得更好的兼容性与加载速度;遇到缓慢或无法访问时可切换线路或刷新页面。</p>
|
||||
<div className="hk-home-road-device-grid">
|
||||
<article>
|
||||
<h3><img src={icon("iphone")} width="24" height="24" alt="" />iPhone 用户</h3>
|
||||
<p className="hk-home-road-good">✓ 推荐使用 Safari 浏览器</p><p className="hk-home-road-warn">! 避免在微信 / QQ 等第三方 App 内打开</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3><img src={icon("android")} width="24" height="24" alt="" />Android 用户</h3>
|
||||
<p className="hk-home-road-good">✓ 推荐使用 Google Chrome 浏览器</p><p className="hk-home-road-warn">! 自带浏览器可能存在兼容与域名拦截风险</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3><img src={icon("desktop")} width="24" height="24" alt="" />电脑 / 桌面端</h3>
|
||||
<p className="hk-home-road-good">✓ 首选 Chrome / Edge 浏览器</p><p className="hk-home-road-warn">! 无法加载时可清除 DNS 缓存或更换公共 DNS</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="hk-home-road-panel hk-home-road-faq" aria-labelledby="homeRoadFAQ">
|
||||
<h2 id="homeRoadFAQ">FAQ</h2>
|
||||
<div className="hk-home-road-faq-list">
|
||||
{(faqItems.length ? faqItems : fallbackFAQ).map((entry, index) => (
|
||||
<article key={index}><h3>{value(entry, "Question", "")}</h3><p>{value(entry, "Answer", "")}</p></article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PageProps } from "@/features/content/model";
|
||||
import { TermsPage } from "./TermsPage";
|
||||
import { PrivacyPage } from "./PrivacyPage";
|
||||
import { CompliancePage } from "./CompliancePage";
|
||||
import { DMCAPage } from "./DMCAPage";
|
||||
export { TermsPage, PrivacyPage, CompliancePage, DMCAPage };
|
||||
export function LegalPage({
|
||||
kind,
|
||||
...props
|
||||
}: PageProps & { kind: "terms" | "privacy" | "2257" | "dmca" }) {
|
||||
const Page = {
|
||||
terms: TermsPage,
|
||||
privacy: PrivacyPage,
|
||||
"2257": CompliancePage,
|
||||
dmca: DMCAPage,
|
||||
}[kind];
|
||||
return <Page {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { PageProps } from "@/features/content/model";
|
||||
|
||||
export function NotFoundPage({ data }: PageProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="hk-page-layout hk-not-found-page">
|
||||
<main className="hk-page-main hk-not-found">
|
||||
<p className="hk-not-found__code" aria-hidden="true">
|
||||
404
|
||||
</p>
|
||||
<h1 className="hk-not-found__title">页面走丢了</h1>
|
||||
<p className="hk-not-found__desc">
|
||||
你访问的页面不存在、已下线,或者链接输错了。试试回到首页,或联系我们。
|
||||
</p>
|
||||
<div className="hk-not-found__actions">
|
||||
<a className="hk-button hk-button--primary hk-button--lg hk-not-found__cta" href="/">
|
||||
<img
|
||||
fetchPriority="low"
|
||||
decoding="async"
|
||||
className="hk-icon"
|
||||
src="/static/images/home.svg?v=huakui-20261010"
|
||||
alt="返回首页"
|
||||
/>
|
||||
返回首页
|
||||
</a>
|
||||
<a
|
||||
className="hk-button hk-button--ghost hk-button--lg hk-not-found__cta"
|
||||
href="/feedback/"
|
||||
>
|
||||
<img
|
||||
fetchPriority="low"
|
||||
decoding="async"
|
||||
className="hk-icon"
|
||||
src="/static/images/mail.svg?v=huakui-20261010"
|
||||
alt="意见反馈"
|
||||
/>
|
||||
联系我们
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
import type { PageProps } from "@/features/content/model";
|
||||
import { useLegalNavigation } from "./useLegalNavigation";
|
||||
|
||||
export function PrivacyPage({ data }: PageProps) {
|
||||
const ref = useLegalNavigation();
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<header className="hk-page-layout-subheader hk-mobile-only" aria-label="隐私政策页顶栏">
|
||||
<a className="hk-page-layout-subheader__btn" href="/" aria-label="返回首页">
|
||||
<img
|
||||
fetchPriority="low"
|
||||
decoding="async"
|
||||
className="hk-icon"
|
||||
src="/static/images/chevron-left.svg?v=huakui-20261010"
|
||||
alt="返回"
|
||||
/>
|
||||
</a>
|
||||
<div className="hk-page-layout-subheader__title">隐私政策</div>
|
||||
<span className="hk-page-layout-subheader__btn" aria-hidden="true"></span>
|
||||
</header>
|
||||
|
||||
<main className="hk-legal-main">
|
||||
<div className="hk-legal-hero">
|
||||
<p className="hk-legal-kicker">
|
||||
<span className="hk-legal-kicker__bar" aria-hidden="true"></span>
|
||||
隐私政策
|
||||
</p>
|
||||
<h1 className="hk-legal-title">花魁短剧隐私政策</h1>
|
||||
<p className="hk-legal-dates">
|
||||
生效日期 2026 年 9 月 1 日 最近更新 2026 年 9 月 1 日
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hk-legal-layout">
|
||||
<div className="hk-legal-toc-slot">
|
||||
<nav className="hk-legal-toc" aria-label="隐私政策目录">
|
||||
<span className="hk-legal-toc__label">目录</span>
|
||||
<div className="hk-legal-toc__list">
|
||||
<a
|
||||
className="hk-legal-toc__item hk-is-active"
|
||||
href="#privacy-collect"
|
||||
>
|
||||
<span className="hk-legal-toc__number">01</span>
|
||||
<span>我们收集的信息</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#privacy-use">
|
||||
<span className="hk-legal-toc__number">02</span>
|
||||
<span>信息的使用方式</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#privacy-cookie">
|
||||
<span className="hk-legal-toc__number">03</span>
|
||||
<span>Cookie 与同类技术</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#privacy-share">
|
||||
<span className="hk-legal-toc__number">04</span>
|
||||
<span>信息共享与披露</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#privacy-security">
|
||||
<span className="hk-legal-toc__number">05</span>
|
||||
<span>信息存储与安全</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#privacy-rights">
|
||||
<span className="hk-legal-toc__number">06</span>
|
||||
<span>您的权利</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#privacy-children">
|
||||
<span className="hk-legal-toc__number">07</span>
|
||||
<span>未成年人信息</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#privacy-updates">
|
||||
<span className="hk-legal-toc__number">08</span>
|
||||
<span>政策更新与联系方式</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<article className="hk-legal-content">
|
||||
<p className="hk-legal-intro">
|
||||
请您在使用花魁短剧网页版与 App
|
||||
前仔细阅读本隐私政策。使用即表示您已年满 18
|
||||
岁并同意本隐私政策的全部内容。以下为示意文本,正式内容以法务提供版本为准。
|
||||
</p>
|
||||
<section className="hk-legal-section" id="privacy-collect">
|
||||
<h2 className="hk-legal-section__title">1. 我们收集的信息</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
1.1
|
||||
本节说明花魁短剧在「我们收集的信息」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
1.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="privacy-use">
|
||||
<h2 className="hk-legal-section__title">2. 信息的使用方式</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
2.1
|
||||
本节说明花魁短剧在「信息的使用方式」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
2.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="privacy-cookie">
|
||||
<h2 className="hk-legal-section__title">3. Cookie 与同类技术</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
3.1 本节说明花魁短剧在「Cookie
|
||||
与同类技术」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
3.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="privacy-share">
|
||||
<h2 className="hk-legal-section__title">4. 信息共享与披露</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
4.1
|
||||
本节说明花魁短剧在「信息共享与披露」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
4.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="privacy-security">
|
||||
<h2 className="hk-legal-section__title">5. 信息存储与安全</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
5.1
|
||||
本节说明花魁短剧在「信息存储与安全」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
5.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="privacy-rights">
|
||||
<h2 className="hk-legal-section__title">6. 您的权利</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
6.1
|
||||
本节说明花魁短剧在「您的权利」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
6.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="privacy-children">
|
||||
<h2 className="hk-legal-section__title">7. 未成年人信息</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
7.1
|
||||
本节说明花魁短剧在「未成年人信息」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
7.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="privacy-updates">
|
||||
<h2 className="hk-legal-section__title">8. 政策更新与联系方式</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
8.1
|
||||
本节说明花魁短剧在「政策更新与联系方式」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
8.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { PageProps } from "@/features/content/model";
|
||||
import { value, items, external } from "@/features/content/model";
|
||||
import { DownloadLink } from "./DownloadLink";
|
||||
import { brandAssets } from "@/lib/brand-assets";
|
||||
const displayURL = (url: unknown) =>
|
||||
String(url || "")
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/+$/, "");
|
||||
const icon = (name: string) => `/static/images/${name}.svg?v=huakui-20261010`;
|
||||
export function SiteNavigationPage({ data }: PageProps) {
|
||||
const config = data.appConfig || {},
|
||||
seo = value(config, "SeoNavInfo", {}),
|
||||
forever = value(config, "ForeverURLAddr", ""),
|
||||
app = value(config, "AppAddr", ""),
|
||||
tg = value(config, "TGGroup", "");
|
||||
const title =
|
||||
value(seo, "DhSiteName", "") ||
|
||||
value(seo, "DhTitle", "") ||
|
||||
data.title ||
|
||||
"花魁短剧 · 官方导航";
|
||||
const services = [
|
||||
{
|
||||
url: tg,
|
||||
name: "官方 TG 群",
|
||||
icon: "brand-telegram",
|
||||
width: 20,
|
||||
height: 16,
|
||||
},
|
||||
{
|
||||
url: value(config, "TwitterAddr", ""),
|
||||
name: "官方推特",
|
||||
icon: "brand-x",
|
||||
width: 18,
|
||||
height: 18,
|
||||
},
|
||||
{
|
||||
url: data.homeRoadURL ?? "/return-home/",
|
||||
name: "回家的路",
|
||||
icon: "home",
|
||||
width: 18,
|
||||
height: 18,
|
||||
},
|
||||
{
|
||||
url: app,
|
||||
name: "APP",
|
||||
icon: "link_smartphone",
|
||||
width: 18,
|
||||
height: 18,
|
||||
download: true,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<main className="hk-site-navigation-page">
|
||||
<div className="hk-site-navigation-page__bg" aria-hidden="true">
|
||||
<img
|
||||
fetchPriority="low"
|
||||
className="hk-site-navigation-page__bg-img"
|
||||
src="/static/images/nav_bg.webp?v=huakui-20261010"
|
||||
alt=""
|
||||
/>
|
||||
<div className="hk-site-navigation-page__bg-mask" />
|
||||
</div>
|
||||
<div className="hk-site-navigation-page__inner">
|
||||
<h1 className="hk-site-navigation-page__sr-title">{title}</h1>
|
||||
<section className="hk-site-navigation-brand" aria-label="品牌介绍">
|
||||
<a className="hk-site-navigation-brand__lockup" href="/" aria-label="花魁短剧首页">
|
||||
<img
|
||||
fetchPriority="low"
|
||||
className="hk-site-navigation-brand__logo"
|
||||
src={brandAssets.navigationLogo}
|
||||
width="248"
|
||||
height="88"
|
||||
alt="花魁短剧"
|
||||
/>
|
||||
</a>
|
||||
<p className="hk-site-navigation-brand__tagline">全网最新最火短剧,花魁为你呈现</p>
|
||||
<p className="hk-site-navigation-brand__highlight">
|
||||
永久免费 最新资源 无需下载
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-site-navigation-entries" aria-label="访问入口">
|
||||
{items(config, "NavPageList").map((entry, index) => (
|
||||
<a
|
||||
className="hk-site-navigation-entry"
|
||||
href={value(entry, "URL", "/")}
|
||||
key={index}
|
||||
target="_blank"
|
||||
rel="sponsored nofollow noopener noreferrer"
|
||||
>
|
||||
<span className="hk-site-navigation-entry__glow" aria-hidden="true" />
|
||||
<span className="hk-site-navigation-entry__copy">
|
||||
<span className="hk-site-navigation-entry__name">
|
||||
花魁短剧 {value(entry, "Label", "")}
|
||||
</span>
|
||||
<span className="hk-site-navigation-entry__url">
|
||||
{displayURL(value(entry, "URL"))}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hk-site-navigation-entry__cta">
|
||||
立即进入
|
||||
<img
|
||||
fetchPriority="low"
|
||||
className="hk-site-navigation-entry__cta-icon"
|
||||
src={icon("arrow-up-right")}
|
||||
width="14"
|
||||
height="14"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
<a
|
||||
className="hk-site-navigation-entry hk-site-navigation-entry--forever"
|
||||
href={forever || "/"}
|
||||
{...external(forever)}
|
||||
>
|
||||
<span className="hk-site-navigation-entry__name">永久域名(VPN 访问)</span>
|
||||
<span className="hk-site-navigation-entry__url">
|
||||
{forever
|
||||
? displayURL(forever)
|
||||
: value(seo, "DhSiteName", "") || "花魁短剧官方地址"}
|
||||
</span>
|
||||
</a>
|
||||
</section>
|
||||
{app && (
|
||||
<DownloadLink className="hk-site-navigation-app" href={app} {...external(app)}>
|
||||
<img
|
||||
fetchPriority="low"
|
||||
className="hk-site-navigation-app__icon"
|
||||
src={icon("download")}
|
||||
width="20"
|
||||
height="20"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hk-site-navigation-app__text">花魁短剧 APP 下载</span>
|
||||
</DownloadLink>
|
||||
)}
|
||||
{tg && (
|
||||
<a className="hk-site-navigation-contact" href={tg} {...external(tg)}>
|
||||
<span>官方联系:</span>
|
||||
<span>{tg}</span>
|
||||
</a>
|
||||
)}
|
||||
<section className="hk-site-navigation-icons" aria-label="官方服务与联系方式">
|
||||
{services
|
||||
.filter((service) => service.url)
|
||||
.map((service) => {
|
||||
const content = (
|
||||
<>
|
||||
<span className="hk-site-navigation-icon__badge">
|
||||
<img
|
||||
fetchPriority="low"
|
||||
src={icon(service.icon)}
|
||||
width={service.width}
|
||||
height={service.height}
|
||||
alt=""
|
||||
/>
|
||||
</span>
|
||||
<span className="hk-site-navigation-icon__label">{service.name}</span>
|
||||
</>
|
||||
);
|
||||
return service.download ? (
|
||||
<DownloadLink
|
||||
className="hk-site-navigation-icon"
|
||||
key={service.name}
|
||||
href={service.url}
|
||||
{...external(service.url)}
|
||||
>
|
||||
{content}
|
||||
</DownloadLink>
|
||||
) : (
|
||||
<a
|
||||
className="hk-site-navigation-icon"
|
||||
key={service.name}
|
||||
href={service.url}
|
||||
{...external(service.url)}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
<footer className="hk-site-navigation-footer">
|
||||
<p className="hk-site-navigation-footer__note">本站仅提供短剧内容与产品服务</p>
|
||||
<p className="hk-site-navigation-footer__note">
|
||||
请遵守当地法律法规,仅限 18 岁以上成年人访问
|
||||
</p>
|
||||
<p className="hk-site-navigation-footer__copy">
|
||||
© 2026 花魁短剧 官方发布页
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
import type { PageProps } from "@/features/content/model";
|
||||
import { useLegalNavigation } from "./useLegalNavigation";
|
||||
|
||||
export function TermsPage({ data }: PageProps) {
|
||||
const ref = useLegalNavigation();
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<header className="hk-page-layout-subheader hk-mobile-only" aria-label="用户条款页顶栏">
|
||||
<a className="hk-page-layout-subheader__btn" href="/" aria-label="返回首页">
|
||||
<img
|
||||
fetchPriority="low"
|
||||
decoding="async"
|
||||
className="hk-icon"
|
||||
src="/static/images/chevron-left.svg?v=huakui-20261010"
|
||||
alt="返回"
|
||||
/>
|
||||
</a>
|
||||
<span className="hk-page-layout-subheader__title">用户条款</span>
|
||||
<span className="hk-page-layout-subheader__btn" aria-hidden="true"></span>
|
||||
</header>
|
||||
|
||||
<main className="hk-legal-main">
|
||||
<div className="hk-legal-hero">
|
||||
<p className="hk-legal-kicker">
|
||||
<span className="hk-legal-kicker__bar" aria-hidden="true"></span>
|
||||
用户条款
|
||||
</p>
|
||||
<h1 className="hk-legal-title">花魁短剧用户服务条款</h1>
|
||||
<p className="hk-legal-dates">
|
||||
生效日期 2026 年 9 月 1 日 最近更新 2026 年 9 月 1 日
|
||||
</p>
|
||||
</div>
|
||||
<div className="hk-legal-layout">
|
||||
<div className="hk-legal-toc-slot">
|
||||
<nav className="hk-legal-toc" aria-label="用户条款目录">
|
||||
<span className="hk-legal-toc__label">目录</span>
|
||||
<div className="hk-legal-toc__list">
|
||||
<a className="hk-legal-toc__item hk-is-active" href="#terms-1">
|
||||
<span className="hk-legal-toc__number">01</span>
|
||||
<span>服务说明</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#terms-2">
|
||||
<span className="hk-legal-toc__number">02</span>
|
||||
<span>账号注册与安全</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#terms-3">
|
||||
<span className="hk-legal-toc__number">03</span>
|
||||
<span>内容与知识产权</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#terms-4">
|
||||
<span className="hk-legal-toc__number">04</span>
|
||||
<span>金币与支付</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#terms-5">
|
||||
<span className="hk-legal-toc__number">05</span>
|
||||
<span>用户行为规范</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#terms-6">
|
||||
<span className="hk-legal-toc__number">06</span>
|
||||
<span>未成年人保护</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#terms-7">
|
||||
<span className="hk-legal-toc__number">07</span>
|
||||
<span>免责声明</span>
|
||||
</a>
|
||||
<a className="hk-legal-toc__item" href="#terms-8">
|
||||
<span className="hk-legal-toc__number">08</span>
|
||||
<span>条款变更与联系方式</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="hk-legal-content">
|
||||
<p className="hk-legal-intro">
|
||||
请您在使用花魁短剧网页版与 App
|
||||
前仔细阅读本用户条款。使用即表示您已年满 18
|
||||
岁并同意本用户条款的全部内容。以下为示意文本,正式内容以法务提供版本为准。
|
||||
</p>
|
||||
<section className="hk-legal-section" id="terms-1">
|
||||
<h2 className="hk-legal-section__title">1. 服务说明</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
1.1
|
||||
本节说明花魁短剧在「服务说明」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
1.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="terms-2">
|
||||
<h2 className="hk-legal-section__title">2. 账号注册与安全</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
2.1
|
||||
本节说明花魁短剧在「账号注册与安全」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
2.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="terms-3">
|
||||
<h2 className="hk-legal-section__title">3. 内容与知识产权</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
3.1
|
||||
本节说明花魁短剧在「内容与知识产权」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
3.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="terms-4">
|
||||
<h2 className="hk-legal-section__title">4. 金币与支付</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
4.1
|
||||
本节说明花魁短剧在「金币与支付」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
4.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="terms-5">
|
||||
<h2 className="hk-legal-section__title">5. 用户行为规范</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
5.1
|
||||
本节说明花魁短剧在「用户行为规范」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
5.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="terms-6">
|
||||
<h2 className="hk-legal-section__title">6. 未成年人保护</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
6.1
|
||||
本节说明花魁短剧在「未成年人保护」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
6.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="terms-7">
|
||||
<h2 className="hk-legal-section__title">7. 免责声明</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
7.1
|
||||
本节说明花魁短剧在「免责声明」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
7.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
<section className="hk-legal-section" id="terms-8">
|
||||
<h2 className="hk-legal-section__title">8. 条款变更与联系方式</h2>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
8.1
|
||||
本节说明花魁短剧在「条款变更与联系方式」方面的规则与您的权利义务。我们会以清晰、可理解的方式向您说明相关内容,并在发生变更时通过站内公告或邮件通知您。
|
||||
</p>
|
||||
<p className="hk-legal-section__paragraph">
|
||||
8.2 若您对本节内容有任何疑问,可通过页尾「联系我们」或
|
||||
support@huakuidj.com 与我们取得联系。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function UnavailablePage() {
|
||||
return (
|
||||
<div className="hk-page-layout hk-not-found-page">
|
||||
<main className="hk-page-main hk-not-found">
|
||||
<p className="hk-not-found__code" aria-hidden="true">
|
||||
503
|
||||
</p>
|
||||
<h1 className="hk-not-found__title">服务暂时不可用</h1>
|
||||
<p className="hk-not-found__desc">内容加载遇到问题,请稍后重试。</p>
|
||||
<div className="hk-not-found__actions">
|
||||
<a className="hk-button hk-button--primary hk-button--lg hk-not-found__cta" href="">
|
||||
重新加载
|
||||
</a>
|
||||
<a className="hk-button hk-button--ghost hk-button--lg hk-not-found__cta" href="/">
|
||||
返回首页
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { AboutPage } from "./AboutPage";
|
||||
export { HelpPage } from "./HelpPage";
|
||||
export { FeedbackPage } from "./FeedbackPage";
|
||||
export { SiteNavigationPage } from "./SiteNavigationPage";
|
||||
export { NotFoundPage } from "./NotFoundPage";
|
||||
export {
|
||||
LegalPage,
|
||||
TermsPage,
|
||||
PrivacyPage,
|
||||
CompliancePage,
|
||||
DMCAPage,
|
||||
} from "./LegalPage";
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Data } from "@/server/services/model";
|
||||
export type PageProps = { data: Data };
|
||||
export function value<T = any>(
|
||||
data: Data | null | undefined,
|
||||
name: string,
|
||||
fallback?: T,
|
||||
): T {
|
||||
const camel =
|
||||
name === "URL"
|
||||
? "url"
|
||||
: name === "ID"
|
||||
? "id"
|
||||
: name[0].toLowerCase() + name.slice(1);
|
||||
const key =
|
||||
data &&
|
||||
Object.keys(data).find((key) => key.toLowerCase() === name.toLowerCase());
|
||||
return (data?.[name] ??
|
||||
data?.[camel] ??
|
||||
(key ? data?.[key] : undefined) ??
|
||||
fallback) as T;
|
||||
}
|
||||
export function items(data: Data | null | undefined, name: string): Data[] {
|
||||
const result = value(data, name, []);
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
export function image(data: Data) {
|
||||
return String(
|
||||
value(data, "Image", "") ||
|
||||
"/static/brand/placeholder.svg?v=huakui-20261010",
|
||||
);
|
||||
}
|
||||
export function width(value: unknown) {
|
||||
return (
|
||||
Math.max(0, Math.min(100, parseFloat(String(value || "0")) || 0)) + "%"
|
||||
);
|
||||
}
|
||||
export function external(href: unknown) {
|
||||
return /^https?:\/\//i.test(String(href || ""))
|
||||
? { target: "_blank", rel: "sponsored nofollow noopener" }
|
||||
: {};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
/** Measured sticky TOC stays within its article; React owns every rendered element. */
|
||||
export function useLegalNavigation() {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const node = root.current,
|
||||
toc = node?.querySelector<HTMLElement>(".hk-legal-toc"),
|
||||
slot = node?.querySelector<HTMLElement>(".hk-legal-toc-slot"),
|
||||
layout = node?.querySelector<HTMLElement>(".hk-legal-layout"),
|
||||
list = node?.querySelector<HTMLElement>(".hk-legal-toc__list");
|
||||
if (!node || !toc || !slot || !layout) return;
|
||||
const links = [
|
||||
...toc.querySelectorAll<HTMLAnchorElement>(".hk-legal-toc__item"),
|
||||
],
|
||||
desktop = matchMedia("(min-width:769px)");
|
||||
function pin() {
|
||||
if (!toc || !slot || !layout) return;
|
||||
const rect = slot.getBoundingClientRect();
|
||||
if (!desktop.matches || rect.top >= 88) {
|
||||
toc.classList.remove("hk-is-fixed");
|
||||
toc.style.cssText = "";
|
||||
slot.style.height = "";
|
||||
return;
|
||||
}
|
||||
const height = toc.offsetHeight;
|
||||
slot.style.height = height + "px";
|
||||
toc.classList.add("hk-is-fixed");
|
||||
Object.assign(toc.style, {
|
||||
top:
|
||||
Math.min(88, layout.getBoundingClientRect().bottom - height) + "px",
|
||||
left: rect.left + "px",
|
||||
width: slot.offsetWidth + "px",
|
||||
});
|
||||
}
|
||||
function activate(anchor: HTMLAnchorElement) {
|
||||
links.forEach((link) =>
|
||||
link.classList.toggle("hk-is-active", link === anchor),
|
||||
);
|
||||
if (list && list.scrollWidth > list.clientWidth)
|
||||
list.scrollTo({
|
||||
left: Math.max(
|
||||
0,
|
||||
list.scrollLeft +
|
||||
anchor.getBoundingClientRect().left -
|
||||
list.getBoundingClientRect().left -
|
||||
(list.clientWidth - anchor.clientWidth) / 2,
|
||||
),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
function click(event: Event) {
|
||||
const target =
|
||||
event.target instanceof Element
|
||||
? event.target.closest<HTMLAnchorElement>(".hk-legal-toc__item")
|
||||
: null;
|
||||
if (target) activate(target);
|
||||
}
|
||||
const initial = links.find((link) => link.hash === location.hash);
|
||||
if (initial) activate(initial);
|
||||
pin();
|
||||
node.addEventListener("click", click);
|
||||
window.addEventListener("scroll", pin, { passive: true });
|
||||
window.addEventListener("resize", pin);
|
||||
desktop.addEventListener("change", pin);
|
||||
return () => {
|
||||
node.removeEventListener("click", click);
|
||||
window.removeEventListener("scroll", pin);
|
||||
window.removeEventListener("resize", pin);
|
||||
desktop.removeEventListener("change", pin);
|
||||
};
|
||||
}, []);
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
import { useDialog } from "@/components/ui/Dialog";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import { useSite } from "@/components/providers/SiteProvider";
|
||||
import { externalProps, safeHref } from "@/features/catalog/model";
|
||||
/** Locked episodes are App-exclusive: the sheet only offers the App download. */
|
||||
export function AppOnlyDialog({
|
||||
download,
|
||||
onClose,
|
||||
}: {
|
||||
download: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const site = useSite(),
|
||||
ref = useDialog(onClose);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="hk-playback-coin hk-playback-coin--short hk-is-open"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="play-coin-title"
|
||||
>
|
||||
<div className="hk-playback-coin__mask" onClick={onClose} aria-hidden="true" />
|
||||
<div className="hk-playback-coin__panel">
|
||||
<div className="hk-playback-coin__handle" aria-hidden="true" />
|
||||
<div className="hk-playback-coin__head">
|
||||
<div className="hk-playback-coin__copy">
|
||||
<h2 className="hk-playback-coin__title" id="play-coin-title">
|
||||
本集请前往 App 观看
|
||||
</h2>
|
||||
<p className="hk-playback-coin__desc">
|
||||
打开花魁短剧 App,全集免费畅看
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="hk-playback-coin__close"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
className="hk-button hk-button--primary hk-playback-coin__pay"
|
||||
href={safeHref(download || "/app/download/")}
|
||||
{...externalProps(download)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
site.openDownload();
|
||||
}}
|
||||
>
|
||||
<Icon name="download" />
|
||||
前往 App 观看
|
||||
</a>
|
||||
<div className="hk-playback-coin__foot">
|
||||
<button className="hk-playback-coin__skip" type="button" onClick={onClose}>
|
||||
稍后再说
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiRequest } from "@/lib/api";
|
||||
import { useSite } from "@/components/providers/SiteProvider";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import { BackLink } from "@/components/catalog/Interactions";
|
||||
import { AdBanners } from "@/components/catalog/Promotions";
|
||||
import { PlayLinks } from "@/components/catalog/PlayLinks";
|
||||
import type { PlayerHandle } from "@/components/player/VideoPlayer";
|
||||
import { useDramaContext } from "@/components/player/usePlaybackAnalytics";
|
||||
import {
|
||||
array,
|
||||
chapterPlayURL,
|
||||
displayName,
|
||||
first,
|
||||
formatLikeCount,
|
||||
get,
|
||||
namedTags,
|
||||
namedThemeTags,
|
||||
num,
|
||||
playURL,
|
||||
str,
|
||||
externalProps,
|
||||
type Data,
|
||||
} from "@/features/catalog/model";
|
||||
import { dramaStatusChange } from "@/lib/tracker";
|
||||
import { trackingContext, coinGate } from "./model";
|
||||
import { PlaybackStage } from "./PlaybackStage";
|
||||
import { AppOnlyDialog } from "./AppOnlyDialog";
|
||||
import { GuessLike, HotList, PlaybackShare, Related, Synopsis } from "./Shared";
|
||||
export function DramaWatchPage({ data }: { data: Data }) {
|
||||
const p = data.playlet || {},
|
||||
id = num(p, "ID"),
|
||||
episode = Number(data.episode) || 1,
|
||||
total = num(p, "TotalEpisode"),
|
||||
chapterID = Number(data.chapterID),
|
||||
chapters = array(get(p, "PlayletChapterInfo"));
|
||||
const initialPreview = !data.canWatch && !!data.hasPreview;
|
||||
const [mode, setModeState] = useState<"main" | "preview">(
|
||||
initialPreview ? "preview" : "main",
|
||||
),
|
||||
[playing, setPlaying] = useState(false),
|
||||
[autoPlay, setAutoPlay] = useState(false),
|
||||
[descending, setDescending] = useState(false),
|
||||
[appOnly, setAppOnly] = useState(false);
|
||||
const player = useRef<PlayerHandle>(null),
|
||||
watchSent = useRef(false);
|
||||
const site = useSite(),
|
||||
loggedIn = !!site.user,
|
||||
name = displayName(p),
|
||||
context = useMemo(
|
||||
() => trackingContext(data.trackerDrama),
|
||||
[data.trackerDrama],
|
||||
);
|
||||
useDramaContext(context);
|
||||
const themes = namedThemeTags(
|
||||
get(p, "ThemeIDs"),
|
||||
get(p, "ThemeNames"),
|
||||
get(p, "ThemeSlugs"),
|
||||
);
|
||||
const setMode = (next: "main" | "preview") => {
|
||||
if (next === mode) return;
|
||||
player.current?.pause();
|
||||
setPlaying(false);
|
||||
setAutoPlay(next === "preview");
|
||||
setModeState(next);
|
||||
};
|
||||
const play = () => {
|
||||
if (mode === "main" && !data.canWatch) {
|
||||
setAppOnly(true);
|
||||
return;
|
||||
}
|
||||
player.current?.play();
|
||||
};
|
||||
const onPlaying = () => {
|
||||
setPlaying(true);
|
||||
if (mode === "main" && loggedIn && chapterID > 0 && !watchSent.current) {
|
||||
watchSent.current = true;
|
||||
void apiRequest("/api/playlet/chapter/watch/report", {
|
||||
chapterId: chapterID,
|
||||
...(id > 0 ? { playletId: id } : {}),
|
||||
}).catch(() => {
|
||||
watchSent.current = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const accepted = () => {
|
||||
if (mode === "preview") player.current?.play();
|
||||
};
|
||||
document.addEventListener("eighteen:closed", accepted);
|
||||
return () => document.removeEventListener("eighteen:closed", accepted);
|
||||
}, [mode]);
|
||||
const parsedYear = String(get(p, "OnlineAt") || "").match(/^(\d{4})/)?.[1],
|
||||
year =
|
||||
parsedYear && !["0001", "1970"].includes(parsedYear)
|
||||
? parsedYear
|
||||
: undefined,
|
||||
status = num(p, "UpdateStatus") === 2 ? "已完结" : "连载中",
|
||||
anchor = str(p, "Anchor") === "暂无" ? "" : str(p, "Anchor");
|
||||
return (
|
||||
<>
|
||||
<header className="hk-page-layout-subheader hk-mobile-only" aria-label="播放页顶栏">
|
||||
<BackLink />
|
||||
<div className="hk-page-layout-subheader__title">{name}</div>
|
||||
<PlaybackShare
|
||||
getVideo={() =>
|
||||
mode === "main" ? player.current?.video() || null : null
|
||||
}
|
||||
/>
|
||||
</header>
|
||||
<div className="hk-page-layout hk-playback-page">
|
||||
<main className="hk-page-main hk-playback-main">
|
||||
<nav
|
||||
className="hk-breadcrumb hk-playback-breadcrumb hk-desktop-only"
|
||||
aria-label="面包屑"
|
||||
>
|
||||
<a className="hk-breadcrumb__item" href="/">
|
||||
首页
|
||||
</a>
|
||||
<Icon name="chevron-right" className="hk-breadcrumb__sep" />
|
||||
<a className="hk-breadcrumb__item" href="/dramas/">
|
||||
短剧
|
||||
</a>
|
||||
<Icon name="chevron-right" className="hk-breadcrumb__sep" />
|
||||
<a className="hk-breadcrumb__item" href={playURL(get(p, "Slug"), id)}>
|
||||
{name}
|
||||
</a>
|
||||
<Icon name="chevron-right" className="hk-breadcrumb__sep" />
|
||||
<span className="hk-breadcrumb__item hk-is-current">第 {episode} 集</span>
|
||||
</nav>
|
||||
<PlaybackStage
|
||||
data={data}
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
playerRef={player}
|
||||
playing={playing}
|
||||
onPlay={play}
|
||||
onPlaying={onPlaying}
|
||||
autoPlay={autoPlay}
|
||||
/>
|
||||
<section className="hk-playback-info" aria-label="剧集信息">
|
||||
<div className="hk-playback-meta">
|
||||
<div className="hk-playback-meta__title-row">
|
||||
<h1 className="hk-playback-meta__title">{name}</h1>
|
||||
{get(p, "IsFeatured") ? (
|
||||
<span className="hk-badge hk-badge--hot">爆剧</span>
|
||||
) : anchor === "原创" ? (
|
||||
<span className="hk-badge hk-badge--original">原创</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="hk-playback-meta__chips">
|
||||
{total > 0 && <span className="hk-playback-chip">全 {total} 集</span>}
|
||||
<span className="hk-playback-chip">{status}</span>
|
||||
{year && <span className="hk-playback-chip">{year}</span>}
|
||||
<span className="hk-playback-chip">1080P</span>
|
||||
</div>
|
||||
{["hk-desktop-only", "hk-mobile-only"].map((device) => (
|
||||
<p className={`hk-playback-meta__stats ${device}`} key={device}>
|
||||
{anchor && !["原创", "已注销"].includes(anchor) && (
|
||||
<span className="hk-playback-meta__stat">主演 {anchor}</span>
|
||||
)}
|
||||
{!!themes.length && (
|
||||
<span className="hk-playback-meta__stat">
|
||||
{device === "hk-desktop-only" ? "题材 " : ""}
|
||||
{themes.map((tag, index) => (
|
||||
<span key={tag.Name}>
|
||||
{index ? " " : ""}
|
||||
{tag.URL ? (
|
||||
<a
|
||||
className="hk-playback-meta__tag"
|
||||
href={tag.URL}
|
||||
{...externalProps(tag.URL)}
|
||||
>
|
||||
{tag.Name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="hk-playback-meta__tag">{tag.Name}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className="hk-playback-meta__stat">
|
||||
热度 {formatLikeCount(get(p, "HeatValue"))}
|
||||
</span>
|
||||
<span className="hk-playback-meta__stat">
|
||||
收藏 <span>{formatLikeCount(get(p, "CountCollect"))}</span>
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="hk-playback-actions">
|
||||
<button
|
||||
className="hk-button hk-button--primary hk-playback-actions__btn hk-playback-actions__btn--play"
|
||||
type="button"
|
||||
onClick={play}
|
||||
>
|
||||
<Icon name="play_hollow" />
|
||||
<span>
|
||||
{mode === "preview"
|
||||
? "播放预告片"
|
||||
: data.canWatch
|
||||
? "立即播放"
|
||||
: "去 App 看"}
|
||||
</span>
|
||||
</button>
|
||||
<PlaybackShare
|
||||
className="hk-button hk-button--ghost hk-playback-actions__btn hk-playback-actions__btn--icon"
|
||||
label
|
||||
getVideo={() =>
|
||||
mode === "main" ? player.current?.video() || null : null
|
||||
}
|
||||
/>
|
||||
<a
|
||||
className="hk-button hk-button--ghost hk-playback-actions__btn hk-playback-actions__btn--icon"
|
||||
href="/feedback/"
|
||||
aria-label="反馈"
|
||||
>
|
||||
<Icon name="message-circle" />
|
||||
<span className="hk-desktop-only">反馈</span>
|
||||
</a>
|
||||
</div>
|
||||
<Synopsis
|
||||
text={first(get(p, "Desc"), get(p, "Description"))}
|
||||
tags={namedTags(get(p, "TagIDs"), get(p, "TagNames"), get(p, "TagSlugs"))}
|
||||
/>
|
||||
</section>
|
||||
<section className="hk-playback-episodes" aria-label="分集">
|
||||
<div className="hk-playback-episodes__head">
|
||||
<div className="hk-playback-episodes__head-left">
|
||||
<h2 className="hk-playback-episodes__title">分集</h2>
|
||||
<span className="hk-playback-episodes__count">
|
||||
共 {total || chapters.length} 集 {status}
|
||||
</span>
|
||||
<nav
|
||||
className="hk-playback-episodes__sort hk-desktop-only"
|
||||
aria-label="分集排序"
|
||||
>
|
||||
{[false, true].map((value) => (
|
||||
<button
|
||||
key={String(value)}
|
||||
className={`hk-playback-episodes__sort-item${descending === value ? " hk-is-active" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={descending === value}
|
||||
onClick={() => setDescending(value)}
|
||||
>
|
||||
{value ? "倒序" : "正序"}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<button
|
||||
className="hk-playback-episodes__order hk-mobile-only"
|
||||
type="button"
|
||||
aria-pressed={descending}
|
||||
onClick={() => setDescending(!descending)}
|
||||
>
|
||||
<span>{descending ? "正序" : "倒序"}</span>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="hk-playback-episodes__grid">
|
||||
{(descending ? [...chapters].reverse() : chapters).map(
|
||||
(chapter, index) => {
|
||||
const chid = num(chapter, "ID"),
|
||||
ep =
|
||||
num(chapter, "CurrentEpisode") ||
|
||||
(descending ? chapters.length - index : index + 1);
|
||||
return (
|
||||
<a
|
||||
key={chid}
|
||||
className={`hk-playback-ep${chid === chapterID ? " hk-is-active" : ""}${coinGate(chapter) ? " hk-playback-ep--coin" : ""}`}
|
||||
href={chapterPlayURL(get(p, "Slug"), id, chid)}
|
||||
onClick={() => void dramaStatusChange(ep, chid)}
|
||||
>
|
||||
{ep}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{!chapters.length && <span className="hk-playback-ep hk-is-active">1</span>}
|
||||
</div>
|
||||
<div className="hk-playback-episodes__legend">
|
||||
<span className="hk-playback-legend">
|
||||
<i
|
||||
className="hk-playback-legend__swatch hk-playback-legend__swatch--free"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hk-desktop-only">免费集 在线即看</span>
|
||||
<span className="hk-mobile-only">免费集在线看</span>
|
||||
</span>
|
||||
<span className="hk-playback-legend">
|
||||
<Icon
|
||||
name="lock"
|
||||
className="hk-playback-legend__swatch hk-playback-legend__swatch--coin"
|
||||
/>
|
||||
<span className="hk-desktop-only">App 集 前往 App 免费看</span>
|
||||
<span className="hk-mobile-only">App 集</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
<PlayLinks data={data} />
|
||||
<div className="hk-playback-promo">
|
||||
<AdBanners
|
||||
items={array(get(data.advitise, "DetailBannerList")).slice(0, 2)}
|
||||
/>
|
||||
</div>
|
||||
<Related data={data} />
|
||||
<section className="hk-playback-dual" aria-label="推荐与热榜">
|
||||
<GuessLike data={data} playletId={id} />
|
||||
<HotList data={data} />
|
||||
</section>
|
||||
<div className="hk-playback-promo hk-playback-promo--bottom">
|
||||
<AdBanners
|
||||
items={array(get(data.advitise, "DetailBannerList")).slice(2, 4)}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{appOnly && (
|
||||
<AppOnlyDialog
|
||||
download={first(get(data.appConfig, "AppAddr"), "/app/download/")}
|
||||
onClose={() => setAppOnly(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default DramaWatchPage;
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
import type { RefObject } from "react";
|
||||
import {
|
||||
VideoPlayer,
|
||||
type PlayerHandle,
|
||||
} from "@/components/player/VideoPlayer";
|
||||
import { EncryptedImage } from "@/components/media/EncryptedImage";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import { AppDownloadLink } from "@/components/catalog/Interactions";
|
||||
import { PromoGrid } from "@/components/catalog/Promotions";
|
||||
import {
|
||||
array,
|
||||
chapterPlayURL,
|
||||
first,
|
||||
get,
|
||||
num,
|
||||
str,
|
||||
type Data,
|
||||
} from "@/features/catalog/model";
|
||||
import { mediaTime } from "./model";
|
||||
import { dramaStatusChange } from "@/lib/tracker";
|
||||
export interface PlaybackStageProps {
|
||||
data: Data;
|
||||
series?: boolean;
|
||||
mode: "main" | "preview";
|
||||
setMode: (mode: "main" | "preview") => void;
|
||||
playerRef: RefObject<PlayerHandle | null>;
|
||||
playing: boolean;
|
||||
onPlay: () => void;
|
||||
onPlaying: () => void;
|
||||
autoPlay: boolean;
|
||||
}
|
||||
export function PlaybackStage({
|
||||
data,
|
||||
series = false,
|
||||
mode,
|
||||
setMode,
|
||||
playerRef,
|
||||
playing,
|
||||
onPlay,
|
||||
onPlaying,
|
||||
autoPlay,
|
||||
}: PlaybackStageProps) {
|
||||
const item = series ? data.video : data.playlet,
|
||||
title = series
|
||||
? str(item, "Title") || "视频"
|
||||
: first(get(item, "PlayName"), get(item, "Title")),
|
||||
ep = Number(data.episode) || 1,
|
||||
total = num(item, "TotalEpisode"),
|
||||
canWatch = !!data.canWatch,
|
||||
preview = mode === "preview",
|
||||
locked = !canWatch,
|
||||
url = preview
|
||||
? String(data.previewURL || "")
|
||||
: series
|
||||
? String(data.videoURL || "")
|
||||
: canWatch
|
||||
? String(data.mainURL || "")
|
||||
: "";
|
||||
const cover = series
|
||||
? first(data.cover, get(get(item, "Video"), "Cover"))
|
||||
: first(
|
||||
get(item, "HorizontalCover"),
|
||||
get(item, "Banner"),
|
||||
get(item, "Cover"),
|
||||
);
|
||||
const videoTime = get(get(item, "Video"), "VideoTime"),
|
||||
nextURL =
|
||||
!series && Number(data.nextChapterID) > 0
|
||||
? chapterPlayURL(get(item, "Slug"), num(item, "ID"), data.nextChapterID)
|
||||
: "";
|
||||
return (
|
||||
<section className="hk-playback-stage" aria-label="播放器">
|
||||
<div
|
||||
className={`hk-playback-player${locked ? " hk-playback-player--locked" : ""}${preview ? " hk-playback-player--preview" : ""}${playing ? " hk-is-playing" : ""}`}
|
||||
data-play-player=""
|
||||
data-play-mode={mode}
|
||||
data-playlet-id={series ? undefined : num(item, "ID")}
|
||||
data-chapter-id={series ? undefined : data.chapterID}
|
||||
data-video-id={series ? data.videoID : undefined}
|
||||
data-can-watch={canWatch ? "1" : "0"}
|
||||
>
|
||||
{url && (
|
||||
<VideoPlayer
|
||||
ref={playerRef}
|
||||
url={url}
|
||||
trackDrama={!series && !preview}
|
||||
onPlaying={onPlaying}
|
||||
autoPlay={autoPlay}
|
||||
/>
|
||||
)}
|
||||
<EncryptedImage
|
||||
className="hk-playback-player__cover hk-lazy"
|
||||
src={cover}
|
||||
alt={title}
|
||||
eager
|
||||
/>
|
||||
<div className="hk-playback-player__scrim" aria-hidden="true" />
|
||||
<div className="hk-playback-player__dim" aria-hidden="true" />
|
||||
<button
|
||||
className="hk-playback-player__center"
|
||||
type="button"
|
||||
aria-label="播放"
|
||||
onClick={onPlay}
|
||||
>
|
||||
<Icon name="play" />
|
||||
</button>
|
||||
{locked && (
|
||||
<div className="hk-playback-player__unlock">
|
||||
<div className="hk-playback-player__unlock-icon" aria-hidden="true">
|
||||
<Icon name="lock" />
|
||||
</div>
|
||||
<p className="hk-playback-player__unlock-ep">
|
||||
{series ? title : `第 ${ep} 集${total ? ` 共 ${total} 集` : ""}`}
|
||||
</p>
|
||||
<p className="hk-playback-player__unlock-title">
|
||||
{series ? "暂无法播放" : "本集请前往 App 观看"}
|
||||
</p>
|
||||
<p className="hk-playback-player__unlock-desc">
|
||||
{series
|
||||
? "当前视频地址不可用,请稍后再试。"
|
||||
: "打开花魁短剧 App,全集免费畅看"}
|
||||
</p>
|
||||
<div className="hk-playback-player__unlock-actions">
|
||||
<AppDownloadLink
|
||||
className={
|
||||
series
|
||||
? "hk-playback-player__unlock-app"
|
||||
: "hk-button hk-button--primary hk-playback-player__unlock-btn"
|
||||
}
|
||||
href={first(get(data.appConfig, "AppAddr"), "/app/download/")}
|
||||
>
|
||||
<Icon name="download" />
|
||||
{series ? "下载 App 免费看" : "前往 App 观看"}
|
||||
</AppDownloadLink>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="hk-playback-player__badge">
|
||||
<span className="hk-desktop-only">
|
||||
{preview
|
||||
? "预告片 免费"
|
||||
: series
|
||||
? `正片${videoTime ? ` ${mediaTime(videoTime)}` : ""}`
|
||||
: `正片 第 ${ep} 集${total ? ` 共 ${total} 集` : ""}`}
|
||||
</span>
|
||||
<span className="hk-mobile-only">
|
||||
{preview
|
||||
? "预告片"
|
||||
: series
|
||||
? videoTime
|
||||
? mediaTime(videoTime)
|
||||
: "正片"
|
||||
: `第 ${ep} 集${total ? ` 共 ${total} 集` : ""}`}
|
||||
</span>
|
||||
</div>
|
||||
{locked && (
|
||||
<div className="hk-playback-player__bar" data-play-locked-bar="">
|
||||
<div className="hk-playback-player__progress" aria-hidden="true">
|
||||
<span
|
||||
className="hk-playback-player__progress-fill"
|
||||
style={{ width: "0%" }}
|
||||
/>
|
||||
<span
|
||||
className="hk-playback-player__progress-thumb"
|
||||
style={{ left: "0%" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="hk-playback-player__controls">
|
||||
<div className="hk-playback-player__left">
|
||||
<button
|
||||
className="hk-playback-player__icon"
|
||||
type="button"
|
||||
aria-label="播放"
|
||||
disabled={series}
|
||||
onClick={onPlay}
|
||||
>
|
||||
<Icon name="play" />
|
||||
</button>
|
||||
{!series &&
|
||||
(nextURL ? (
|
||||
<a
|
||||
className="hk-playback-player__icon"
|
||||
href={nextURL}
|
||||
aria-label="下一集"
|
||||
onClick={() =>
|
||||
void dramaStatusChange(ep + 1, Number(data.nextChapterID))
|
||||
}
|
||||
>
|
||||
<Icon name="chevron-right" />
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
className="hk-playback-player__icon"
|
||||
type="button"
|
||||
aria-label="下一集"
|
||||
disabled
|
||||
>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
))}
|
||||
<span className="hk-playback-player__time">
|
||||
00:00 /{" "}
|
||||
{mediaTime(
|
||||
series ? videoTime : get(data.chapter, "MediaTime"),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hk-playback-player__right">
|
||||
<span className="hk-playback-player__text">1.0x</span>
|
||||
<span className="hk-playback-player__text">1080P</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PromoGrid items={array(get(data.advitise, "NineGridList"))} />
|
||||
<div className="hk-playback-tabs" aria-label="内容类型">
|
||||
<button
|
||||
type="button"
|
||||
className={`hk-playback-tabs__item${!preview ? " hk-is-active" : ""}`}
|
||||
onClick={() => setMode("main")}
|
||||
>
|
||||
正片
|
||||
</button>
|
||||
{!series && data.hasPreview && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`hk-playback-tabs__item${preview ? " hk-is-active" : ""}`}
|
||||
onClick={() => setMode("preview")}
|
||||
>
|
||||
<Icon name="film" />
|
||||
预告片
|
||||
</button>
|
||||
<p className="hk-playback-tabs__hint hk-desktop-only">
|
||||
预告免费看
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
import { useRef, useState } from "react";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import { BackLink } from "@/components/catalog/Interactions";
|
||||
import { AdBanners } from "@/components/catalog/Promotions";
|
||||
import { PlayLinks } from "@/components/catalog/PlayLinks";
|
||||
import type { PlayerHandle } from "@/components/player/VideoPlayer";
|
||||
import { useDramaContext } from "@/components/player/usePlaybackAnalytics";
|
||||
import {
|
||||
array,
|
||||
first,
|
||||
formatLikeCount,
|
||||
get,
|
||||
num,
|
||||
str,
|
||||
strings,
|
||||
type Data,
|
||||
} from "@/features/catalog/model";
|
||||
import { PlaybackStage } from "./PlaybackStage";
|
||||
import { GuessLike, HotList, PlaybackShare, Synopsis } from "./Shared";
|
||||
export function SeriesWatchPage({ data }: { data: Data }) {
|
||||
const video = data.video || {},
|
||||
media = get(video, "Video"),
|
||||
title = str(video, "Title") || "视频",
|
||||
player = useRef<PlayerHandle>(null),
|
||||
[playing, setPlaying] = useState(false);
|
||||
useDramaContext(null);
|
||||
const play = () => player.current?.play(),
|
||||
actions = "btn btn--ghost play-actions__btn play-actions__btn--icon";
|
||||
return (
|
||||
<>
|
||||
<header className="hk-page-layout-subheader hk-mobile-only" aria-label="播放页顶栏">
|
||||
<BackLink />
|
||||
<div className="hk-page-layout-subheader__title">{title}</div>
|
||||
<PlaybackShare />
|
||||
</header>
|
||||
<div className="hk-page-layout hk-playback-page">
|
||||
<main className="hk-page-main hk-playback-main">
|
||||
<nav
|
||||
className="hk-breadcrumb hk-playback-breadcrumb hk-desktop-only"
|
||||
aria-label="面包屑"
|
||||
>
|
||||
<a className="hk-breadcrumb__item" href="/">
|
||||
首页
|
||||
</a>
|
||||
<Icon name="chevron-right" className="hk-breadcrumb__sep" />
|
||||
<a className="hk-breadcrumb__item" href="/series/">
|
||||
影集
|
||||
</a>
|
||||
<Icon name="chevron-right" className="hk-breadcrumb__sep" />
|
||||
<span className="hk-breadcrumb__item hk-is-current">{title}</span>
|
||||
</nav>
|
||||
<PlaybackStage
|
||||
data={data}
|
||||
series
|
||||
mode="main"
|
||||
setMode={() => undefined}
|
||||
playerRef={player}
|
||||
playing={playing}
|
||||
onPlay={play}
|
||||
onPlaying={() => setPlaying(true)}
|
||||
autoPlay={false}
|
||||
/>
|
||||
<section className="hk-playback-info" aria-label="剧集信息">
|
||||
<div className="hk-playback-meta">
|
||||
<div className="hk-playback-meta__title-row">
|
||||
<h1 className="hk-playback-meta__title">{title}</h1>
|
||||
<span className="hk-badge hk-badge--hot">影集</span>
|
||||
</div>
|
||||
<div className="hk-playback-meta__chips">
|
||||
{num(media, "VideoTime") > 0 && (
|
||||
<span className="hk-playback-chip">
|
||||
{num(media, "VideoTime")} 分钟
|
||||
</span>
|
||||
)}
|
||||
{!!num(media, "Width") && !!num(media, "Height") && (
|
||||
<span className="hk-playback-chip">
|
||||
{num(media, "Width")}×{num(media, "Height")}
|
||||
</span>
|
||||
)}
|
||||
<span className="hk-playback-chip">18+</span>
|
||||
</div>
|
||||
<p className="hk-playback-meta__stats hk-desktop-only">
|
||||
{strings(get(video, "TagsName")).length > 0
|
||||
? `标签 ${strings(get(video, "TagsName")).join(" ")} `
|
||||
: ""}
|
||||
{num(video, "WatchTimes")
|
||||
? `热度 ${formatLikeCount(get(video, "WatchTimes"))}`
|
||||
: ""}
|
||||
</p>
|
||||
<p className="hk-playback-meta__stats hk-mobile-only">
|
||||
{strings(get(video, "TagsName")).join(" ")}
|
||||
{num(video, "WatchTimes")
|
||||
? ` 热度 ${formatLikeCount(get(video, "WatchTimes"))}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hk-playback-actions">
|
||||
<button
|
||||
className="hk-button hk-button--primary hk-playback-actions__btn hk-playback-actions__btn--play"
|
||||
type="button"
|
||||
disabled={!data.canWatch}
|
||||
onClick={play}
|
||||
>
|
||||
<Icon name="play_hollow" />
|
||||
<span>立即播放</span>
|
||||
</button>
|
||||
<PlaybackShare className={actions} label />
|
||||
<a className={actions} href="/feedback/" aria-label="反馈">
|
||||
<Icon name="message-circle" />
|
||||
<span className="hk-desktop-only">反馈</span>
|
||||
</a>
|
||||
</div>
|
||||
<Synopsis
|
||||
text={first(
|
||||
get(data.seoInfo, "Description"),
|
||||
get(video, "Title"),
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
<PlayLinks data={data} />
|
||||
<div className="hk-playback-promo">
|
||||
<AdBanners
|
||||
items={array(get(data.advitise, "DetailBannerList")).slice(0, 2)}
|
||||
/>
|
||||
</div>
|
||||
<section className="hk-playback-dual" aria-label="推荐与热榜">
|
||||
<GuessLike data={data} playletId={Number(data.guessPlayletID) || 0} />
|
||||
<HotList data={data} />
|
||||
</section>
|
||||
<div className="hk-playback-promo hk-playback-promo--bottom">
|
||||
<AdBanners
|
||||
items={array(get(data.advitise, "DetailBannerList")).slice(2, 4)}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default SeriesWatchPage;
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { apiRequest } from "@/lib/api";
|
||||
import { useSite } from "@/components/providers/SiteProvider";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import { CardTags, PlayRecommendationCard } from "@/components/catalog/Cards";
|
||||
import {
|
||||
EncryptedImage,
|
||||
EncryptedBackground,
|
||||
} from "@/components/media/EncryptedImage";
|
||||
import {
|
||||
array,
|
||||
displayName,
|
||||
get,
|
||||
num,
|
||||
str,
|
||||
itemPlayURL,
|
||||
formatLikeCount,
|
||||
episodeStatus,
|
||||
type Data,
|
||||
type NamedTag,
|
||||
} from "@/features/catalog/model";
|
||||
import { reportPlaybackAction } from "@/components/player/usePlaybackAnalytics";
|
||||
export function PlaybackShare({
|
||||
className = "page-subheader__btn",
|
||||
label = false,
|
||||
getVideo,
|
||||
}: {
|
||||
className?: string;
|
||||
label?: boolean;
|
||||
getVideo?: () => HTMLVideoElement | null;
|
||||
}) {
|
||||
const site = useSite();
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
type="button"
|
||||
aria-label="分享"
|
||||
onClick={async () => {
|
||||
reportPlaybackAction("ep_share", getVideo?.());
|
||||
try {
|
||||
if (navigator.share)
|
||||
await navigator.share({
|
||||
title: document.title,
|
||||
url: location.href,
|
||||
});
|
||||
else if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(location.href);
|
||||
site.notify("链接已复制");
|
||||
} else {
|
||||
const input = document.createElement("textarea");
|
||||
input.value = location.href;
|
||||
input.style.position = "fixed";
|
||||
input.style.opacity = "0";
|
||||
document.body.append(input);
|
||||
input.select();
|
||||
const copied = document.execCommand("copy");
|
||||
input.remove();
|
||||
if (!copied) throw Error();
|
||||
site.notify("链接已复制");
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && error.name === "AbortError"))
|
||||
site.notify("分享失败,请手动复制链接");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="share" />
|
||||
{label && <span className="hk-desktop-only">分享</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
export function Synopsis({
|
||||
text,
|
||||
tags = [],
|
||||
}: {
|
||||
text: string;
|
||||
tags?: NamedTag[];
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false),
|
||||
[overflow, setOverflow] = useState(false),
|
||||
ref = useRef<HTMLParagraphElement>(null);
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
if (ref.current && !expanded)
|
||||
setOverflow(ref.current.scrollHeight > ref.current.clientHeight + 1);
|
||||
};
|
||||
sync();
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(sync);
|
||||
if (ref.current) observer?.observe(ref.current);
|
||||
window.addEventListener("resize", sync);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", sync);
|
||||
};
|
||||
}, [text, expanded]);
|
||||
return (
|
||||
<div className={`hk-playback-synopsis${expanded ? " hk-is-expanded" : ""}`}>
|
||||
<div className="hk-playback-synopsis__head">
|
||||
<h2 className="hk-playback-synopsis__title">剧情简介</h2>
|
||||
{(overflow || expanded) && (
|
||||
<button
|
||||
className="hk-playback-synopsis__more"
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span>{expanded ? "收起" : "展开"}</span>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="hk-playback-synopsis__text" ref={ref}>
|
||||
{text || "暂无简介"}
|
||||
</p>
|
||||
{!!tags.length && (
|
||||
<div className="hk-playback-synopsis__tags" aria-label="标签">
|
||||
{tags.map((tag) =>
|
||||
tag.URL ? (
|
||||
<a className="hk-playback-synopsis__tag" href={tag.URL} key={tag.Name}>
|
||||
{tag.Name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="hk-playback-synopsis__tag" key={tag.Name}>
|
||||
{tag.Name}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function HotList({ data }: { data: Data }) {
|
||||
const items = array(data.hotList);
|
||||
return (
|
||||
<aside className="hk-playback-hot" aria-label="热播榜单">
|
||||
<div className="hk-playback-hot__head">
|
||||
<h2 className="hk-playback-hot__title">热播榜单</h2>
|
||||
<a
|
||||
className="hk-playback-hot__more"
|
||||
href={String(data.hotMoreURL || "/dramas/all/0/2/")}
|
||||
>
|
||||
更多
|
||||
<Icon name="chevron-right" />
|
||||
</a>
|
||||
</div>
|
||||
<ol className="hk-playback-hot__list">
|
||||
{items.map((item, index) => (
|
||||
<li
|
||||
className={index >= 5 ? "hk-desktop-only" : undefined}
|
||||
key={num(item, "ID")}
|
||||
>
|
||||
<div className="hk-playback-hot__item">
|
||||
<a
|
||||
className={`hk-playback-hot__no${index < 3 ? " hk-is-top" : ""}`}
|
||||
href={itemPlayURL(item)}
|
||||
>
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</a>
|
||||
<a className="hk-playback-hot__cover-link" href={itemPlayURL(item)}>
|
||||
<EncryptedImage
|
||||
className="hk-playback-hot__cover hk-lazy"
|
||||
src={str(item, "Cover")}
|
||||
alt={displayName(item) + "封面"}
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<span className="hk-playback-hot__info">
|
||||
<a className="hk-playback-hot__name" href={itemPlayURL(item)}>
|
||||
{displayName(item)}
|
||||
</a>
|
||||
<span className="hk-playback-hot__meta">
|
||||
<CardTags item={item} />
|
||||
{num(item, "TotalEpisode")
|
||||
? ` 全 ${num(item, "TotalEpisode")} 集`
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
<a className="hk-playback-hot__heat" href={itemPlayURL(item)}>
|
||||
{formatLikeCount(get(item, "HeatValue"))}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{!items.length && (
|
||||
<li>
|
||||
<p
|
||||
className="hk-playback-hot__empty"
|
||||
style={{ margin: 0, padding: "12px 0", color: "var(--text-dim)" }}
|
||||
>
|
||||
暂无热榜数据
|
||||
</p>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
export function GuessLike({
|
||||
data,
|
||||
playletId,
|
||||
}: {
|
||||
data: Data;
|
||||
playletId: number;
|
||||
}) {
|
||||
const site = useSite(),
|
||||
[items, setItems] = useState(array(data.guessLike)),
|
||||
[page, setPage] = useState(Number(data.guessPage) || 1),
|
||||
[busy, setBusy] = useState(false),
|
||||
pending = useRef(false),
|
||||
size = Number(data.guessPageSize) || 6;
|
||||
if (!items.length) return null;
|
||||
const refresh = async () => {
|
||||
if (pending.current || !playletId) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
let next =
|
||||
Number(data.guessTotal) > 0 && page * size >= Number(data.guessTotal)
|
||||
? 1
|
||||
: page + 1;
|
||||
let result = await apiRequest<Record<string, unknown>>(
|
||||
"/api/playlet/guess-like/list",
|
||||
undefined,
|
||||
{ params: { playletId, pageNum: next, pageSize: size } },
|
||||
);
|
||||
if (!array(result.list).length && next > 1) {
|
||||
next = 1;
|
||||
result = await apiRequest("/api/playlet/guess-like/list", undefined, {
|
||||
params: { playletId, pageNum: 1, pageSize: size },
|
||||
});
|
||||
}
|
||||
setItems(array(result.list));
|
||||
setPage(Number(result.pageNum) || next);
|
||||
} catch (error) {
|
||||
site.notify(error instanceof Error ? error.message : "推荐加载失败");
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section className="hk-playback-like" aria-label="同类推荐">
|
||||
<div className="hk-playback-like__head">
|
||||
<div className="hk-playback-like__titles">
|
||||
<h2 className="hk-playback-like__title">同类推荐</h2>
|
||||
<span className="hk-playback-like__desc hk-desktop-only">与本剧题材相近的好剧</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="hk-playback-like__refresh"
|
||||
disabled={busy}
|
||||
onClick={refresh}
|
||||
>
|
||||
{busy ? "加载中…" : "换一换"}
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="hk-playback-like__grid" id="play-like">
|
||||
{items.map((item) => (
|
||||
<PlayRecommendationCard item={item} key={num(item, "ID")} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function Related({ data }: { data: Data }) {
|
||||
const items = array(data.related).slice(0, 3);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<section className="hk-playback-related hk-mobile-only" aria-label="大家都在看">
|
||||
<div className="hk-playback-related__head">
|
||||
<h2 className="hk-playback-related__title">大家都在看</h2>
|
||||
<a className="hk-playback-related__more" href="/featured/">
|
||||
更多
|
||||
<Icon name="chevron-right" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="hk-playback-related__grid">
|
||||
{items.map((item) => (
|
||||
<article className="hk-drama-card hk-drama-card--play-sm" key={num(item, "ID")}>
|
||||
<EncryptedBackground
|
||||
as="a"
|
||||
className="hk-drama-card__cover hk-lazy"
|
||||
href={itemPlayURL(item)}
|
||||
src={str(item, "Cover")}
|
||||
>
|
||||
<span className="hk-drama-card__ep">
|
||||
{num(item, "TotalEpisode")
|
||||
? `全 ${num(item, "TotalEpisode")} 集`
|
||||
: episodeStatus(item)}
|
||||
</span>
|
||||
</EncryptedBackground>
|
||||
<h3 className="hk-drama-card__title">
|
||||
<a href={itemPlayURL(item)}>{displayName(item)}</a>
|
||||
</h3>
|
||||
<p className="hk-drama-card__meta">
|
||||
<CardTags item={item} />
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { get, number, type CatalogRecord } from "@/features/catalog/model";
|
||||
import type { Fields } from "@/components/analytics/tracker";
|
||||
export const integer = (value: unknown) => Math.trunc(number(value));
|
||||
export function trackingContext(input: unknown): Fields | null {
|
||||
if (!input || !number(get(input, "DramaID"))) return null;
|
||||
const string = (key: string, fallback = "") =>
|
||||
String(get(input, key) ?? "").trim() || fallback;
|
||||
return {
|
||||
media_id: string("MediaID"),
|
||||
drama_id: string("DramaID"),
|
||||
drama_title: string("DramaTitle", "未命名短剧"),
|
||||
episode: Math.max(1, integer(get(input, "Episode"))),
|
||||
total_episode: Math.max(1, integer(get(input, "TotalEpisode"))),
|
||||
video_id: string("VideoID"),
|
||||
drama_type_id: string("DramaTypeID", "default"),
|
||||
drama_type_name: string("DramaTypeName", "默认分类"),
|
||||
drama_content_type: "short_drama",
|
||||
recommend_trace_id: string("RecommendTraceID"),
|
||||
drama_tag_key: string("DramaTagKey", "default"),
|
||||
drama_tag_name: string("DramaTagName", "默认标签"),
|
||||
drama_duration: Math.max(0, number(get(input, "DramaDuration"))),
|
||||
};
|
||||
}
|
||||
export function coinGate(chapter: CatalogRecord): boolean {
|
||||
return (
|
||||
!chapter.canWatch &&
|
||||
!chapter.AllowWatch &&
|
||||
(!!chapter.needUnlock || number(chapter.payType) === 1)
|
||||
);
|
||||
}
|
||||
export function mediaTime(value: unknown): string {
|
||||
const seconds = Math.max(0, integer(value));
|
||||
return [
|
||||
Math.floor(seconds / 3600) || null,
|
||||
Math.floor(seconds / 60) % 60,
|
||||
seconds % 60,
|
||||
]
|
||||
.filter((value) => value != null)
|
||||
.map((value) => String(value).padStart(2, "0"))
|
||||
.join(":");
|
||||
}
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public code: number,
|
||||
message: string,
|
||||
public status = 200,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
export interface DramaContext {
|
||||
media_id?: string | number;
|
||||
drama_id?: string | number;
|
||||
episode?: number;
|
||||
video_id?: string | number;
|
||||
recommend_trace_id?: string;
|
||||
}
|
||||
export interface RequestOptions {
|
||||
method?: string;
|
||||
params?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
trackerContext?: DramaContext;
|
||||
trackerEvent?: string | false;
|
||||
signal?: AbortSignal;
|
||||
skipRecovery?: boolean;
|
||||
}
|
||||
type Tracker = {
|
||||
getSDKCommonHeader?: () => string | Promise<string>;
|
||||
getCommonFields?: () =>
|
||||
| Record<string, unknown>
|
||||
| Promise<Record<string, unknown>>;
|
||||
getDramaContext?: () => DramaContext;
|
||||
setUid?: (uid: string) => void;
|
||||
};
|
||||
const tracker = () =>
|
||||
typeof window === "undefined"
|
||||
? undefined
|
||||
: (window as unknown as { tracker?: Tracker }).tracker;
|
||||
export function getCookie(name: string): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
const value =
|
||||
document.cookie
|
||||
.split(";")
|
||||
.map((p) => p.trim())
|
||||
.find((p) => p.startsWith(name + "="))
|
||||
?.slice(name.length + 1) || "";
|
||||
try {
|
||||
return decodeURIComponent(value.replace(/\+/g, " "));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
function setCookie(name: string, value: unknown, days = 7) {
|
||||
document.cookie =
|
||||
name +
|
||||
"=" +
|
||||
encodeURIComponent(String(value ?? "")) +
|
||||
"; Path=/; SameSite=Lax; Max-Age=" +
|
||||
Math.floor(days * 86400) +
|
||||
(location.protocol === "https:" ? "; Secure" : "");
|
||||
}
|
||||
const authKeys = [
|
||||
"token",
|
||||
"_token_",
|
||||
"sid",
|
||||
"_s_token_",
|
||||
"userId",
|
||||
"accountNo",
|
||||
"nickName",
|
||||
"introduction",
|
||||
"avatar",
|
||||
"email",
|
||||
"inviteCode",
|
||||
"recharge",
|
||||
"vipType",
|
||||
"currentVipExpire",
|
||||
"showCardId",
|
||||
"showCardName",
|
||||
"userType",
|
||||
"isRegister",
|
||||
];
|
||||
export function clearAuthSession() {
|
||||
authKeys.forEach((key) => setCookie(key, "", 0));
|
||||
document.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
export function setAuthSession(user: Record<string, unknown>) {
|
||||
const values = {
|
||||
...user,
|
||||
userType: user.type,
|
||||
isRegister:
|
||||
user.isRegister === true || user.isRegister === 1 || user.type === 1
|
||||
? "1"
|
||||
: "0",
|
||||
};
|
||||
for (const [key, value] of Object.entries(values))
|
||||
if (authKeys.includes(key) && value != null) setCookie(key, value);
|
||||
setCookie("guest_skip_auto", "", 0);
|
||||
try {
|
||||
sessionStorage.removeItem("guest_logout_manual");
|
||||
} catch {
|
||||
/* storage disabled */
|
||||
}
|
||||
if (user.userId != null) tracker()?.setUid?.(String(user.userId));
|
||||
document.dispatchEvent(new CustomEvent("auth:login", { detail: { user } }));
|
||||
}
|
||||
export function getDeviceID(): string {
|
||||
let id = getCookie("guest_dev_id");
|
||||
try {
|
||||
id =
|
||||
localStorage.getItem("uuid") ||
|
||||
id ||
|
||||
localStorage.getItem("guest_dev_id") ||
|
||||
"";
|
||||
} catch {
|
||||
/* cookie fallback */
|
||||
}
|
||||
if (!id)
|
||||
id =
|
||||
Array.from(crypto.getRandomValues(new Uint8Array(16)), (n) =>
|
||||
(n % 16).toString(16).toUpperCase(),
|
||||
).join("") + Date.now();
|
||||
try {
|
||||
localStorage.setItem("uuid", id);
|
||||
localStorage.setItem("guest_dev_id", id);
|
||||
} catch {
|
||||
/* cookie still usable */
|
||||
}
|
||||
setCookie("guest_dev_id", id, 365);
|
||||
return id;
|
||||
}
|
||||
export function getChannel(): string {
|
||||
const params = new URLSearchParams(location.search);
|
||||
let value = getCookie("channel");
|
||||
try {
|
||||
value = localStorage.getItem("channel") || value;
|
||||
} catch {
|
||||
/* cookie fallback */
|
||||
}
|
||||
value ||= params.get("dc") || params.get("ch") || params.get("pc") || "";
|
||||
if (!/^[A-Za-z0-9_.-]{1,50}$/.test(value)) return "";
|
||||
try {
|
||||
localStorage.setItem("channel", value);
|
||||
} catch {
|
||||
/* cookie fallback */
|
||||
}
|
||||
setCookie("channel", value, 365);
|
||||
return value;
|
||||
}
|
||||
const events: Record<string, string> = {
|
||||
"/api/playlet/chapter/like/set": "drama_like",
|
||||
"/api/playlet/like/set": "drama_like",
|
||||
"/api/playlet/chapter/collect/set": "drama_collect",
|
||||
"/api/playlet/collect/set": "drama_collect",
|
||||
"/api/comment/send": "drama_comment",
|
||||
"/api/playlet/chapter/unlock": "drama_purchase",
|
||||
};
|
||||
const base64 = (value: unknown) =>
|
||||
btoa(
|
||||
Array.from(new TextEncoder().encode(JSON.stringify(value)), (n) =>
|
||||
String.fromCharCode(n),
|
||||
).join(""),
|
||||
)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
const clean = (value: unknown) =>
|
||||
String(value ?? "")
|
||||
.replace(/[^\x20-\x7e]|[;=]/g, " ")
|
||||
.trim();
|
||||
async function deadline<T>(
|
||||
promise: Promise<T>,
|
||||
fallback: T,
|
||||
ms = 1500,
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise.catch(() => fallback),
|
||||
new Promise<T>((resolve) => {
|
||||
timer = setTimeout(() => resolve(fallback), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
async function requestHeaders(
|
||||
path: string,
|
||||
method: string,
|
||||
options: RequestOptions,
|
||||
): Promise<Headers> {
|
||||
const headers = new Headers({
|
||||
Accept: "application/json",
|
||||
...options.headers,
|
||||
});
|
||||
if (new URL(path, location.href).origin !== location.origin) return headers;
|
||||
const sdk = tracker();
|
||||
const commonHeader = deadline(
|
||||
Promise.resolve().then(() => sdk?.getSDKCommonHeader?.() || ""),
|
||||
"",
|
||||
);
|
||||
const event =
|
||||
options.trackerEvent === false
|
||||
? ""
|
||||
: options.trackerEvent || events[new URL(path, location.href).pathname];
|
||||
const context = options.trackerContext || sdk?.getDramaContext?.();
|
||||
const common =
|
||||
event && !["GET", "HEAD"].includes(method) && context?.drama_id
|
||||
? await deadline(
|
||||
Promise.resolve().then(() => sdk?.getCommonFields?.() || {}),
|
||||
{},
|
||||
)
|
||||
: null;
|
||||
if (common && context) {
|
||||
const requestID = Array.from(
|
||||
crypto.getRandomValues(new Uint8Array(16)),
|
||||
(n) => n.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const drama = {
|
||||
media_id: String(context.media_id ?? "").trim(),
|
||||
drama_id: String(context.drama_id ?? "").trim(),
|
||||
episode: Math.max(1, context.episode || 1),
|
||||
video_id: String(context.video_id ?? "").trim(),
|
||||
recommend_trace_id: String(context.recommend_trace_id ?? "").trim(),
|
||||
};
|
||||
headers.set("X-Tracker-Request-Id", requestID);
|
||||
headers.set("X-Tracker-Event-Name", event);
|
||||
headers.set(
|
||||
"X-DC-Request-Context",
|
||||
base64({ v: 1, request_id: requestID, event_name: event, common, drama }),
|
||||
);
|
||||
headers.set(
|
||||
"X-User-Agent",
|
||||
[
|
||||
"DevID=" + clean(common.device_id),
|
||||
"DevType=" + clean(common.device || "OTHER"),
|
||||
"SysType=" +
|
||||
(common.device === "Android"
|
||||
? "h5_android"
|
||||
: common.device === "iOS"
|
||||
? "h5_ios"
|
||||
: "h5_pc"),
|
||||
"Ver=" + clean(common.app_version || "1.0.0"),
|
||||
"BuildID=web",
|
||||
"device_brand=" + clean(common.device_brand),
|
||||
"device_model=" + clean(common.device_model),
|
||||
"sid=" + clean(common.sid),
|
||||
].join(";"),
|
||||
);
|
||||
}
|
||||
const value = await commonHeader;
|
||||
if (value && !headers.has("X-SDK-Common")) headers.set("X-SDK-Common", value);
|
||||
return headers;
|
||||
}
|
||||
let guestPromise: Promise<boolean> | null = null;
|
||||
export async function ensureGuestSession(): Promise<boolean> {
|
||||
if (getCookie("token") || getCookie("_token_")) return true;
|
||||
if (!guestPromise)
|
||||
guestPromise = (async () => {
|
||||
const devID = getDeviceID();
|
||||
try {
|
||||
const user = await apiRequest<Record<string, unknown>>(
|
||||
"/api/auth/guest",
|
||||
{ devID, deviceId: devID, channel: getChannel(), mac: "" },
|
||||
{ skipRecovery: true },
|
||||
);
|
||||
setAuthSession(user);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})().finally(() => {
|
||||
guestPromise = null;
|
||||
});
|
||||
return guestPromise;
|
||||
}
|
||||
let recovery: Promise<boolean> | null = null;
|
||||
async function recoverSession(): Promise<boolean> {
|
||||
if (!recovery)
|
||||
recovery = (async () => {
|
||||
const registered =
|
||||
getCookie("isRegister") === "1" || getCookie("userType") === "1";
|
||||
clearAuthSession();
|
||||
if (registered) {
|
||||
document.dispatchEvent(new CustomEvent("auth:required"));
|
||||
return false;
|
||||
}
|
||||
return ensureGuestSession();
|
||||
})().finally(() => {
|
||||
recovery = null;
|
||||
});
|
||||
return recovery;
|
||||
}
|
||||
/** Same-origin BFF request; prepared tracking headers and request_id survive one guest retry. */
|
||||
export async function apiRequest<T = Record<string, unknown>>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const method = options.method || (body === undefined ? "GET" : "POST");
|
||||
const url = new URL(path, location.href);
|
||||
for (const [key, value] of Object.entries(options.params || {}))
|
||||
if (value != null) url.searchParams.set(key, String(value));
|
||||
const headers = await requestHeaders(url.href, method, options);
|
||||
const payload =
|
||||
body == null
|
||||
? undefined
|
||||
: body instanceof FormData
|
||||
? body
|
||||
: JSON.stringify(body);
|
||||
if (payload && !(body instanceof FormData))
|
||||
headers.set("Content-Type", "application/json");
|
||||
const run = async () => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.timeout ?? 30000,
|
||||
);
|
||||
const abort = () => controller.abort();
|
||||
options.signal?.addEventListener("abort", abort, { once: true });
|
||||
if (options.signal?.aborted) controller.abort();
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: payload,
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const result = (await response.json()) as {
|
||||
code?: number;
|
||||
data: T;
|
||||
msg?: string;
|
||||
tip?: string;
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok)
|
||||
throw new ApiError(
|
||||
Number(result.code || response.status),
|
||||
result.tip || result.msg || result.message || "请求失败",
|
||||
response.status,
|
||||
);
|
||||
return result;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
};
|
||||
let result = await run();
|
||||
if (
|
||||
[1000, 1002, 1012, 6001].includes(Number(result.code)) &&
|
||||
!options.skipRecovery &&
|
||||
!/\/api\/auth\/(guest|login|register|resetPassword)/.test(path) &&
|
||||
(await recoverSession())
|
||||
)
|
||||
result = await run();
|
||||
if (result.code !== undefined && result.code !== 200)
|
||||
throw new ApiError(
|
||||
Number(result.code),
|
||||
result.tip || result.msg || result.message || "请求失败",
|
||||
);
|
||||
return (result.data === undefined ? result : result.data) as T;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
type Fields = Record<string, unknown>;
|
||||
|
||||
function fields(value: unknown): Fields {
|
||||
return value && typeof value === "object" ? value as Fields : {};
|
||||
}
|
||||
|
||||
/** Resolve both normalized page data and the upstream config without empty-field overrides. */
|
||||
export function appDownloadURL(value: unknown): string {
|
||||
const config = fields(value);
|
||||
const sources = [config, fields(config.config), fields(config.newAddressInfo)];
|
||||
for (const keys of [["appAddr", "AppAddr"], ["officialAppAddr", "OfficialAppAddr"]]) {
|
||||
for (const source of sources) {
|
||||
for (const key of keys) {
|
||||
const candidate = source[key];
|
||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Versioned filenames avoid the former site's static files and cached brand images.
|
||||
const root = "/static/brand/huakui-20261013";
|
||||
|
||||
export const brandAssets = {
|
||||
headerPng: `${root}/header.png`,
|
||||
navigationLogo: `${root}/footer.png`,
|
||||
authLogo: `${root}/auth.png`,
|
||||
iconSvg: `${root}/icon.svg`,
|
||||
iconPng: `${root}/icon.png`,
|
||||
faviconIco: `${root}/favicon.ico`,
|
||||
} as const;
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Public image locations only; this module never receives API credentials. */
|
||||
function httpURL(value: string): URL | null {
|
||||
if (!/^https?:\/\//i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value)) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.hostname && !url.username && !url.password ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function imageCDNBases(...values: unknown[]): string[] {
|
||||
const result = new Set<string>();
|
||||
for (const value of values) {
|
||||
const url = httpURL(String(value || "").trim());
|
||||
if (!url || url.search || url.hash) continue;
|
||||
url.pathname = url.pathname.replace(/\/+$/, "") + "/";
|
||||
result.add(url.href);
|
||||
}
|
||||
return [...result];
|
||||
}
|
||||
|
||||
/** Preserve absolute/signed URLs, and resolve API object keys independently of the page URL. */
|
||||
export function imageSourceURLs(source: string, bases: readonly string[] = []): string[] {
|
||||
const value = source.trim();
|
||||
if (!value || /[\u0000-\u0020\u007f\\]/.test(value)) return [];
|
||||
if (/^(?:\/static\/|data:image\/|blob:)/i.test(value)) return [value];
|
||||
const cdns = imageCDNBases(...bases);
|
||||
const absolute = httpURL(value);
|
||||
if (absolute) {
|
||||
const original = cdns.find((base) => {
|
||||
const url = new URL(base);
|
||||
return url.origin === absolute.origin && absolute.pathname.startsWith(url.pathname);
|
||||
});
|
||||
if (!original) return [value];
|
||||
const path = absolute.pathname.slice(new URL(original).pathname.length) + absolute.search + absolute.hash;
|
||||
return [...new Set([value, ...cdns.map((base) => new URL(path, base).href)])];
|
||||
}
|
||||
// No scheme, network-path reference or traversal may turn an object key into another host.
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(value) || value.startsWith("//")) return [];
|
||||
const path = value.replace(/^\/+/, "").replace(/^(?:\.\/)+/, "");
|
||||
if (!path || path.split(/[/?#]/).some((part) => part === "..")) return [];
|
||||
return cdns.length ? cdns.map((base) => new URL(path, base).href) : ["/" + path];
|
||||
}
|
||||
|
||||
/** Account APIs now also return the same relative object keys as catalog APIs. */
|
||||
export function validProfileImage(value: unknown): string {
|
||||
const source = String(value || "").trim();
|
||||
return (httpURL(source) || /^(?:\/static\/|\/?v3\/image\/)/.test(source)) &&
|
||||
imageSourceURLs(source).length ? source : "";
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { imageSourceURLs } from "./image-source";
|
||||
|
||||
export const IMAGE_PLACEHOLDER =
|
||||
"/static/brand/placeholder.svg?v=huakui-20261010";
|
||||
const key = new TextEncoder().encode("2019ysapp7527");
|
||||
const cache = new Map<string, string>();
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
const queue: (() => void)[] = [];
|
||||
let active = 0;
|
||||
|
||||
export function shouldDecryptImage(url: string): boolean {
|
||||
return (
|
||||
!!url &&
|
||||
!/^(data:|blob:|\/static\/)/i.test(url) &&
|
||||
!url.includes("placehold.co")
|
||||
);
|
||||
}
|
||||
export function decryptImageBytes(buffer: ArrayBuffer): Uint8Array {
|
||||
const bytes = new Uint8Array(buffer.slice(0));
|
||||
for (let index = 0; index < Math.min(100, bytes.length); index++)
|
||||
bytes[index] ^= key[index % key.length];
|
||||
return bytes;
|
||||
}
|
||||
export function imageMime(bytes: Uint8Array): string {
|
||||
if (
|
||||
bytes[0] === 0x47 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x38
|
||||
)
|
||||
return "image/gif";
|
||||
if (
|
||||
bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47
|
||||
)
|
||||
return "image/png";
|
||||
if (
|
||||
bytes[0] === 0x52 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 &&
|
||||
bytes[9] === 0x45 &&
|
||||
bytes[10] === 0x42 &&
|
||||
bytes[11] === 0x50
|
||||
)
|
||||
return "image/webp";
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
function hasImageSignature(bytes: Uint8Array): boolean {
|
||||
return bytes.length >= 12 && (
|
||||
(bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) ||
|
||||
(bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) ||
|
||||
(bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) ||
|
||||
(bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50)
|
||||
);
|
||||
}
|
||||
|
||||
export function decodeImageBytes(buffer: ArrayBuffer): Uint8Array {
|
||||
const plain = new Uint8Array(buffer);
|
||||
if (hasImageSignature(plain)) return plain;
|
||||
const decrypted = decryptImageBytes(buffer);
|
||||
if (hasImageSignature(decrypted)) return decrypted;
|
||||
throw new Error("Invalid image payload");
|
||||
}
|
||||
/** Shared bounded network queue and in-flight cache; no business scripts or DOM mutations. */
|
||||
export function resolveImage(url: string, priority = false, bases: readonly string[] = []): Promise<string> {
|
||||
if (!url) return Promise.resolve(IMAGE_PLACEHOLDER);
|
||||
const sources = imageSourceURLs(url, bases);
|
||||
if (!sources.length) return Promise.reject(new Error("Invalid image URL"));
|
||||
if (!shouldDecryptImage(sources[0])) return Promise.resolve(sources[0]);
|
||||
const cacheKey = JSON.stringify(sources);
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
const pending = inflight.get(cacheKey);
|
||||
if (pending) return pending;
|
||||
const limit =
|
||||
typeof matchMedia !== "undefined" && matchMedia("(max-width:767px)").matches
|
||||
? 2
|
||||
: 4;
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
const run = () => {
|
||||
active++;
|
||||
(async () => {
|
||||
let failure: unknown;
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const response = await fetch(source, {
|
||||
credentials: "same-origin",
|
||||
signal: AbortSignal.timeout(12000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Image HTTP ${response.status}`);
|
||||
const bytes = decodeImageBytes(await response.arrayBuffer());
|
||||
const blob = URL.createObjectURL(new Blob([bytes as BlobPart], { type: imageMime(bytes) }));
|
||||
cache.set(cacheKey, blob);
|
||||
if (cache.size > 256) {
|
||||
const oldest = cache.keys().next().value!;
|
||||
URL.revokeObjectURL(cache.get(oldest)!);
|
||||
cache.delete(oldest);
|
||||
}
|
||||
return blob;
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
})()
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
.finally(() => {
|
||||
active--;
|
||||
inflight.delete(cacheKey);
|
||||
queue.shift()?.();
|
||||
});
|
||||
};
|
||||
if (active < limit) run();
|
||||
else if (priority) queue.unshift(run);
|
||||
else queue.push(run);
|
||||
});
|
||||
inflight.set(cacheKey, promise);
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Preserve signed media queries and relative encryption-key routes used by the BFF. */
|
||||
export function resolvePlayURL(value: unknown): string {
|
||||
const url = String(value ?? "").trim();
|
||||
if (!url) return "";
|
||||
if (/^(https?:\/\/|blob:|data:)/i.test(url)) return url;
|
||||
const path = url.replace(/^\/+/, "");
|
||||
return "/" + (path.startsWith("h5/m3u8/") ? path : "h5/m3u8/" + path);
|
||||
}
|
||||
export const SHELL_BROWSER =
|
||||
/Quark|MQQBrowser|QQ\/|UCBrowser|UCWEB|MicroMessenger|baidubrowser|bdbrowser|BaiduHD|MiuiBrowser|HuaweiBrowser|HONORBrowser|HBPC|SogouMobileBrowser|VivoBrowser|HeyTapBrowser|OPPOBrowser|HeyTap|QihooBrowser|360Browser|360SE|360EE/i;
|
||||
export function nativeHLS(
|
||||
video: HTMLVideoElement,
|
||||
userAgent = navigator.userAgent,
|
||||
): boolean {
|
||||
return (
|
||||
!SHELL_BROWSER.test(userAgent) &&
|
||||
/Safari/i.test(userAgent) &&
|
||||
!/Chrome|Chromium|CriOS|Edg|OPR/i.test(userAgent) &&
|
||||
!!video.canPlayType("application/vnd.apple.mpegurl")
|
||||
);
|
||||
}
|
||||
export function isHLSURL(url: string): boolean {
|
||||
return /\.m3u8(?:[?#]|$)|\/h5\/m3u8\//i.test(url);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
{
|
||||
"version": 1,
|
||||
"routes": [
|
||||
{
|
||||
"id": "Index:/",
|
||||
"label": "首页",
|
||||
"path": "/",
|
||||
"legacyPaths": [
|
||||
"/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "SearchMobile:/discover/search/",
|
||||
"label": "移动端搜索",
|
||||
"path": "/discover/search/",
|
||||
"legacyPaths": [
|
||||
"/search-mobile/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Search:/search/",
|
||||
"label": "搜索结果",
|
||||
"path": "/search/",
|
||||
"legacyPaths": [
|
||||
"/search/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Search:/search/:q/",
|
||||
"label": "搜索结果",
|
||||
"path": "/search/:q/",
|
||||
"legacyPaths": [
|
||||
"/search/:q/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Search:/search/:q/page/:page/",
|
||||
"label": "搜索结果",
|
||||
"path": "/search/:q/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/search/:q/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "About:/about/",
|
||||
"label": "关于我们",
|
||||
"path": "/about/",
|
||||
"legacyPaths": [
|
||||
"/about/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Help:/help/",
|
||||
"label": "帮助中心",
|
||||
"path": "/help/",
|
||||
"legacyPaths": [
|
||||
"/help/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Terms:/terms/",
|
||||
"label": "服务条款",
|
||||
"path": "/terms/",
|
||||
"legacyPaths": [
|
||||
"/terms/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Privacy:/privacy/",
|
||||
"label": "隐私政策",
|
||||
"path": "/privacy/",
|
||||
"legacyPaths": [
|
||||
"/privacy/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Compliance2257:/2257/",
|
||||
"label": "2257 声明",
|
||||
"path": "/2257/",
|
||||
"legacyPaths": [
|
||||
"/2257/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "DMCA:/dmca/",
|
||||
"label": "版权声明",
|
||||
"path": "/dmca/",
|
||||
"legacyPaths": [
|
||||
"/dmca/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "DownloadApp:/app/download/",
|
||||
"label": "下载 App",
|
||||
"path": "/app/download/",
|
||||
"legacyPaths": [
|
||||
"/download-app/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "HomeRoad:/return-home/",
|
||||
"label": "回家的路",
|
||||
"path": "/return-home/",
|
||||
"legacyPaths": [
|
||||
"/home-road/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Nav:/navigation/",
|
||||
"label": "官方导航",
|
||||
"path": "/navigation/",
|
||||
"legacyPaths": [
|
||||
"/nav/",
|
||||
"/dh/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "List:/dramas/",
|
||||
"label": "短剧列表",
|
||||
"path": "/dramas/",
|
||||
"legacyPaths": [
|
||||
"/list/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "List:/dramas/:theme/",
|
||||
"label": "短剧列表",
|
||||
"path": "/dramas/:theme/",
|
||||
"legacyPaths": [
|
||||
"/list/:theme/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "List:/dramas/:theme/:updateStatus/:sortType/",
|
||||
"label": "短剧列表",
|
||||
"path": "/dramas/:theme/:updateStatus/:sortType/",
|
||||
"legacyPaths": [
|
||||
"/list/:theme/:updateStatus/:sortType/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "List:/dramas/:theme/:updateStatus/:sortType/page/:page/",
|
||||
"label": "短剧列表",
|
||||
"path": "/dramas/:theme/:updateStatus/:sortType/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/list/:theme/:updateStatus/:sortType/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreList:/curated/dramas/:configId/:timeRange/:theme/page/:page/",
|
||||
"label": "短剧推荐栏目",
|
||||
"path": "/curated/dramas/:configId/:timeRange/:theme/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/more-list/:configId/:timeRange/:theme/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreList:/curated/dramas/:configId/:timeRange/:theme/",
|
||||
"label": "短剧推荐栏目",
|
||||
"path": "/curated/dramas/:configId/:timeRange/:theme/",
|
||||
"legacyPaths": [
|
||||
"/more-list/:configId/:timeRange/:theme/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreList:/curated/dramas/:configId/page/:page/",
|
||||
"label": "短剧推荐栏目",
|
||||
"path": "/curated/dramas/:configId/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/more-list/:configId/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreList:/curated/dramas/:configId/",
|
||||
"label": "短剧推荐栏目",
|
||||
"path": "/curated/dramas/:configId/",
|
||||
"legacyPaths": [
|
||||
"/more-list/:configId/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreSeriesList:/curated/series/:configId/:timeRange/:theme/page/:page/",
|
||||
"label": "剧集推荐栏目",
|
||||
"path": "/curated/series/:configId/:timeRange/:theme/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/more-series-list/:configId/:timeRange/:theme/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreSeriesList:/curated/series/:configId/:timeRange/:theme/",
|
||||
"label": "剧集推荐栏目",
|
||||
"path": "/curated/series/:configId/:timeRange/:theme/",
|
||||
"legacyPaths": [
|
||||
"/more-series-list/:configId/:timeRange/:theme/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreSeriesList:/curated/series/:configId/page/:page/",
|
||||
"label": "剧集推荐栏目",
|
||||
"path": "/curated/series/:configId/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/more-series-list/:configId/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreSeriesList:/curated/series/:configId/",
|
||||
"label": "剧集推荐栏目",
|
||||
"path": "/curated/series/:configId/",
|
||||
"legacyPaths": [
|
||||
"/more-series-list/:configId/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Series:/series/",
|
||||
"label": "剧集列表",
|
||||
"path": "/series/",
|
||||
"legacyPaths": [
|
||||
"/series/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Series:/series/:media/:topic/:sortType/",
|
||||
"label": "剧集列表",
|
||||
"path": "/series/:media/:topic/:sortType/",
|
||||
"legacyPaths": [
|
||||
"/series/:media/:topic/:sortType/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Series:/series/:media/:topic/:sortType/page/:page/",
|
||||
"label": "剧集列表",
|
||||
"path": "/series/:media/:topic/:sortType/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/series/:media/:topic/:sortType/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Topic:/collections/",
|
||||
"label": "专题列表",
|
||||
"path": "/collections/",
|
||||
"legacyPaths": [
|
||||
"/topic/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Topic:/collections/page/:page/",
|
||||
"label": "专题列表",
|
||||
"path": "/collections/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/topic/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Topic:/collections/sort/:sortType/",
|
||||
"label": "专题列表",
|
||||
"path": "/collections/sort/:sortType/",
|
||||
"legacyPaths": [
|
||||
"/topic/sort/:sortType/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Topic:/collections/sort/:sortType/page/:page/",
|
||||
"label": "专题列表",
|
||||
"path": "/collections/sort/:sortType/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/topic/sort/:sortType/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "TopicDetail:/collection/:slug/sort/:sortType/page/:page/",
|
||||
"label": "专题详情",
|
||||
"path": "/collection/:slug/sort/:sortType/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/topic-detail/:slug/sort/:sortType/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "TopicDetail:/collection/:slug/sort/:sortType/",
|
||||
"label": "专题详情",
|
||||
"path": "/collection/:slug/sort/:sortType/",
|
||||
"legacyPaths": [
|
||||
"/topic-detail/:slug/sort/:sortType/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "TopicDetail:/collection/:slug/page/:page/",
|
||||
"label": "专题详情",
|
||||
"path": "/collection/:slug/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/topic-detail/:slug/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "TopicDetail:/collection/:slug/",
|
||||
"label": "专题详情",
|
||||
"path": "/collection/:slug/",
|
||||
"legacyPaths": [
|
||||
"/topic-detail/:slug/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Rank:/rankings/",
|
||||
"label": "排行榜",
|
||||
"path": "/rankings/",
|
||||
"legacyPaths": [
|
||||
"/rank/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Rank:/rankings/page/:page/",
|
||||
"label": "排行榜",
|
||||
"path": "/rankings/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/rank/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Rank:/rankings/:sortOrder/",
|
||||
"label": "排行榜",
|
||||
"path": "/rankings/:sortOrder/",
|
||||
"legacyPaths": [
|
||||
"/rank/:sortOrder/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Rank:/rankings/:sortOrder/page/:page/",
|
||||
"label": "排行榜",
|
||||
"path": "/rankings/:sortOrder/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/rank/:sortOrder/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Tags:/tags/:slug/sort/:sortType/page/:page/",
|
||||
"label": "标签详情",
|
||||
"path": "/tags/:slug/sort/:sortType/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/tags/:slug/sort/:sortType/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Tags:/tags/:slug/sort/:sortType/",
|
||||
"label": "标签详情",
|
||||
"path": "/tags/:slug/sort/:sortType/",
|
||||
"legacyPaths": [
|
||||
"/tags/:slug/sort/:sortType/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Tags:/tags/:slug/page/:page/",
|
||||
"label": "标签详情",
|
||||
"path": "/tags/:slug/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/tags/:slug/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Tags:/tags/:slug/",
|
||||
"label": "标签详情",
|
||||
"path": "/tags/:slug/",
|
||||
"legacyPaths": [
|
||||
"/tags/:slug/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "RecentlyAdded:/latest/",
|
||||
"label": "最新更新",
|
||||
"path": "/latest/",
|
||||
"legacyPaths": [
|
||||
"/recently-added/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "RecentlyAdded:/latest/page/:page/",
|
||||
"label": "最新更新",
|
||||
"path": "/latest/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/recently-added/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "PopularRecommend:/featured/",
|
||||
"label": "热门推荐",
|
||||
"path": "/featured/",
|
||||
"legacyPaths": [
|
||||
"/popular-recommend/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "PopularRecommend:/featured/page/:page/",
|
||||
"label": "热门推荐",
|
||||
"path": "/featured/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/popular-recommend/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "PopularRecommend:/featured/:rankType/",
|
||||
"label": "热门推荐",
|
||||
"path": "/featured/:rankType/",
|
||||
"legacyPaths": [
|
||||
"/popular-recommend/:rankType/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "PopularRecommend:/featured/:rankType/page/:page/",
|
||||
"label": "热门推荐",
|
||||
"path": "/featured/:rankType/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/popular-recommend/:rankType/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Play:/drama/:slug/",
|
||||
"label": "短剧播放",
|
||||
"path": "/drama/:slug/",
|
||||
"legacyPaths": [
|
||||
"/play/:slug/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Play:/drama/:slug/:ep/",
|
||||
"label": "短剧播放",
|
||||
"path": "/drama/:slug/:ep/",
|
||||
"legacyPaths": [
|
||||
"/play/:slug/:ep/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Play:/drama/:slug/recommendations/page/:guessPage/",
|
||||
"label": "短剧播放",
|
||||
"path": "/drama/:slug/recommendations/page/:guessPage/",
|
||||
"legacyPaths": [
|
||||
"/play/:slug/recommendations/page/:guessPage/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Play:/drama/:slug/:ep/recommendations/page/:guessPage/",
|
||||
"label": "短剧播放",
|
||||
"path": "/drama/:slug/:ep/recommendations/page/:guessPage/",
|
||||
"legacyPaths": [
|
||||
"/play/:slug/:ep/recommendations/page/:guessPage/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "SeriesPlay:/series/watch/:slug/",
|
||||
"label": "剧集播放",
|
||||
"path": "/series/watch/:slug/",
|
||||
"legacyPaths": [
|
||||
"/series-play/:slug/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "SeriesPlay:/series/watch/:slug/recommendations/page/:guessPage/",
|
||||
"label": "剧集播放",
|
||||
"path": "/series/watch/:slug/recommendations/page/:guessPage/",
|
||||
"legacyPaths": [
|
||||
"/series-play/:slug/recommendations/page/:guessPage/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Feedback:/feedback/",
|
||||
"label": "意见反馈",
|
||||
"path": "/feedback/",
|
||||
"legacyPaths": [
|
||||
"/feedback/"
|
||||
],
|
||||
"selectable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Viewport } from "next";
|
||||
|
||||
/** Public installation settings shared by Metadata, manifest and the client. */
|
||||
export const pwaConfig = {
|
||||
name: "花魁短剧",
|
||||
shortName: "花魁短剧",
|
||||
themeColor: "#141414",
|
||||
manifest: "/manifest.webmanifest",
|
||||
worker: "/sw.js",
|
||||
scope: "/",
|
||||
icons: {
|
||||
small: "/static/pwa/icon-192.png",
|
||||
large: "/static/pwa/icon-512.png",
|
||||
maskable: "/static/pwa/icon-maskable-512.png",
|
||||
apple: "/static/pwa/apple-touch-icon.png",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const pwaViewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: pwaConfig.themeColor,
|
||||
colorScheme: "dark",
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user