[Ai] feat: 花魁短剧 Next.js 站点初始提交
- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { SiteProvider } from "../src/components/providers/SiteProvider";
|
||||
import { SiteHeader } from "../src/components/layout/SiteHeader";
|
||||
import { MobileTabbar } from "../src/components/layout/MobileTabbar";
|
||||
import { HomePage } from "../src/features/catalog/HomePage";
|
||||
import { HomeRoadPage } from "../src/features/content/HomeRoadPage";
|
||||
import { SiteNavigationPage } from "../src/features/content/SiteNavigationPage";
|
||||
import { brandAssets } from "../src/lib/brand-assets";
|
||||
import { toPublicPath } from "../src/lib/routes";
|
||||
import { allListURL, chapterPlayURL, categoryURL, listThemeSegment, listUpdateStatus, moreListURL, moreSeriesListURL, navHasMegaMenu, navHref, navIsActive, navIsDownload, pageURL, videoPlayURL } from "../src/lib/view-helpers";
|
||||
|
||||
const data = {
|
||||
requestPath: "/dramas/", isLoggedIn: true, isRegistered: true, userID: 7,
|
||||
userName: "观众", recharge: 100, appConfig: {}, config: {},
|
||||
categoryList: [
|
||||
{ name: "首页", urlValue: "/", categoryType: 1 },
|
||||
{ name: "短剧", urlValue: "/list", categoryType: 1 },
|
||||
{ name: "专题", urlValue: "/topic", categoryType: 1 },
|
||||
{ name: "排行榜", urlValue: "/rank", categoryType: 1 },
|
||||
{ name: "回家的路", urlValue: "/home-road/", categoryType: 1 },
|
||||
],
|
||||
themeList: [{ id: 1, slug: "romance", name: "爱情" }],
|
||||
};
|
||||
const render = (children: ReactNode) => renderToStaticMarkup(<SiteProvider data={data} dialogs={false}>{children}</SiteProvider>);
|
||||
const hrefs = (html: string) => [...html.matchAll(/<a\b[^>]*\bhref="([^"]*)"/g)].map((match) => match[1].replaceAll("&", "&"));
|
||||
function assertPublicLinks(html: string): string[] {
|
||||
const links = hrefs(html);
|
||||
for (const href of links.filter((value) => value.startsWith("/") && !value.startsWith("//")))
|
||||
assert.equal(href, toPublicPath(href), "internal link should reach its page directly: " + href);
|
||||
return links;
|
||||
}
|
||||
|
||||
test("shared view helpers produce business URLs and keep nested menu activation boundaries", () => {
|
||||
assert.equal(chapterPlayURL("story/a", 7, 101), "/drama/story%2Fa/101/");
|
||||
assert.equal(pageURL("/play/story%2fa?x=a+b&x=%2b#ep%202"), "/drama/story%2fa/?x=a+b&x=%2b#ep%202");
|
||||
assert.equal(videoPlayURL("movie", 9), "/series/watch/movie/");
|
||||
assert.equal(categoryURL("romance", 1), "/collection/romance/");
|
||||
assert.equal(allListURL("romance", 1, 2, 1, 3), "/dramas/romance/2/1/page/3/");
|
||||
assert.equal(moreListURL("picks", 1, 0, "", 0, 1), "/curated/dramas/picks/");
|
||||
assert.equal(moreSeriesListURL("movies", 2, 0, "", 0, 1), "/curated/series/movies/");
|
||||
assert.equal(navHref("/playlet"), "/dramas/");
|
||||
assert.equal(navHref("/topic/romance/"), "/collection/romance/");
|
||||
assert.equal(navHref("/download-app/"), "/app/download/");
|
||||
assert.equal(navHref("/home-road/"), "/return-home/");
|
||||
assert.equal(navIsDownload("/app/download/", "客户端下载"), true);
|
||||
assert.equal(navHasMegaMenu("/dramas/"), true);
|
||||
assert.equal(listThemeSegment("/dramas/romance/2/1/"), "romance");
|
||||
assert.equal(listUpdateStatus("/dramas/romance/2/1/"), 2);
|
||||
assert.equal(navIsActive("/series/", "/series/watch/movie/"), false);
|
||||
assert.equal(navIsActive("/dramas/", "/dramas/romance/"), true);
|
||||
});
|
||||
|
||||
test("navigation configuration resolves both legacy and normalized collection-detail aliases without decoding slugs", () => {
|
||||
for (const root of ["/topic", "/collections"]) {
|
||||
assert.equal(navHref(root + "/热门?q=a"), "/collection/%E7%83%AD%E9%97%A8/?q=a");
|
||||
assert.equal(navHref(root + "/story%2fa%252F1?x=a+b&x=%2b#ep%202"), "/collection/story%2fa%252F1/?x=a+b&x=%2b#ep%202");
|
||||
assert.equal(navHref(root + "/page/2/"), "/collections/page/2/");
|
||||
assert.equal(navHref(root + "/sort/2/"), "/collections/sort/2/");
|
||||
}
|
||||
});
|
||||
|
||||
test("SSR header exposes direct canonical URLs and no account entries", () => {
|
||||
const html = render(<SiteHeader data={data} />);
|
||||
const links = assertPublicLinks(html);
|
||||
for (const expected of ["/dramas/", "/collections/", "/rankings/", "/discover/search/", "/return-home/"])
|
||||
assert.ok(links.includes(expected), expected);
|
||||
assert.ok(!links.some((link) => link.startsWith("/account")), "account links removed");
|
||||
assert.doesNotMatch(html, /data-login-open|hk-user-menu/);
|
||||
});
|
||||
|
||||
test("configured header submenus expose canonical links, external link attributes and download actions", () => {
|
||||
const html = render(<SiteHeader data={{
|
||||
...data,
|
||||
appConfig: { appAddr: "https://downloads.test/app" },
|
||||
categoryList: [
|
||||
{
|
||||
name: "友情链接", urlValue: "#", categoryType: 2,
|
||||
translationList: [
|
||||
{ name: "黄果视频", urlValue: "https://huangguo.com", isFriendAds: false },
|
||||
{ name: "外部站点", urlValue: "//partner.test/path", isFriendAds: true },
|
||||
{ name: "爱情专题", urlValue: "/topic/romance/" },
|
||||
{ name: "下载 App", urlValue: "/download-app/" },
|
||||
],
|
||||
},
|
||||
{ name: "更多入口", urlValue: "#", translationList: [{ name: "排行榜", urlValue: "/rank/" }] },
|
||||
],
|
||||
}} />);
|
||||
const links = assertPublicLinks(html);
|
||||
assert.ok(links.includes("/collection/romance/"));
|
||||
assert.ok(links.includes("/rankings/"));
|
||||
for (const href of ["https://huangguo.com", "//partner.test/path"]) {
|
||||
const anchor = [...html.matchAll(/<a\b[^>]*>/g)].map(([tag]) => tag).find((tag) => tag.includes(`href="${href}"`));
|
||||
assert.ok(anchor, href);
|
||||
assert.match(anchor, /target="_blank"/);
|
||||
assert.match(anchor, /rel="sponsored nofollow noopener"/);
|
||||
}
|
||||
assert.match(html, /<a\b[^>]*href="https:\/\/downloads\.test\/app"[^>]*data-download-open=""/);
|
||||
const toggles = [...html.matchAll(/<button\b[^>]*>/g)].map(([tag]) => tag).filter((tag) => tag.includes("hk-site-navigation__submenu-toggle"));
|
||||
assert.equal(toggles.length, 2);
|
||||
const controls = toggles.map((tag) => {
|
||||
assert.match(tag, /aria-expanded="false"/);
|
||||
const id = /aria-controls="([^"]+)"/.exec(tag)?.[1];
|
||||
assert.ok(id);
|
||||
assert.ok(html.includes(`id="${id}"`), "submenu control points to its rendered panel");
|
||||
return id;
|
||||
});
|
||||
assert.equal(new Set(controls).size, controls.length);
|
||||
});
|
||||
|
||||
test("configured lists take precedence over the short-drama mega menu while missing or empty lists retain normal navigation", () => {
|
||||
const configured = render(<SiteHeader data={{
|
||||
...data,
|
||||
categoryList: [{ name: "短剧", urlValue: "/list", translationList: [{ name: "爱情专题", urlValue: "/topic/romance/" }] }],
|
||||
}} />);
|
||||
assert.match(configured, /data-navigation-submenu="desktop"/);
|
||||
assert.doesNotMatch(configured, /aria-label="短剧快捷筛选"/);
|
||||
for (const translationList of [undefined, [], null, {}, "invalid"]) {
|
||||
const html = render(<SiteHeader data={{
|
||||
...data,
|
||||
categoryList: [
|
||||
{ name: "短剧", urlValue: "/list", translationList },
|
||||
{ name: "专题", urlValue: "/topic", translationList },
|
||||
],
|
||||
}} />);
|
||||
assert.doesNotMatch(html, /data-navigation-submenu="desktop"/);
|
||||
assert.match(html, /aria-label="短剧快捷筛选"/);
|
||||
const links = assertPublicLinks(html);
|
||||
assert.ok(links.includes("/dramas/"));
|
||||
assert.ok(links.includes("/collections/"));
|
||||
}
|
||||
});
|
||||
|
||||
test("mobile TabBar shows only content tabs and stays hidden on nested series pages", () => {
|
||||
const links = assertPublicLinks(renderToStaticMarkup(<MobileTabbar data={{ requestPath: "/" }} />));
|
||||
assert.deepEqual(links, ["/", "/dramas/", "/series/"]);
|
||||
for (const requestPath of ["/account/", "/series/watch/movie/"])
|
||||
assert.equal(renderToStaticMarkup(<MobileTabbar data={{ requestPath }} />), "", requestPath);
|
||||
assert.match(renderToStaticMarkup(<MobileTabbar data={{ requestPath: "/dramas/romance/" }} />), /aria-current="page"[^>]*data-navigation-key="mobile_short"/);
|
||||
});
|
||||
|
||||
test("home Hero, cards and curated sections all navigate directly to new page addresses", () => {
|
||||
const work = { id: 7, slug: "story", playName: "作品", totalEpisode: 8, lastChapterId: 101, lastEpisode: 2, cover: "/static/brand/header.svg", banner: "/static/brand/header.svg" };
|
||||
const html = render(<HomePage data={{
|
||||
...data, homeBannerData: [work],
|
||||
homeConfigData: [
|
||||
{ id: 1, slug: "picks", name: "精选", type: 1, list: [work] },
|
||||
{ id: 2, slug: "movies", name: "剧集", type: 2, videoList: [{ id: 9, slug: "movie", title: "剧集", coverImg: "/static/brand/header.svg" }] },
|
||||
{ id: 3, name: "专题", type: 3, themeList: [{ id: 4, slug: "romance", name: "爱情", covers: [work.cover] }] },
|
||||
],
|
||||
}} />);
|
||||
const links = assertPublicLinks(html);
|
||||
for (const expected of ["/drama/story/", "/dramas/", "/curated/dramas/picks/", "/curated/series/movies/", "/series/watch/movie/", "/collection/romance/", "/collections/", "/app/download/"])
|
||||
assert.ok(links.includes(expected), expected);
|
||||
});
|
||||
|
||||
test("home road renders current branding, configured access routes and official contact links", () => {
|
||||
const html = render(<HomeRoadPage data={{
|
||||
requestPath: "/return-home/", siteURL: "https://huakui.example",
|
||||
appConfig: {
|
||||
newURLAddr: "https://new.huakui.example/",
|
||||
foreverURLAddr: "https://huakui.example/home-road/",
|
||||
newURLAddr1: "/topic/",
|
||||
navPageList: [{ url: "/list/" }, { url: "https://backup.example/path?x=a+b&x=%2b" }],
|
||||
gmailAddr: "help@huakui.example", tgGroup: "https://t.me/huakui_official",
|
||||
telegram: "https://t.me/huakui_support", twitterAddr: "https://x.com/huakui",
|
||||
},
|
||||
faqItems: [{ Question: "如何保存入口?", Answer: "收藏本页并保存备用地址。" }],
|
||||
}} />);
|
||||
const links = assertPublicLinks(html);
|
||||
for (const expected of ["https://new.huakui.example/", "/dramas/", "https://backup.example/path?x=a+b&x=%2b", "mailto:help@huakui.example", "https://t.me/huakui_official", "https://t.me/huakui_support", "https://x.com/huakui"])
|
||||
assert.ok(links.includes(expected), expected);
|
||||
assert.ok(html.includes(`src="${brandAssets.navigationLogo}"`));
|
||||
assert.match(html, /alt="花魁短剧"/);
|
||||
assert.match(html, /花魁短剧 · 官方最新地址发布页/);
|
||||
assert.match(html, /huakui\.example\/return-home/);
|
||||
assert.match(html, /如何保存入口?/);
|
||||
assert.doesNotMatch(html, /黄果短剧|huangguoaiai@gmail\.com|href="\/home-road\//);
|
||||
});
|
||||
|
||||
test("home road keeps an accessible fallback when configured routes or contacts are absent or unsafe", () => {
|
||||
for (const appConfig of [{}, {
|
||||
newURLAddr: "javascript:alert(1)", foreverURLAddr: "//unknown.example/",
|
||||
newURLAddr1: "https://unsafe.example/\\path", newURLAddr2: "data:text/html,unsafe",
|
||||
navPageList: [{ url: "javascript:alert(2)" }],
|
||||
gmailAddr: "invalid", tgGroup: "javascript:alert(3)",
|
||||
}]) {
|
||||
const html = render(<HomeRoadPage data={{ appConfig }} />);
|
||||
assert.ok(hrefs(html).every((href) => href === "/"));
|
||||
assert.match(html, /永久入口正在更新/);
|
||||
assert.match(html, /官方联系方式正在更新/);
|
||||
assert.match(html, /回家的路页面有什么作用?/);
|
||||
assert.doesNotMatch(html, /javascript:|data:text|huangguoaiai@gmail\.com/);
|
||||
}
|
||||
});
|
||||
|
||||
test("site navigation uses the supplied home road destination and hides an unavailable entry", () => {
|
||||
for (const homeRoadURL of ["/return-home/", "https://main.huakui.test/return-home/", "https://files.huakui.test/access.pdf", ""]) {
|
||||
const html = render(<SiteNavigationPage data={{ appConfig: {}, homeRoadURL }} />);
|
||||
const homeRoadLink = [...html.matchAll(/<a\b[^>]*>[\s\S]*?<\/a>/g)]
|
||||
.map(([anchor]) => anchor).find((anchor) => anchor.includes(">回家的路</span>"));
|
||||
if (!homeRoadURL) assert.equal(homeRoadLink, undefined);
|
||||
else {
|
||||
assert.ok(homeRoadLink);
|
||||
assert.ok(homeRoadLink.includes(`href="${homeRoadURL}"`));
|
||||
if (homeRoadURL.startsWith("https://")) assert.match(homeRoadLink, /target="_blank"/);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user