UI-UX/scripts/cdp-screenshot.mjs
iye 10878ddb3f
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
feat(vote): 重构投票模型为终身 12 票 + 每艺人 1 票
前端:
- store 改为 votedArtists[] + zustand persist
- VoteModal 删除 1/3/5/ALL 选择器,改三态(待投/已投/满额)
- 卡片/排行/详情页加 hasVoted 状态 + ✓ 角标
- Hero 右上角 Countdown 替换为 HeroVoteProgress(12 格点亮进度)
- /me 改为终身额度叙事(QuotaCard / StatsGrid / MyFanSupport)

后端:
- votes 表加 @@unique([userId, artistId])(已 apply 到生产 RDS)
- /api/vote 重写:12 票上限 + P2002 ALREADY_VOTED + P2003 NOT_FOUND 兜底
- /api/me 新增 votedArtists[] + voteQuota,移除 dailyQuota
- 新增 ERR.ALREADY_VOTED 错误码

测试:
- DB 层 5/5 + E2E 18/18 通过(scripts/e2e-vote-flow.sh)
- 修复 P2003 FK 违反未识别的 bug

详情见 docs/todo/voting-refactor-完成报告.md 与 voting-refactor-backend-完成报告.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:14:57 +08:00

352 lines
11 KiB
JavaScript

