[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,254 @@
|
||||
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.UI_OUTPUT_DIR || "/tmp/hkdj-figma/implementation";
|
||||
const allowEmptyContent = process.env.UI_ALLOW_EMPTY_CONTENT === "1";
|
||||
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>[] = [];
|
||||
const failures: string[] = [];
|
||||
const paths = [
|
||||
"/",
|
||||
"/dramas/",
|
||||
"/series/",
|
||||
"/collections/",
|
||||
"/collection/dushi/",
|
||||
"/rankings/",
|
||||
"/tags/chitie/",
|
||||
"/latest/",
|
||||
"/featured/",
|
||||
"/search/",
|
||||
"/search/测试/",
|
||||
"/discover/search/",
|
||||
"/about/",
|
||||
"/help/",
|
||||
"/terms/",
|
||||
"/privacy/",
|
||||
"/feedback/",
|
||||
"/2257/",
|
||||
"/dmca/",
|
||||
"/navigation/",
|
||||
"/__ui_missing_page__/",
|
||||
];
|
||||
// The account system was removed; every former account page permanently returns home.
|
||||
const removedAccountPaths = [
|
||||
"/account/",
|
||||
"/account/history/",
|
||||
"/account/favorites/",
|
||||
"/account/purchases/",
|
||||
"/account/settings/",
|
||||
"/account/recharge/",
|
||||
"/account/login/",
|
||||
"/account/register/",
|
||||
"/login/",
|
||||
"/profile/",
|
||||
];
|
||||
try {
|
||||
for (const [device, viewport] of [
|
||||
["desktop", { width: 1440, height: 1000 }],
|
||||
["mobile", { width: 375, height: 812 }],
|
||||
] as const) {
|
||||
const context = await browser.newContext({
|
||||
viewport,
|
||||
isMobile: device === "mobile",
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
await context.addInitScript(() => localStorage.setItem("hg_age_ok", "1"));
|
||||
await context.route("**/*", async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
new URL(request.url()).origin !== origin &&
|
||||
(request.method() !== "GET" || request.resourceType() === "script")
|
||||
)
|
||||
return route.abort();
|
||||
if (
|
||||
request.url().includes("/api/statistics/") ||
|
||||
request.url().includes("/api/report/")
|
||||
)
|
||||
return route.fulfill({ json: { code: 200, data: {} } });
|
||||
return route.continue();
|
||||
});
|
||||
for (const path of removedAccountPaths) {
|
||||
const response = await context.request.get(origin + path, { maxRedirects: 0 });
|
||||
if (response.status() !== 301 || response.headers().location !== "/")
|
||||
failures.push(
|
||||
`${device} ${path}: expected 301 to /, got ${response.status()} ${response.headers().location || ""}`,
|
||||
);
|
||||
}
|
||||
const page = await context.newPage();
|
||||
let play = "";
|
||||
for (const path of paths) {
|
||||
const errors: string[] = [];
|
||||
const missing: string[] = [];
|
||||
const onError = (error: Error): void => {
|
||||
errors.push(error.message);
|
||||
};
|
||||
const onResponse = (
|
||||
response: import("@playwright/test").Response,
|
||||
): void => {
|
||||
if (
|
||||
response.status() >= 400 &&
|
||||
response.url().startsWith(origin + "/static/")
|
||||
)
|
||||
missing.push(new URL(response.url()).pathname);
|
||||
};
|
||||
page.on("pageerror", onError);
|
||||
page.on("response", onResponse);
|
||||
try {
|
||||
const response = await page.goto(origin + path, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 40000,
|
||||
});
|
||||
await page.waitForTimeout(
|
||||
path === "/" || path === "/dramas/" ? 1800 : 250,
|
||||
);
|
||||
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 });
|
||||
}
|
||||
if (path === "/") {
|
||||
const playLink = page.locator('a[href^="/drama/"]').first();
|
||||
play = (await playLink.count())
|
||||
? (await playLink.getAttribute("href")) || ""
|
||||
: "";
|
||||
if (!play && !allowEmptyContent)
|
||||
failures.push(
|
||||
`${device} ${path}: no playable content; populate the upstream or use UI_ALLOW_EMPTY_CONTENT=1 for empty-state checks`,
|
||||
);
|
||||
}
|
||||
const layout = await page.evaluate(() => {
|
||||
const boxes = Object.fromEntries(
|
||||
Object.entries({
|
||||
header: ".site-header",
|
||||
logo: ".site-header .brand__logo",
|
||||
footer: ".site-footer",
|
||||
hero: ".hero",
|
||||
listHead: ".list-hero",
|
||||
listFilters: ".list-filters",
|
||||
firstCard: ".grid--list > :first-child",
|
||||
tabbar: ".mobile-tabbar",
|
||||
}).map(([key, selector]) => {
|
||||
const element = document.querySelector(selector);
|
||||
const rect = element?.getBoundingClientRect();
|
||||
return [
|
||||
key,
|
||||
rect
|
||||
? {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}
|
||||
: null,
|
||||
];
|
||||
}),
|
||||
);
|
||||
return {
|
||||
width: innerWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
title: document.title,
|
||||
noindex: /(?:^|[,\s])noindex(?:$|[,\s])/.test(
|
||||
document.querySelector<HTMLMetaElement>('meta[name="robots"]')
|
||||
?.content || "",
|
||||
),
|
||||
background: getComputedStyle(document.body).backgroundColor,
|
||||
accent: getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent")
|
||||
.trim(),
|
||||
oldBrand: document.body.innerText.includes("黄果短剧"),
|
||||
accountEntries: document.querySelectorAll(
|
||||
"[data-login-open], [data-register-open], .hk-user-menu, a[href^='/account']",
|
||||
).length,
|
||||
...boxes,
|
||||
};
|
||||
});
|
||||
const unavailableEntity =
|
||||
allowEmptyContent &&
|
||||
response?.status() === 404 &&
|
||||
(path.startsWith("/collection/") || path.startsWith("/tags/"));
|
||||
const expected =
|
||||
path === "/__ui_missing_page__/" || unavailableEntity ? 404 : 200;
|
||||
if (response?.status() !== expected)
|
||||
failures.push(
|
||||
`${device} ${path}: status ${response?.status()}, expected ${expected}`,
|
||||
);
|
||||
if (unavailableEntity && !layout.noindex)
|
||||
failures.push(
|
||||
`${device} ${path}: missing entity must remain noindex`,
|
||||
);
|
||||
if (layout.scrollWidth > layout.width + 1)
|
||||
failures.push(
|
||||
`${device} ${path}: horizontal overflow ${layout.scrollWidth}`,
|
||||
);
|
||||
if (layout.accountEntries)
|
||||
failures.push(`${device} ${path}: removed account entries remain`);
|
||||
if (layout.oldBrand)
|
||||
failures.push(`${device} ${path}: former brand remains visible`);
|
||||
if (errors.length || missing.length)
|
||||
failures.push(
|
||||
`${device} ${path}: ${JSON.stringify({ errors, missing })}`,
|
||||
);
|
||||
results.push({
|
||||
device,
|
||||
path,
|
||||
status: response?.status(),
|
||||
unavailableEntity,
|
||||
...layout,
|
||||
errors,
|
||||
missing,
|
||||
});
|
||||
await page.screenshot({
|
||||
path: resolve(
|
||||
output,
|
||||
`${device}-${path === "/" ? "home" : path.split("/").filter(Boolean).join("-")}.png`,
|
||||
),
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push(`${device} ${path}: ${String(error)}`);
|
||||
} finally {
|
||||
page.off("pageerror", onError);
|
||||
page.off("response", onResponse);
|
||||
}
|
||||
}
|
||||
if (play) {
|
||||
await page.goto(origin + play, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(300);
|
||||
results.push({
|
||||
device,
|
||||
path: play,
|
||||
player: await page.locator("[data-play-player]").count(),
|
||||
xgplayer: await page.locator(".xgplayer").count(),
|
||||
video: await page.locator("video").count(),
|
||||
});
|
||||
await page.screenshot({
|
||||
path: resolve(output, device + "-play.png"),
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
});
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
await writeFile(
|
||||
resolve(output, "results.json"),
|
||||
JSON.stringify({ results, failures }, null, 2),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({ pages: results.length, failures, output }, null, 2),
|
||||
);
|
||||
if (failures.length) process.exitCode = 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user