[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,364 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { readFile, mkdtemp, rm } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
import { chromium } from "@playwright/test";
|
||||
import { decryptImageBytes, IMAGE_PLACEHOLDER } from "../src/lib/media/images";
|
||||
import { createStyleFixture } from "./style-fixtures";
|
||||
|
||||
// Exercise the actual React components and API client against local mocks only.
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."),
|
||||
temporary = await mkdtemp(join(tmpdir(), "huakui-catalog-react-"));
|
||||
const bundle = join(temporary, "catalog.js");
|
||||
const heroStyles = await createStyleFixture(["base", "index"]);
|
||||
const plainImage = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jEtQAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
const encryptedImage = decryptImageBytes(
|
||||
plainImage.buffer.slice(plainImage.byteOffset, plainImage.byteOffset + plainImage.byteLength) as ArrayBuffer,
|
||||
);
|
||||
const imageReads = new Map<string, number>();
|
||||
let releaseReplacement: (() => void) | undefined;
|
||||
let replacementArrived: (() => void) | undefined;
|
||||
const replacementStarted = new Promise<void>((done) => { replacementArrived = done; });
|
||||
await build({
|
||||
stdin: {
|
||||
resolveDir: root,
|
||||
loader: "tsx",
|
||||
contents: `
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {useState} from 'react';
|
||||
import {AppRouterContext} from 'next/dist/shared/lib/app-router-context.shared-runtime';
|
||||
import {SiteProvider} from '@/components/providers/SiteProvider';
|
||||
import {HeroReact} from '@/components/catalog/HeroReact';
|
||||
import {SearchMobilePage} from '@/features/catalog/DiscoveryPages';
|
||||
const router={back(){window.__navigation='back'},forward(){},refresh(){},push(path){window.__navigation=path},replace(path){window.__navigation=path},prefetch(){}};
|
||||
const banners=[{id:1,slug:'first',playName:'第一部',cover:'/static/brand/header.svg',banner:'/static/brand/footer.svg',totalEpisode:8,countCollect:5,isCollect:2},{id:2,slug:'second',playName:'第二部',cover:'/static/brand/header.svg',totalEpisode:12,countCollect:7,isCollect:1},{id:3,slug:'third',playName:'第三部',cover:'/static/brand/header.svg',totalEpisode:2,countCollect:0,isCollect:2}];
|
||||
const assetBanners=[{...banners[0],horizontalCover:'/fixture/encrypted-first.png',banner:'/fixture/encrypted-first.png'},{...banners[1],horizontalCover:'/fixture/encrypted-second.png',banner:'/fixture/encrypted-second.png'},{...banners[2],horizontalCover:'/fixture/broken-image.png',banner:'/fixture/encrypted-second.png'}];
|
||||
function HeroAssets(){const [items,setItems]=useState(assetBanners);window.__replaceHeroPoster=source=>setItems(previous=>previous.map((item,index)=>index===0?{...item,horizontalCover:source}:item));return <HeroReact items={items}/>;}
|
||||
createRoot(document.getElementById('root')).render(<AppRouterContext.Provider value={router}><SiteProvider data={{}} dialogs={false}>{location.pathname==='/hero-css/'?<HeroAssets/>:<><HeroReact items={banners}/><SearchMobilePage data={{query:'',trends:[{Rank:1,Title:'都市',Heat:'1 万',Badge:'热'}],recommended:[]}}/></>}</SiteProvider></AppRouterContext.Provider>);
|
||||
`,
|
||||
},
|
||||
bundle: true,
|
||||
platform: "browser",
|
||||
format: "iife",
|
||||
outfile: bundle,
|
||||
tsconfig: join(root, "tsconfig.json"),
|
||||
banner: { js: 'var process={env:{NODE_ENV:"development"}};' },
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"development"',
|
||||
"process.env.__NEXT_ROUTER_BASEPATH": '""',
|
||||
},
|
||||
});
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const path = new URL(request.url || "/", "http://local.test").pathname;
|
||||
if (path === heroStyles.href) {
|
||||
response.setHeader("Content-Type", "text/css;charset=utf-8");
|
||||
response.end(heroStyles.css);
|
||||
return;
|
||||
}
|
||||
if (path === "/") {
|
||||
response.setHeader("Content-Type", "text/html");
|
||||
response.end(
|
||||
'<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script src="/catalog.js"></script></body></html>',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (path === "/hero-css/") {
|
||||
response.setHeader("Content-Type", "text/html;charset=utf-8");
|
||||
response.end(`<!doctype html><html data-hk-page-styles="${heroStyles.tokens}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="${heroStyles.href}"></head><body><div id="root"></div><script src="/catalog.js"></script></body></html>`);
|
||||
return;
|
||||
}
|
||||
if (path.startsWith("/fixture/")) {
|
||||
imageReads.set(path, (imageReads.get(path) || 0) + 1);
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
if (path === "/fixture/broken-image.png") {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
if (path === "/fixture/replacement.png") {
|
||||
await new Promise<void>((done) => { releaseReplacement = done; replacementArrived?.(); });
|
||||
}
|
||||
response.setHeader("Content-Type", "application/octet-stream");
|
||||
response.end(encryptedImage);
|
||||
return;
|
||||
}
|
||||
if (path.startsWith("/search/")) {
|
||||
response.setHeader("Content-Type", "text/html");
|
||||
response.end(
|
||||
"<!doctype html><html><head><title>隔离搜索结果</title></head><body class=\"hk-search-page\"><main data-search-results=\"\"></main></body></html>",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (path === "/catalog.js") {
|
||||
response.setHeader("Content-Type", "application/javascript");
|
||||
response.end(await readFile(bundle));
|
||||
return;
|
||||
}
|
||||
if (path.startsWith("/static/")) {
|
||||
const file = resolve(root, "public", "." + path);
|
||||
if (!file.startsWith(join(root, "public") + "/")) {
|
||||
response.writeHead(403).end();
|
||||
return;
|
||||
}
|
||||
response.setHeader(
|
||||
"Content-Type",
|
||||
extname(file) === ".svg" ? "image/svg+xml" : extname(file) === ".css" ? "text/css" : "application/octet-stream",
|
||||
);
|
||||
response.end(await readFile(file));
|
||||
return;
|
||||
}
|
||||
response.writeHead(404).end();
|
||||
} catch {
|
||||
response.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolveListen) =>
|
||||
server.listen(0, "127.0.0.1", resolveListen),
|
||||
);
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
const configuredChrome = process.env.CHROME_PATH,
|
||||
macChrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
||||
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(configuredChrome || existsSync(macChrome)
|
||||
? { executablePath: configuredChrome || macChrome }
|
||||
: {}),
|
||||
});
|
||||
const context = await browser.newContext(),
|
||||
page = await context.newPage(),
|
||||
errors: string[] = [],
|
||||
requests: {
|
||||
path: string;
|
||||
body: Record<string, unknown>;
|
||||
headers: Record<string, string>;
|
||||
}[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await context.addInitScript({
|
||||
content: `
|
||||
window.__copies=[];window.__events=[];window.__navigation='';
|
||||
Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async function(value){window.__copies.push(value)}}});
|
||||
window.tracker={getSDKCommonHeader:function(){return 'mock-common'},getCommonFields:function(){return {device_id:'mock-device',device:'OTHER',sid:'mock-sid'}}};
|
||||
window.WebSDK={track:new Proxy({},{get:function(_,method){return function(payload){window.__events.push({method:String(method),payload:payload})}}})};
|
||||
if(!localStorage.getItem('hgdj_search_history'))localStorage.setItem('hgdj_search_history',JSON.stringify(['历史词']));
|
||||
`,
|
||||
});
|
||||
let suggestionMode: "normal" | "fail" = "normal";
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request(),
|
||||
url = new URL(request.url()),
|
||||
body = (request.postDataJSON() as Record<string, unknown>) || {};
|
||||
requests.push({ path: url.pathname, body, headers: request.headers() });
|
||||
if (url.pathname === "/api/auth/guest") {
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 200,
|
||||
data: { token: "mock-guest", userId: 12345, type: 0 },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/web/search/suggest") {
|
||||
if (suggestionMode === "fail") {
|
||||
await route.abort("failed");
|
||||
return;
|
||||
}
|
||||
const keyword = url.searchParams.get("keyword") || "";
|
||||
if (keyword === "旧")
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 200));
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 200,
|
||||
keyword,
|
||||
feature: {
|
||||
title: keyword + "作品",
|
||||
cover: "/static/brand/header.svg",
|
||||
url: "/drama/suggest/",
|
||||
meta: "全 8 集",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
title: keyword,
|
||||
query: keyword,
|
||||
kind: "搜索",
|
||||
url: "/search/" + encodeURIComponent(keyword) + "/",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
errors.push("Unexpected API: " + url.pathname);
|
||||
await route.fulfill({ status: 500, json: { code: 500, data: null } });
|
||||
});
|
||||
await page.goto(origin);
|
||||
await page
|
||||
.waitForSelector(".hk-home-hero__share", { timeout: 10000 })
|
||||
.catch((error) => {
|
||||
throw new Error(
|
||||
`React mount failed: ${errors.join("; ")}; ${String(error)}`,
|
||||
);
|
||||
});
|
||||
await page.hover(".hk-home-hero");
|
||||
// The account system was removed: the hero has no collection toggle or account entry.
|
||||
assert.equal(await page.locator(".hk-home-hero__collect").count(), 0);
|
||||
assert.equal(await page.locator("[data-login-open], [data-register-open]").count(), 0);
|
||||
await page.locator(".hk-home-hero__share").first().click();
|
||||
assert.deepEqual(
|
||||
await page.evaluate(
|
||||
() => (window as unknown as { __copies: string[] }).__copies,
|
||||
),
|
||||
[origin + "/drama/first/"],
|
||||
);
|
||||
await page.locator(".hk-home-hero__thumb").nth(1).click();
|
||||
assert.equal(await page.locator("[data-hero-current]").textContent(), "02");
|
||||
assert.equal(
|
||||
await page.locator(".hk-home-hero__slide.hk-is-active .hk-home-hero__title").textContent(),
|
||||
"第二部",
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator(".hk-mobile-search__chip").first().textContent(),
|
||||
"历史词",
|
||||
);
|
||||
await page.getByRole("button", { name: "清除搜索历史", exact: true }).click();
|
||||
assert.equal(
|
||||
await page.locator(".hk-mobile-search__history").isVisible(),
|
||||
false,
|
||||
);
|
||||
const input = page.locator(".hk-mobile-search__input");
|
||||
await input.fill("都市");
|
||||
await page.waitForSelector(".hk-mobile-search__suggestion-feature");
|
||||
assert.equal(
|
||||
await page.locator(".hk-mobile-search__feature-title").textContent(),
|
||||
"都市作品",
|
||||
);
|
||||
await input.fill("旧");
|
||||
await page.waitForTimeout(140);
|
||||
await input.fill("新");
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector(".hk-mobile-search__feature-title")?.textContent ===
|
||||
"新作品",
|
||||
);
|
||||
await page.waitForTimeout(220);
|
||||
assert.equal(
|
||||
await page.locator(".hk-mobile-search__feature-title").textContent(),
|
||||
"新作品",
|
||||
);
|
||||
suggestionMode = "fail";
|
||||
await input.fill("无网");
|
||||
await page.waitForSelector(".hk-mobile-search__suggestion");
|
||||
assert.match(
|
||||
(await page.locator(".hk-mobile-search__suggestion").getAttribute("href")) ||
|
||||
"",
|
||||
/\/search\/%E6%97%A0%E7%BD%91\//,
|
||||
);
|
||||
await input.fill("a/b");
|
||||
const navigation = page.waitForURL(origin + "/search/a%2Fb/");
|
||||
await input.press("Enter");
|
||||
await navigation;
|
||||
assert.equal(new URL(page.url()).pathname, "/search/a%2Fb/");
|
||||
assert.equal(await page.locator("[data-search-results]").count(), 1);
|
||||
assert.equal(await page.locator("body").getAttribute("class"), "hk-search-page");
|
||||
assert.ok(
|
||||
await page.evaluate(() =>
|
||||
JSON.parse(localStorage.getItem("hgdj_search_history") || "[]").includes(
|
||||
"a/b",
|
||||
),
|
||||
),
|
||||
);
|
||||
// The interaction fixture intentionally has no global CSS. Verify the actual
|
||||
// desktop poster opacity contract separately so a loaded-but-transparent image fails.
|
||||
const heroPage = await context.newPage();
|
||||
await heroPage.setViewportSize({ width: 1440, height: 1000 });
|
||||
heroPage.on("pageerror", (error) => errors.push(error.message));
|
||||
const activePoster = ".hk-home-hero__slide.hk-is-active .hk-home-hero__poster";
|
||||
const waitForPoster = async () => {
|
||||
await heroPage.waitForFunction(() => {
|
||||
const image = document.querySelector<HTMLImageElement>(".hk-home-hero__slide.hk-is-active .hk-home-hero__poster");
|
||||
return !!image && image.complete && image.naturalWidth > 0 &&
|
||||
image.getAttribute("data-hero-loaded") === "1" &&
|
||||
Number(getComputedStyle(image).opacity) > 0.99;
|
||||
});
|
||||
return heroPage.locator(activePoster).evaluate((image) => ({
|
||||
src: image.getAttribute("src"),
|
||||
naturalWidth: (image as HTMLImageElement).naturalWidth,
|
||||
opacity: Number(getComputedStyle(image).opacity),
|
||||
}));
|
||||
};
|
||||
await heroPage.goto(origin + "/hero-css/");
|
||||
await heroPage.hover(".hk-home-hero");
|
||||
const firstPoster = await waitForPoster();
|
||||
assert.match(firstPoster.src || "", /^blob:/);
|
||||
assert.equal(firstPoster.naturalWidth, 1, "Encrypted PNG is really decoded by the browser");
|
||||
await heroPage.locator(".hk-home-hero__thumb").nth(1).click();
|
||||
const secondPoster = await waitForPoster();
|
||||
assert.match(secondPoster.src || "", /^blob:/);
|
||||
assert.notEqual(secondPoster.src, firstPoster.src);
|
||||
const firstReads = imageReads.get("/fixture/encrypted-first.png");
|
||||
const secondReads = imageReads.get("/fixture/encrypted-second.png");
|
||||
for (const index of [0, 1, 0]) {
|
||||
await heroPage.locator(".hk-home-hero__thumb").nth(index).click();
|
||||
const revisited = await waitForPoster();
|
||||
assert.equal(revisited.src, index === 0 ? firstPoster.src : secondPoster.src);
|
||||
}
|
||||
assert.equal(imageReads.get("/fixture/encrypted-first.png"), firstReads, "Revisiting a poster reuses its decoded cache");
|
||||
assert.equal(imageReads.get("/fixture/encrypted-second.png"), secondReads);
|
||||
await heroPage.locator(".hk-home-hero__thumb").nth(2).click();
|
||||
const fallback = await waitForPoster();
|
||||
assert.equal(fallback.src, IMAGE_PLACEHOLDER, "Failed poster shows the real shared brand placeholder");
|
||||
assert.equal(fallback.naturalWidth, 480);
|
||||
await heroPage.waitForFunction(() => document.querySelector(".hk-home-hero__slide.hk-is-active .hk-home-hero__poster")?.getAttribute("data-decrypt-failed") === "1");
|
||||
|
||||
await heroPage.locator(".hk-home-hero__thumb").first().click();
|
||||
await waitForPoster();
|
||||
await heroPage.evaluate((source) => {
|
||||
(window as unknown as { __replaceHeroPoster(source: string): void }).__replaceHeroPoster(source);
|
||||
}, "/fixture/replacement.png");
|
||||
await replacementStarted;
|
||||
await heroPage.waitForFunction(() => {
|
||||
const image = document.querySelector<HTMLImageElement>(".hk-home-hero__slide.hk-is-active .hk-home-hero__poster");
|
||||
return image?.getAttribute("src")?.includes("/static/brand/placeholder.svg") &&
|
||||
image.getAttribute("data-hero-loaded") !== "1" &&
|
||||
Number(getComputedStyle(image).opacity) < 0.01;
|
||||
});
|
||||
assert.equal(await heroPage.locator(".hk-home-hero__slide.hk-is-active .hk-home-hero__title").textContent(), "第一部", "The same item receives a new image source");
|
||||
releaseReplacement?.();
|
||||
const replacement = await waitForPoster();
|
||||
assert.match(replacement.src || "", /^blob:/);
|
||||
assert.notEqual(replacement.src, firstPoster.src, "A new source cannot keep the previous poster's readiness/blob");
|
||||
await heroPage.reload();
|
||||
await heroPage.hover(".hk-home-hero");
|
||||
const refreshed = await waitForPoster();
|
||||
assert.match(refreshed.src || "", /^blob:/);
|
||||
assert.equal(await heroPage.locator("[data-hero-current]").textContent(), "01");
|
||||
assert((imageReads.get("/fixture/encrypted-first.png") || 0) > (firstReads || 0), "A refreshed document loads and reveals its image again");
|
||||
await heroPage.close();
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(
|
||||
requests.filter((request) => request.path.startsWith("/api/playlet/collect")),
|
||||
[],
|
||||
"Removed collection API must not be called",
|
||||
);
|
||||
console.log(
|
||||
"React catalog smoke passed: no collection/account entries, share, carousel, history, suggestions, stale requests, offline fallback, encoded search, encrypted hero CSS visibility, cached revisits, visible brand fallback, source replacement and refresh. All APIs mocked.",
|
||||
);
|
||||
await context.close();
|
||||
} finally {
|
||||
releaseReplacement?.();
|
||||
await browser?.close();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user