// 一次性截图脚本 —— 用系统 Chrome 的 remote debugging 协议直接通信,
// 避免装 puppeteer/playwright(项目 pnpm store 状态不允许)。
//
// 通过 Fetch.requestPaused 拦截 /api/auth/session 注入 mock next-auth session,
// 让客户端 useSession() 以为已登录,从而能触发 VoteModal / 进入 /me 页。
import { spawn } from "node:child_process";
import { writeFile, mkdir } from "node:fs/promises";
import { setTimeout as wait } from "node:timers/promises";
const CHROME = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
const PORT = 9333;
const PROFILE = "C:\\Users\\10419\\AppData\\Local\\Temp\\cs-voting-shot";
const OUT_DIR = "d:\\ClaudeProjects\\虚拟明星\\UI-UX\\docs\\screenshots\\voting-refactor";
const ORIGIN = "http://localhost:3000";
const MOCK_SESSION = {
user: { id: "mock-user", name: "测试用户" },
expires: new Date(Date.now() + 86400 * 1000).toISOString(),
};
async function launchChrome() {
const proc = spawn(
CHROME,
[
`--headless=new`,
`--disable-gpu`,
`--remote-debugging-port=${PORT}`,
`--user-data-dir=${PROFILE}`,
`--window-size=1500,900`,
`--hide-scrollbars`,
`--no-first-run`,
`--no-default-browser-check`,
`about:blank`,
],
{ stdio: "ignore", detached: true },
);
proc.unref();
for (let i = 0; i < 30; i++) {
try {
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
if (r.ok) return proc.pid;
} catch (_e) {
void _e;
}
await wait(300);
}
throw new Error("Chrome remote debugging did not come up");
}
async function killChrome(pid) {
try {
process.kill(pid);
} catch {
/* */
}
spawn("taskkill", ["/F", "/PID", String(pid), "/T"], { stdio: "ignore" });
}
async function openPage() {
const r = await fetch(`http://127.0.0.1:${PORT}/json/new?about:blank`, {
method: "PUT",
});
return await r.json();
}
class CDP {
constructor(wsUrl) {
this.ws = null;
this.wsUrl = wsUrl;
this.id = 0;
this.pending = new Map();
this.listeners = new Set();
}
async connect() {
this.ws = new WebSocket(this.wsUrl);
await new Promise((res, rej) => {
this.ws.addEventListener("open", () => res(), { once: true });
this.ws.addEventListener("error", (e) => rej(e), { once: true });
});
this.ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id);
this.pending.delete(msg.id);
if (msg.error) reject(new Error(msg.error.message));
else resolve(msg.result);
} else if (msg.method) {
for (const cb of this.listeners) cb(msg);
}
});
}
on(cb) {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
send(method, params = {}) {
const id = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }));
});
}
close() {
this.ws.close();
}
}
function b64(str) {
return Buffer.from(str).toString("base64");
}
/** 安装 /api/auth/session 拦截器 —— 返回 mock session 让 useSession 觉得已登录。
* /me 路由会调用 next-auth/server,这个对客户端透明 —— 但 /me 页是 server component 包了
* client MeContent,如果 server 端 redirect 我们抓 not handled。简单做法:直接 navigate
* 到 /me 仍可能 redirect,但 hero / 卡片 / vote modal 这些纯 client 用 useSession 都 OK。
*/
async function setupSessionMock(cdp) {
await cdp.send("Fetch.enable", {
patterns: [{ urlPattern: "*/api/auth/session*" }],
});
cdp.on(async (msg) => {
if (msg.method !== "Fetch.requestPaused") return;
const { requestId, request } = msg.params;
if (request.url.includes("/api/auth/session")) {
const body = b64(JSON.stringify(MOCK_SESSION));
await cdp.send("Fetch.fulfillRequest", {
requestId,
responseCode: 200,
responseHeaders: [
{ name: "Content-Type", value: "application/json" },
{ name: "Cache-Control", value: "no-store" },
],
body,
});
} else {
await cdp.send("Fetch.continueRequest", { requestId });
}
});
}
async function waitForLoad(cdp, ms = 2000) {
await wait(ms);
}
async function setLocalStorage(cdp, items) {
for (const [key, value] of Object.entries(items)) {
await cdp.send("Runtime.evaluate", {
expression: `localStorage.setItem(${JSON.stringify(key)}, ${JSON.stringify(value)})`,
});
}
}
async function clearLocalStorage(cdp) {
await cdp.send("Runtime.evaluate", { expression: `localStorage.clear()` });
}
async function navigate(cdp, url, settleMs = 2500) {
await cdp.send("Page.navigate", { url });
await waitForLoad(cdp, settleMs);
}
async function pauseVideos(cdp) {
await cdp.send("Runtime.evaluate", {
expression: `document.querySelectorAll('video').forEach(v => { try { v.pause(); v.currentTime = 0.5; } catch{} });`,
});
}
async function screenshotFull(cdp, path) {
const r = await cdp.send("Page.captureScreenshot", { format: "png" });
await writeFile(path, Buffer.from(r.data, "base64"));
console.log(`[ok] ${path}`);
}
async function screenshotElement(cdp, selector, path, padding = 8) {
const r = await cdp.send("Runtime.evaluate", {
expression: `(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.left, y: r.top, width: r.width, height: r.height };
})()`,
returnByValue: true,
});
if (!r.result || !r.result.value) {
console.log(`[skip] no element ${selector} for ${path}`);
return false;
}
const b = r.result.value;
const cap = await cdp.send("Page.captureScreenshot", {
format: "png",
clip: {
x: Math.max(0, b.x - padding),
y: Math.max(0, b.y - padding),
width: Math.max(1, b.width + padding * 2),
height: Math.max(1, b.height + padding * 2),
scale: 1,
},
});
await writeFile(path, Buffer.from(cap.data, "base64"));
console.log(`[ok] ${path} (${selector})`);
return true;
}
function makeVoteState(ids) {
return JSON.stringify({ state: { votedArtists: ids }, version: 0 });
}
async function main() {
await mkdir(OUT_DIR, { recursive: true });
console.log("[boot] launching Chrome...");
const pid = await launchChrome();
console.log(`[boot] Chrome pid=${pid} port=${PORT}`);
try {
const target = await openPage();
const cdp = new CDP(target.webSocketDebuggerUrl);
await cdp.connect();
await cdp.send("Page.enable");
await cdp.send("Network.enable");
await cdp.send("Runtime.enable");
await cdp.send("Emulation.setDeviceMetricsOverride", {
width: 1500,
height: 900,
deviceScaleFactor: 1,
mobile: false,
});
await setupSessionMock(cdp);
// ===== 1. Hero 进度三态(裁切胶囊 + 整图各一份)=====
// 1a. 0/12 (已登录但未投)
// user-data-dir 复用会带上上次的 localStorage —— 必须先进 ORIGIN 上下文再 clear
await navigate(cdp, ORIGIN, 500);
await clearLocalStorage(cdp);
await navigate(cdp, ORIGIN);
await pauseVideos(cdp);
await wait(800);
await screenshotFull(cdp, `${OUT_DIR}\\01a-hero-0of12.png`);
await screenshotElement(
cdp,
"[data-hero-vote-progress]",
`${OUT_DIR}\\01a-progress-0of12.png`,
12,
);
// 1b. 5/12
await setLocalStorage(cdp, {
"cyber-star-vote": makeVoteState(["001", "002", "003", "004", "005"]),
});
await navigate(cdp, ORIGIN);
await pauseVideos(cdp);
await wait(800);
await screenshotFull(cdp, `${OUT_DIR}\\01b-hero-5of12.png`);
await screenshotElement(
cdp,
"[data-hero-vote-progress]",
`${OUT_DIR}\\01b-progress-5of12.png`,
12,
);
// 1c. 12/12
await setLocalStorage(cdp, {
"cyber-star-vote": makeVoteState([
"001", "002", "003", "004", "005", "006",
"007", "008", "009", "010", "011", "012",
]),
});
await navigate(cdp, ORIGIN);
await pauseVideos(cdp);
await wait(800);
await screenshotFull(cdp, `${OUT_DIR}\\01c-hero-12of12.png`);
await screenshotElement(
cdp,
"[data-hero-vote-progress]",
`${OUT_DIR}\\01c-progress-12of12.png`,
12,
);
// ===== 2. 艺人卡片角标对比 =====
// 投了 1/3/5 — 卡片网格里能看到混合态(已投紫框✓ vs 未投灰框)
await setLocalStorage(cdp, {
"cyber-star-vote": makeVoteState(["001", "003", "005"]),
});
await navigate(cdp, ORIGIN);
await pauseVideos(cdp);
await cdp.send("Runtime.evaluate", {
expression: `document.getElementById('artists')?.scrollIntoView({behavior:'instant',block:'start'}); window.scrollBy(0, 100);`,
});
await wait(1000);
await screenshotFull(cdp, `${OUT_DIR}\\02-artist-cards-mixed.png`);
// ===== 3. /me 页 =====
// useSession returns mock session → MeContent 应该正常渲染
// 但 /me 是 server component:它在 server 端调 auth() — 我们 intercept 仅作用 client。
// 实际 server component 不会用到 fetch /api/auth/session,它用 cookies 直接验。
// 没有 next-auth cookie → server redirect。我们尝试,如果失败就截 redirect 后状态。
await navigate(cdp, `${ORIGIN}/me`, 1500);
// 检查是否被重定向
const urlInfo = await cdp.send("Runtime.evaluate", {
expression: "location.pathname",
returnByValue: true,
});
if (urlInfo.result.value !== "/me") {
console.log(
`[note] /me redirected to ${urlInfo.result.value} — server auth() not bypassed`,
);
// 在 hash 模式下不会 redirect; 强制 navigate 后端 client only render
// 退而求其次:直接构造一个空白 page 在客户端 render MeContent — 无法,跳过
}
await screenshotFull(cdp, `${OUT_DIR}\\03-me-page.png`);
// ===== 4. VoteModal 正常态(未投 + 未满) =====
await setLocalStorage(cdp, {
"cyber-star-vote": makeVoteState([]),
});
await navigate(cdp, ORIGIN);
await pauseVideos(cdp);
await wait(1200);
// 滚到卡片区点第一张卡的投票按钮
await cdp.send("Runtime.evaluate", {
expression: `document.getElementById('artists')?.scrollIntoView({behavior:'instant',block:'start'}); window.scrollBy(0, 100);`,
});
await wait(500);
const clicked = await cdp.send("Runtime.evaluate", {
expression: `(() => {
const btns = Array.from(document.querySelectorAll('button'));
const target = btns.find(b => (b.textContent || '').trim() === '投票');
if (target) { target.click(); return true; }
return false;
})()`,
returnByValue: true,
});
console.log(`[note] vote modal trigger: ${clicked.result.value}`);
await wait(1000);
await screenshotFull(cdp, `${OUT_DIR}\\04-vote-modal-normal.png`);
await screenshotElement(
cdp,
'[role="dialog"]',
`${OUT_DIR}\\04-vote-modal-cropped.png`,
24,
);
cdp.close();
console.log("[done]");
} finally {
await killChrome(pid);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});