[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,340 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve, extname } from "node:path";
|
||||
import { build } from "esbuild";
|
||||
import { chromium, type Browser, type Page } from "@playwright/test";
|
||||
import manifest from "../src/app/manifest";
|
||||
import { createStyleFixture } from "./style-fixtures";
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
setPwaMode(mode: "fullscreen" | "standalone", active: boolean): void;
|
||||
setIosStandalone(active: boolean): void;
|
||||
setNativeFullscreen(active: boolean): void;
|
||||
mountPwaProvider(active: boolean): void;
|
||||
}
|
||||
|
||||
// Real React provider, shared dialog/tabbar and official player; no production API or CDN.
|
||||
const cwd = process.cwd();
|
||||
const temp = await mkdtemp(join(tmpdir(), "huakui-pwa-"));
|
||||
const output = process.env.PWA_SMOKE_OUTPUT || "/tmp/hkdj-pwa-smoke";
|
||||
let browser: Browser | undefined;
|
||||
let server: Server | undefined;
|
||||
let origin = "";
|
||||
let networkReads = 0;
|
||||
const requests: string[] = [];
|
||||
const results: Record<string, unknown>[] = [];
|
||||
|
||||
async function waitForInstalled(page: Page, expected: boolean) {
|
||||
await page.waitForFunction(
|
||||
(active) => document.documentElement.classList.contains("hk-pwa-standalone") === active,
|
||||
expected,
|
||||
);
|
||||
}
|
||||
|
||||
async function fullscreen(page: Page, css: boolean, viewport: { width: number; height: number }) {
|
||||
await page.locator(".xgplayer-fullscreen").click({ force: true });
|
||||
await page.waitForFunction(
|
||||
(useCss) => useCss
|
||||
? !!document.querySelector(".xgplayer-is-cssfullscreen")
|
||||
: !!document.fullscreenElement,
|
||||
css,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
({ width, height }) => {
|
||||
const rect = document.querySelector(".hk-video-player")!.getBoundingClientRect();
|
||||
return Math.abs(rect.left) < 1 && Math.abs(rect.top) < 1 &&
|
||||
Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1;
|
||||
},
|
||||
viewport,
|
||||
);
|
||||
const measurements = await page.locator(".hk-video-player").evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const controls = getComputedStyle(element.querySelector(".xgplayer-controls")!);
|
||||
return { height: style.height, left: controls.left, right: controls.right, bottom: controls.bottom };
|
||||
});
|
||||
assert.equal(Math.round(parseFloat(measurements.height)), viewport.height, "Fullscreen uses the complete dynamic viewport");
|
||||
if (css) {
|
||||
assert.equal(parseFloat(measurements.left), 20);
|
||||
assert.equal(parseFloat(measurements.right), 20);
|
||||
assert.equal(parseFloat(measurements.bottom), 34);
|
||||
}
|
||||
// XGPlayer's native transition can settle after its fullscreen class appears.
|
||||
await page.waitForTimeout(250);
|
||||
return measurements;
|
||||
}
|
||||
|
||||
async function exitFullscreen(page: Page) {
|
||||
await page.locator(".xgplayer-fullscreen").click({ force: true });
|
||||
await page.waitForFunction(() => !document.fullscreenElement && !document.querySelector(".xgplayer-is-cssfullscreen"));
|
||||
await page.waitForTimeout(200);
|
||||
assert.notEqual(await page.evaluate(() => getComputedStyle(document.body).overflowY), "hidden", "Fullscreen exit restores page scrolling");
|
||||
await page.evaluate(() => window.scrollTo(0, 180));
|
||||
await page.waitForFunction(() => window.scrollY >= 179);
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir(output, { recursive: true });
|
||||
const generated = spawnSync("ffmpeg", [
|
||||
"-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i",
|
||||
"testsrc2=size=320x180:rate=24", "-t", "8", "-c:v", "libx264",
|
||||
"-pix_fmt", "yuv420p", "-movflags", "+faststart", join(temp, "fixture.mp4"),
|
||||
], { encoding: "utf8" });
|
||||
assert.equal(generated.status, 0, generated.stderr || "ffmpeg is required for real player validation");
|
||||
const entry = join(temp, "fixture.tsx");
|
||||
await writeFile(entry, `
|
||||
import {useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {PwaProvider} from '${cwd}/src/components/pwa/PwaProvider';
|
||||
import {VideoPlayer} from '${cwd}/src/components/player/VideoPlayer';
|
||||
import {MobileTabbar} from '${cwd}/src/components/layout/MobileTabbar';
|
||||
import {Dialog} from '${cwd}/src/components/ui/Dialog';
|
||||
function Fixture(){
|
||||
const [provider,setProvider]=useState(true),[dialog,setDialog]=useState(false);
|
||||
window.mountPwaProvider=setProvider;
|
||||
return <>{provider&&<PwaProvider/>}
|
||||
<header className="hk-site-header hk-is-scrolled" id="fixed-header"><strong>花魁短剧</strong><span>PWA</span></header>
|
||||
<main className="hk-playback-page" id="flow"><div id="flow-probe"/>
|
||||
<div className="fixture-content"><section className="hk-playback-player"><VideoPlayer url={location.origin+'/fixture.mp4'} /></section>
|
||||
<button id="open-modal" onClick={()=>setDialog(true)}>打开对话框</button>
|
||||
<div className="fixture-scroll">独立播放器与安全区回归</div>
|
||||
</div>
|
||||
</main>
|
||||
<MobileTabbar data={{requestPath:'/'}}/>
|
||||
{dialog&&<Dialog className="hk-age-gate hk-is-open" label="PWA 测试对话框" onClose={()=>setDialog(false)}>
|
||||
<div className="hk-age-gate__mask"/><div className="hk-age-gate__dialog" id="modal-panel">
|
||||
<button onClick={()=>setDialog(false)}>关闭</button>
|
||||
<h2>安全区对话框</h2>{Array.from({length:32},(_,i)=><p key={i}>第{i+1}项:内容可以滚动,关闭按钮可达。</p>)}
|
||||
</div>
|
||||
</Dialog>}
|
||||
</>;
|
||||
}
|
||||
createRoot(document.getElementById('root')).render(<Fixture/>);
|
||||
`);
|
||||
await build({
|
||||
entryPoints: [entry], bundle: true, platform: "browser", format: "iife",
|
||||
outfile: join(temp, "app.js"), tsconfig: join(cwd, "tsconfig.json"),
|
||||
nodePaths: [join(cwd, "node_modules")], define: { "process.env.NODE_ENV": '"production"' },
|
||||
});
|
||||
const styles = ["base", "header", "play", "mobileTabbar", "eighteen", "pwa"];
|
||||
const pwaStyles = await createStyleFixture(styles);
|
||||
server = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url!, origin || "http://localhost");
|
||||
requests.push(url.pathname);
|
||||
if (url.pathname === pwaStyles.href) {
|
||||
response.setHeader("Content-Type", "text/css;charset=utf-8");
|
||||
response.end(pwaStyles.css);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/network-check") {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(JSON.stringify({ count: ++networkReads }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/manifest.webmanifest") {
|
||||
response.setHeader("Content-Type", "application/manifest+json");
|
||||
response.end(JSON.stringify(manifest()));
|
||||
return;
|
||||
}
|
||||
let file: string | undefined;
|
||||
if (url.pathname === "/sw.js") {
|
||||
file = join(cwd, "public/sw.js");
|
||||
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
} else if (url.pathname.startsWith("/static/")) {
|
||||
file = resolve(cwd, "public", "." + url.pathname);
|
||||
assert(file.startsWith(resolve(cwd, "public") + "/"));
|
||||
} else if (["/app.js", "/app.css", "/fixture.mp4"].includes(url.pathname)) {
|
||||
file = join(temp, url.pathname.slice(1));
|
||||
}
|
||||
if (file) {
|
||||
const bytes = await readFile(file);
|
||||
response.setHeader("Content-Type", ({ ".js": "application/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".mp4": "video/mp4" } as Record<string, string>)[extname(file)] || "application/octet-stream");
|
||||
const range = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range || "");
|
||||
if (range) {
|
||||
const start = Number(range[1]);
|
||||
const end = Math.min(range[2] ? Number(range[2]) : bytes.length - 1, bytes.length - 1);
|
||||
response.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${bytes.length}`, "Accept-Ranges": "bytes" });
|
||||
response.end(bytes.subarray(start, end + 1));
|
||||
} else response.end(bytes);
|
||||
return;
|
||||
}
|
||||
if (url.pathname !== "/") {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
response.setHeader("Content-Type", "text/html;charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(`<!doctype html><html lang="zh-CN" data-hk-page-styles="${pwaStyles.tokens}"><head><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><link rel="manifest" href="/manifest.webmanifest"><link rel="stylesheet" href="/app.css"><link rel="stylesheet" href="${pwaStyles.href}"><style>.fixture-content{width:min(100%,960px);margin:auto}.fixture-scroll{height:1500px;padding:24px}#open-modal{margin:16px;padding:12px}#flow-probe{height:0}.hk-age-gate__dialog p{margin:12px 0;flex-shrink:0}</style></head><body><div id="root"></div><script src="/app.js"></script></body></html>`);
|
||||
} catch (error) {
|
||||
response.writeHead(500).end(String(error));
|
||||
}
|
||||
});
|
||||
await new Promise<void>(done => server!.listen(0, "127.0.0.1", done));
|
||||
const address = server.address();
|
||||
assert(address && typeof address !== "string");
|
||||
origin = `http://127.0.0.1:${address.port}`;
|
||||
browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", headless: true });
|
||||
|
||||
for (const [name, viewport] of [
|
||||
["portrait", { width: 390, height: 844 }],
|
||||
["landscape", { width: 844, height: 390 }],
|
||||
["desktop", { width: 1440, height: 900 }],
|
||||
] as const) {
|
||||
const context = await browser.newContext({ viewport });
|
||||
await context.route("**/*", route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort());
|
||||
await context.addInitScript(() => {
|
||||
// tsx preserves callback names with this helper; serialized Playwright callbacks need it too.
|
||||
Object.defineProperty(window, "__name", { value: Function("value", "return value"), configurable: true });
|
||||
const fixture = window as unknown as FixtureWindow;
|
||||
const originalMatchMedia = window.matchMedia.bind(window);
|
||||
const states = new Map<string, { matches: boolean; target: EventTarget; list: MediaQueryList }>();
|
||||
window.matchMedia = (query) => {
|
||||
if (!/^\(display-mode: (fullscreen|standalone)\)$/.test(query)) return originalMatchMedia(query);
|
||||
let state = states.get(query);
|
||||
if (!state) {
|
||||
const target = new EventTarget();
|
||||
const list = Object.assign(target, { media: query, onchange: null,
|
||||
addListener(listener: EventListener) { target.addEventListener("change", listener); },
|
||||
removeListener(listener: EventListener) { target.removeEventListener("change", listener); },
|
||||
}) as unknown as MediaQueryList;
|
||||
state = { matches: false, target, list };
|
||||
const current = state;
|
||||
Object.defineProperty(list, "matches", { get: () => current.matches });
|
||||
states.set(query, state);
|
||||
}
|
||||
return state.list;
|
||||
};
|
||||
fixture.setPwaMode = (mode, active) => {
|
||||
const query = `(display-mode: ${mode})`;
|
||||
window.matchMedia(query);
|
||||
const state = states.get(query)!;
|
||||
state.matches = active;
|
||||
state.target.dispatchEvent(Object.assign(new Event("change"), { matches: active, media: query }));
|
||||
};
|
||||
let ios = false;
|
||||
Object.defineProperty(navigator, "standalone", { configurable: true, get: () => ios });
|
||||
fixture.setIosStandalone = active => {
|
||||
ios = active;
|
||||
fixture.setPwaMode("fullscreen", states.get("(display-mode: fullscreen)")?.matches || false);
|
||||
};
|
||||
let native = document.fullscreenEnabled;
|
||||
Object.defineProperty(document, "fullscreenEnabled", { configurable: true, get: () => native });
|
||||
Object.defineProperty(document, "webkitFullscreenEnabled", { configurable: true, get: () => false });
|
||||
fixture.setNativeFullscreen = active => { native = active; };
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.goto(origin, { waitUntil: "networkidle" });
|
||||
await page.waitForFunction(() => !!navigator.serviceWorker.controller && !!document.querySelector("video")).catch(async error => {
|
||||
console.error({ errors, requests, state: await page.evaluate(async () => ({ secure: window.isSecureContext, video: !!document.querySelector("video"), controller: navigator.serviceWorker.controller?.scriptURL, registrations: (await navigator.serviceWorker.getRegistrations()).map(registration => ({ scope: registration.scope, active: registration.active?.state, installing: registration.installing?.state })) })) });
|
||||
throw error;
|
||||
});
|
||||
await waitForInstalled(page, false);
|
||||
const initial = await page.evaluate(() => {
|
||||
const style = getComputedStyle(document.body);
|
||||
const header = document.querySelector("#fixed-header")!.getBoundingClientRect();
|
||||
return { borders: [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth], headerTop: header.top, headerHeight: header.height, flowTop: document.querySelector("#flow-probe")!.getBoundingClientRect().top };
|
||||
});
|
||||
assert.deepEqual(initial.borders, ["0px", "0px", "0px", "0px"], `${name}: normal browser keeps zero safe-area borders`);
|
||||
assert.equal(initial.headerTop, 0);
|
||||
const sw = await page.evaluate(async () => {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const a = await (await fetch("/network-check")).json();
|
||||
const b = await (await fetch("/network-check")).json();
|
||||
return { scope: registration.scope, updateViaCache: registration.updateViaCache, caches: await caches.keys(), a: a.count, b: b.count, script: navigator.serviceWorker.controller!.scriptURL };
|
||||
});
|
||||
assert.equal(sw.scope, origin + "/");
|
||||
assert.equal(sw.script, origin + "/sw.js");
|
||||
assert.equal(sw.updateViaCache, "none");
|
||||
assert.deepEqual(sw.caches, []);
|
||||
assert.equal(sw.b, sw.a + 1, "Controlled requests continue reaching the network");
|
||||
for (const mode of ["standalone", "fullscreen"] as const) {
|
||||
await page.evaluate(mode => (window as unknown as FixtureWindow).setPwaMode(mode, true), mode);
|
||||
await waitForInstalled(page, true);
|
||||
await page.evaluate(mode => (window as unknown as FixtureWindow).setPwaMode(mode, false), mode);
|
||||
await waitForInstalled(page, false);
|
||||
}
|
||||
await page.evaluate(() => (window as unknown as FixtureWindow).setIosStandalone(true));
|
||||
await waitForInstalled(page, true);
|
||||
await page.evaluate(() => {
|
||||
for (const [side, value] of Object.entries({ top: 44, right: 20, bottom: 34, left: 20 })) document.documentElement.style.setProperty("--pwa-safe-" + side, value + "px");
|
||||
});
|
||||
const safe = await page.evaluate(() => {
|
||||
const rect = (selector: string) => { const r = document.querySelector(selector)!.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, bottom: r.bottom }; };
|
||||
const tab = document.querySelector(".hk-mobile-tabbar")!;
|
||||
const body = getComputedStyle(document.body);
|
||||
return { header: rect("#fixed-header"), flow: rect("#flow-probe"), tab: rect(".hk-mobile-tabbar"), tabDisplay: getComputedStyle(tab).display, tabPadding: getComputedStyle(tab).paddingBottom, bodyBottom: body.paddingBottom, bodyBorderBottom: body.borderBottomWidth, bodyBorders: [body.borderTopWidth, body.borderRightWidth, body.borderLeftWidth], scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth };
|
||||
});
|
||||
assert.equal(safe.header.y, 44);
|
||||
assert.equal(safe.header.height, initial.headerHeight, `${name}: header measurement excludes the top inset`);
|
||||
assert.equal(safe.flow.y, initial.flowTop + 44, `${name}: safe-area added once to normal flow`);
|
||||
assert(safe.flow.y >= safe.header.bottom, `${name}: fixed header never covers the flow boundary`);
|
||||
assert.deepEqual(safe.bodyBorders, ["44px", "20px", "20px"]);
|
||||
assert(safe.scrollWidth <= safe.clientWidth, `${name}: no horizontal overflow`);
|
||||
if (viewport.width <= 768) {
|
||||
// The account tab was removed with the login system: only 首页/短剧/剧集 remain.
|
||||
assert.deepEqual(
|
||||
await page.locator(".hk-mobile-tabbar__item").allTextContents(),
|
||||
["首页", "短剧", "剧集"],
|
||||
);
|
||||
assert.equal(safe.tab.height, 90);
|
||||
assert.equal(safe.tabPadding, "34px");
|
||||
assert.equal(safe.bodyBottom, "90px");
|
||||
assert.equal(safe.bodyBorderBottom, "0px", "Visible TabBar owns bottom safety without another body border");
|
||||
assert.equal(safe.tab.bottom, viewport.height);
|
||||
} else {
|
||||
assert.equal(safe.tabDisplay, "none");
|
||||
assert.equal(safe.bodyBorderBottom, "34px", "Hidden TabBar leaves bottom safety to the body border");
|
||||
}
|
||||
await page.locator("#open-modal").click();
|
||||
await page.waitForSelector("#modal-panel");
|
||||
const modal = await page.locator("#modal-panel").evaluate(element => {
|
||||
const r = element.getBoundingClientRect();
|
||||
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, scroll: element.scrollHeight, client: element.clientHeight, overflow: getComputedStyle(element).overflowY };
|
||||
});
|
||||
assert(modal.top >= 44 && modal.bottom <= viewport.height - 34, `${name}: modal stays within top/bottom safe areas`);
|
||||
assert(modal.left >= 20 && modal.right <= viewport.width - 20, `${name}: modal stays within side safe areas`);
|
||||
assert(modal.scroll > modal.client && modal.overflow === "auto", `${name}: tall modal remains scrollable`);
|
||||
assert.equal(await page.evaluate(() => document.body.style.overflow), "hidden");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector("#modal-panel", { state: "detached" });
|
||||
assert.notEqual(await page.evaluate(() => getComputedStyle(document.body).overflowY), "hidden");
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
if (name !== "desktop") await page.screenshot({ path: join(output, name + "-safe-area.png") });
|
||||
|
||||
await page.locator("video").evaluate(async element => {
|
||||
const video = element as HTMLVideoElement;
|
||||
video.muted = true;
|
||||
await video.play();
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector("video")!.currentTime > 0.1);
|
||||
// Installed iOS without the document fullscreen API must use the CSS fallback.
|
||||
await page.evaluate(() => (window as unknown as FixtureWindow).setNativeFullscreen(false));
|
||||
const css = await fullscreen(page, true, viewport);
|
||||
if (name === "portrait") await page.screenshot({ path: join(output, "portrait-css-fullscreen.png") });
|
||||
await exitFullscreen(page);
|
||||
await page.evaluate(() => { window.scrollTo(0, 0); (window as unknown as FixtureWindow).setNativeFullscreen(true); });
|
||||
const native = await fullscreen(page, false, viewport);
|
||||
if (name === "desktop") await page.screenshot({ path: join(output, "desktop-native-fullscreen.png") });
|
||||
await exitFullscreen(page);
|
||||
await page.evaluate(() => { (window as unknown as FixtureWindow).setIosStandalone(false); for (const side of ["top", "right", "bottom", "left"]) document.documentElement.style.removeProperty("--pwa-safe-" + side); });
|
||||
await waitForInstalled(page, false);
|
||||
assert.deepEqual(errors, [], `${name}: no browser runtime errors`);
|
||||
results.push({ name, viewport, worker: true, modeChanges: true, safe, modal, cssFullscreen: css, nativeFullscreen: native, scrollRestored: true });
|
||||
await context.close();
|
||||
console.log(`${name}: provider modes, network-only SW, safe areas, modal, CSS/native fullscreen and scroll restoration passed`);
|
||||
}
|
||||
assert(!requests.some(path => path.startsWith("/api/")), "The PWA fixture never calls a production API");
|
||||
await writeFile(join(output, "results.json"), JSON.stringify(results, null, 2) + "\n");
|
||||
console.log(`PWA browser regression passed. Results: ${output}`);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
server?.closeAllConnections();
|
||||
if (server?.listening) await new Promise<void>(done => server!.close(() => done()));
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user