[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:
谢宇宁
2026-10-16 11:33:31 +07:00
co-authored by Claude Opus 5.5
commit d2ddb1c321
423 changed files with 52152 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
export {
DocumentPage as default,
generatePageMetadata as generateMetadata,
} from "@/server/react/page";
+4
View File
@@ -0,0 +1,4 @@
export {
DocumentPage as default,
generatePageMetadata as generateMetadata,
} from '@/server/react/page';
+4
View File
@@ -0,0 +1,4 @@
export {
DocumentPage as default,
generatePageMetadata as generateMetadata,
} from "@/server/react/page";
+4
View File
@@ -0,0 +1,4 @@
export {
DocumentPage as default,
generatePageMetadata as generateMetadata,
} from "@/server/react/page";
+10
View File
@@ -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";
+4
View File
@@ -0,0 +1,4 @@
export {
DocumentPage as default,
generatePageMetadata as generateMetadata,
} from "@/server/react/page";
+4
View File
@@ -0,0 +1,4 @@
export {
DocumentPage as default,
generatePageMetadata as generateMetadata,
} from "@/server/react/page";
+4
View File
@@ -0,0 +1,4 @@
export {
DocumentPage as default,
generatePageMetadata as generateMetadata,
} from "@/server/react/page";
+10
View File
@@ -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;
+20
View File
@@ -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";
+10
View File
@@ -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;
+10
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
export {
DocumentPage as default,
generatePageMetadata as generateMetadata,
} from "@/server/react/page";
+4
View File
@@ -0,0 +1,4 @@
export {
DocumentPage as default,
generatePageMetadata as generateMetadata,
} from "@/server/react/page";
+87
View File
@@ -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>
);
}
+5
View File
@@ -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;
+24
View File
@@ -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";
+10
View File
@@ -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;
+11
View File
@@ -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>
);
}
+4
View File
@@ -0,0 +1,4 @@
export {
DocumentPage as default,
generatePageMetadata as generateMetadata,
} from "@/server/react/page";
+4
View File
@@ -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";
+5
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
export {
DocumentPage as default,
generatePageMetadata as generateMetadata,
} from "@/server/react/page";
+4
View File
@@ -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";
+5
View File
@@ -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;
+5
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
export {
DocumentPage as default,
generatePageMetadata as generateMetadata,
} from "@/server/react/page";
+4
View File
@@ -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;
+479
View File
@@ -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),
};
+407
View File
@@ -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>
);
}
+259
View File
@@ -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>
);
}
+212
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+98
View File
@@ -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>
);
}
+45
View File
@@ -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;
}
+155
View File
@@ -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>
);
}
+815
View File
@@ -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)} />}
</>
);
}
+42
View File
@@ -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>
);
}
+233
View File
@@ -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;
+9
View File
@@ -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>;
}
+158
View File
@@ -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);
};
}
+598
View File
@@ -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} />;
}
+168
View File
@@ -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>
);
}
+36
View File
@@ -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;
}
+183
View File
@@ -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>
);
}
+31
View File
@@ -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;
+36
View File
@@ -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="扫码下载"
/>
);
}
+850
View File
@@ -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>
</>
);
}
+437
View File
@@ -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>
</>
);
}
+264
View File
@@ -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>
</>
);
}
+45
View File
@@ -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;
+87
View File
@@ -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"));
+121
View File
@@ -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 };
}
+154
View File
@@ -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 双端&nbsp; 竖屏沉浸播放&nbsp; 离线缓存随时看
</p>
<p className="hk-about-app__desc-mobile">
竖屏沉浸播放&nbsp; 离线缓存&nbsp; 弹幕互动
</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>
</>
);
}
+122
View File
@@ -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 日&nbsp; 最近更新 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>
);
}
+129
View File
@@ -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 日&nbsp; 最近更新 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>
);
}
+22
View File
@@ -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();
}
}}
/>
);
}
+384
View File
@@ -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>
</>
);
}
+90
View File
@@ -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>
);
}
+202
View File
@@ -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">✓&nbsp; 推荐使用 Safari 浏览器</p><p className="hk-home-road-warn">!&nbsp; 避免在微信 / QQ 等第三方 App 内打开</p>
</article>
<article>
<h3><img src={icon("android")} width="24" height="24" alt="" />Android 用户</h3>
<p className="hk-home-road-good">✓&nbsp; 推荐使用 Google Chrome 浏览器</p><p className="hk-home-road-warn">!&nbsp; 自带浏览器可能存在兼容与域名拦截风险</p>
</article>
<article>
<h3><img src={icon("desktop")} width="24" height="24" alt="" />电脑 / 桌面端</h3>
<p className="hk-home-road-good">✓&nbsp; 首选 Chrome / Edge 浏览器</p><p className="hk-home-road-warn">!&nbsp; 无法加载时可清除 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>
);
}
+18
View File
@@ -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} />;
}
+44
View File
@@ -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>
</>
);
}
+178
View File
@@ -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 日&nbsp; 最近更新 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>
);
}
+198
View File
@@ -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">
永久免费&nbsp;&nbsp;最新资源&nbsp;&nbsp;无需下载
</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">
花魁短剧&nbsp;&nbsp;{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">花魁短剧&nbsp;&nbsp;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 花魁短剧&nbsp;&nbsp;官方发布页
</p>
</footer>
</div>
</main>
);
}
+173
View File
@@ -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 日&nbsp; 最近更新 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>
);
}
+21
View File
@@ -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>
);
}
+12
View File
@@ -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";
+41
View File
@@ -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;
}
+66
View File
@@ -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>
);
}
+330
View File
@@ -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;
+244
View File
@@ -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>
);
}
+141
View File
@@ -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;
+307
View File
@@ -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>
);
}
+41
View File
@@ -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
View File
@@ -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;
}
+20
View File
@@ -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 "";
}
+11
View File
@@ -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;
+51
View File
@@ -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 : "";
}
+126
View File
@@ -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;
}
+24
View File
@@ -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);
}
+528
View File
@@ -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
}
]
}
+25
View File
@@ -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