[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
+195
View File
@@ -0,0 +1,195 @@
import { chromium } from "@playwright/test";
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
const origin = process.env.SMOKE_URL || "http://localhost:25131";
const output = process.env.SMOKE_OUTPUT_DIR || "/tmp/hkdj-browser-smoke";
await mkdir(output, { recursive: true });
const browser = await chromium.launch({
executablePath:
process.env.CHROME_PATH ||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
headless: true,
});
const results: Record<string, unknown>[] = [];
try {
for (const [name, viewport] of [
["desktop", { width: 1440, height: 1000 }],
["mobile", { width: 390, height: 844 }],
] as const) {
const context = await browser.newContext({
viewport,
isMobile: name === "mobile",
deviceScaleFactor: 1,
});
await context.addInitScript(() => localStorage.setItem("hg_age_ok", "1"));
await context.route("**/*", async (route) => {
const request = route.request();
const local = new URL(request.url()).origin === origin;
// Exercise UI without recording synthetic analytics, purchases or user actions.
if (
!local &&
(request.method() !== "GET" || request.resourceType() === "script")
)
return route.abort();
return route.continue();
});
const page = await context.newPage();
const errors: string[] = [];
const missingAssets: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("response", (response) => {
if (
response.status() >= 400 &&
response.url().startsWith(origin + "/static/")
)
missingAssets.push(new URL(response.url()).pathname);
});
for (const path of [
"/",
"/dramas/",
"/discover/search/",
]) {
const response = await page.goto(origin + path, {
waitUntil: "domcontentloaded",
timeout: 40000,
});
await page.waitForTimeout(500);
if (path !== "/discover/search/")
await page.waitForFunction(
() => typeof (window as unknown as Record<string, unknown>).tracker === "object",
null,
{ timeout: 15000 },
);
for (let index = 0; index < 12; index++) {
const close = page.locator(
"[data-dialog-ad].hk-is-open [data-dialog-ad-item]:not([hidden]) [data-dialog-ad-close]",
);
if (!(await close.count()) || !(await close.first().isVisible())) break;
await close.first().click({ force: true });
}
const layout = await page.evaluate(() => ({
viewport: innerWidth,
document: document.documentElement.scrollWidth,
heading: document.querySelector("h1")?.textContent?.trim() || "",
legacyScripts: Array.from(document.scripts).some((script) =>
/\/static\/js\//.test(script.src),
),
tracker:
typeof (window as unknown as Record<string, unknown>).tracker ===
"object",
}));
results.push({
device: name,
path,
status: response?.status(),
...layout,
});
if (response?.status() !== 200)
throw new Error(`${name} ${path} returned ${response?.status()}`);
if (layout.document > layout.viewport + 1)
throw new Error(
`${name} ${path} overflows horizontally: ${layout.document}`,
);
if (
layout.legacyScripts ||
(path !== "/discover/search/" && !layout.tracker)
)
throw new Error(`${name} ${path} missing shared client runtime`);
if (path === "/") {
const accountEntries = await page.locator(
"[data-login-open], [data-register-open], .hk-user-menu, a[href^='/account']",
).count();
if (accountEntries)
throw new Error(`${name} home still renders ${accountEntries} account entries`);
if (name === "desktop" && await page.locator("[data-hero]").count()) {
await page.hover("[data-hero]");
await page.waitForFunction(() => {
const slide = document.querySelector(".hk-home-hero__slide.hk-is-active");
const image = slide?.querySelector<HTMLImageElement>(".hk-home-hero__poster");
return !!image && image.complete && image.naturalWidth > 0 &&
!image.hasAttribute("data-decrypt-failed") &&
!image.currentSrc.includes("/static/brand/placeholder.svg") &&
getComputedStyle(image).opacity === "1" &&
getComputedStyle(slide!).opacity === "1";
}, null, { timeout: 45000 });
}
await page.screenshot({
path: resolve(output, name + "-home.png"),
fullPage: false,
animations: "disabled",
});
if (name === "mobile") {
await page.locator("[data-drawer-open]").click();
if (
!(await page
.locator("[data-drawer]")
.evaluate((node) => node.classList.contains("hk-is-open")))
)
throw new Error("Mobile drawer did not open");
await page.locator(".hk-drawer__close").click();
} else {
const input = page.locator("[data-header-search-input]");
const suggestionResponse = page.waitForResponse(
(response) => response.url().includes("/api/web/search/suggest"),
{ timeout: 15000 },
);
await input.fill("测试");
await suggestionResponse;
if (
!(await page.locator("[data-header-search-dropdown]").isVisible())
)
throw new Error("Search suggestions did not open");
const download = page.locator("[data-download-open]").first();
if (await download.isVisible()) {
await download.click();
if (
!(await page
.locator("[data-download-pop]")
.evaluate((node) => node.classList.contains("hk-is-open")))
)
throw new Error("Download dialog did not open");
await page.keyboard.press("Escape");
}
}
}
}
for (const removed of ["/account/", "/account/settings/", "/account/recharge/", "/login/"]) {
const response = await context.request.get(origin + removed, { maxRedirects: 0 });
if (response.status() !== 301 || response.headers().location !== "/")
throw new Error(`${name} ${removed} should 301 to /, got ${response.status()} ${response.headers().location}`);
}
const playURL = await page.evaluate(() =>
document
.querySelector<HTMLAnchorElement>('a[href^="/drama/"]')
?.getAttribute("href"),
);
if (playURL) {
await page.goto(origin + playURL, { waitUntil: "domcontentloaded" });
const player = page.locator("[data-play-player]");
results.push({
device: name,
path: "play",
player: await player.count(),
xgplayer: await page.locator(".xgplayer").count(),
video: await page.locator("video").count(),
});
}
if (errors.length || missingAssets.length)
throw new Error(JSON.stringify({ device: name, errors, missingAssets }));
await context.close();
}
await writeFile(
resolve(output, "results.json"),
JSON.stringify(results, null, 2),
);
console.log(
JSON.stringify(
{ pages: results.length, devices: 2, errors: 0, output },
null,
2,
),
);
} finally {
await browser.close();
}