[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
+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} />;
}