[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,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();
|
||||
}
|
||||
Reference in New Issue
Block a user