Files
zycandClaude Fable 5 216a711291 后端生成闸+多项修复;前端全站更新;QA 审计与报告
后端:
- 新增 celery_health 生成前置闸——无 worker 在线时图片/视频生成入口
  一律 503,防"提交到 ARK 后无人轮询、结果悬空+额度冻结"的数据丢失
- 拼接导出:帧率跟随源众数、字幕逐句重映射、原声/BGM 混音修复
- 资金核算审计脚本 + genesis 账目回填 + 滞留预留清理命令
- 接入 yunqi provider 与豆包 TTS 模型(catalog/migrations/bootstrap 命令)

前端:全站页面更新(pipeline/library/products/projects/team/account 等),
新增共享 pager 分页组件

QA:刷新 function-audit 全量输出,新增 full-qa 报告
文档:BP 产品介绍资料、design/CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 10:06:15 +08:00

482 lines
24 KiB
JavaScript

// 逐页「功能审计」驱动 —— 区别于 visual-parity(只比像素),这里比「行为」。
//
// 它把每个页面在真实 React 路由里打开,枚举所有可交互元素(按钮 / 标签 tab /
// 切换 toggle / 下拉 chip / 可点卡片 …),逐个真实点击,用 MutationObserver +
// URL + 浮层 + 网络请求 + 自身状态(class/aria/checked)五路探针判断「点完到底有没有
// 反应」。没反应的 = DEAD(功能没接 / 假按钮)。再拿设计稿 /exact/*.html 的控件清单
// 对一遍,报出 React 里「压根没渲染出来」的 MISSING 控件。
//
// 用法(先起前后端 :5173 / :8010):
// cd AirShelf/core/qa/function-audit && npm install
// node audit.mjs # 全量,逐元素 reload 隔离(最准)
// node audit.mjs --mode quick # 不 reload,快但有级联噪声
// node audit.mjs --only pipeline,account # 只跑指定页
// node audit.mjs --include-pointer # 额外把 cursor:pointer 的 div 也当候选(查隐藏死交互)
// node audit.mjs --headed # 看着浏览器跑
//
// 产物:output/<page>.audit.json + output/<page>.audit.md + output/summary.md
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "../../..");
function arg(name, fallback = "") {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : fallback;
}
function boolArg(name) {
return process.argv.includes(`--${name}`);
}
const BASE = arg("base", "http://127.0.0.1:5173").replace(/\/+$/, "");
const EMAIL = arg("email", "e2e-20260529-0806@airshelf.test");
const PASSWORD = arg("password", "demo12345");
let TOKEN = arg("token", "");
const MODE = arg("mode", "isolated"); // isolated | quick
const ONLY = arg("only", "").split(",").map((s) => s.trim()).filter(Boolean);
const INCLUDE_POINTER = boolArg("include-pointer");
const HEADED = boolArg("headed");
const SETTLE = Number.parseInt(arg("settle", "700"), 10); // 点击后观察窗口 ms
const outDir = path.resolve(here, "output");
fs.mkdirSync(outDir, { recursive: true });
// 只跳「单击即不可逆」的真实付费/真生成/真导出 + 弹窗里的最终确认按钮。
// 触发型(删除/移除/下线/充值/退出登录 = 点了只是开二次确认弹窗或跳转)不在此列,照常点验。
// ★ 2026-06-10 教训:漏了「整体重写/AI 全生/重新生成/重跑/确认脚本」,一轮审计对演示项目真跑了
// 6 次 generate-script(真扣费 ¥6)。凡单击即触发真实 AI 生成/落库写的按钮文字必须进这张表。
const DESTRUCTIVE = /(微信支付|支付宝|立即支付|去支付|确认支付|确认充值|立即生成|生成脚本|生成基础资产|生成故事板|生成分镜|提交片段|提交视频|提交生成|提交导出|重新导出|导出\s*MP4|确认删除|确认移除|确认退出|确认下线|整体重写|AI\s*全生|重新生成全部|重跑|开始生成视频|确认脚本|跳过故事板|采用此版本|AI\s*生成(三视图|人物|场景)|保存草稿|删除本场|添加分镜|发送|^生成$)/i;
async function login() {
if (TOKEN) return TOKEN;
const res = await fetch(`${BASE}/api/auth/login/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: EMAIL, password: PASSWORD })
});
if (!res.ok) throw new Error(`登录失败 ${res.status}:${await res.text()}\n请确认后端 :8010 在跑、演示账号正确。`);
const data = await res.json();
return data.token;
}
async function fetchIds() {
const headers = { Authorization: `Token ${TOKEN}` };
const out = { productId: "", projectId: "" };
try {
const p = await (await fetch(`${BASE}/api/products/?page_size=1`, { headers })).json();
out.productId = (p.results || p)[0]?.id || "";
} catch {}
try {
const j = await (await fetch(`${BASE}/api/projects/?page_size=1`, { headers })).json();
out.projectId = (j.results || j)[0]?.id || "";
} catch {}
return out;
}
// ── 注入页面的工具函数(收集 + 探针),作为字符串在 page.evaluate 里复用 ──
const PAGE_HELPERS = `
window.__audit = window.__audit || {};
// 强候选:语义上就该可交互的元素,无条件纳入
window.__audit.SEL_STRONG = [
'button','[role=button]','[role=tab]','[role=switch]','[role=menuitem]','[role=option]','[role=checkbox]',
'a[href]','input[type=checkbox]','input[type=radio]','select','summary','[onclick]'
].join(',');
// 弱候选:靠类名猜的,必须 cursor:pointer 才算(滤掉装饰性状态徽章 .pill「通过」等)
window.__audit.SEL_WEAK = [
'.tab','.pill','.chip','.seg','.stage-pill','.stage-step','.view-tog > *',
'.switch','.toggle','.sidebar-toggle','.nav-item','.shortcut','.qa-item','.mi'
].join(',');
window.__audit.isVisible = function(el){
const r = el.getBoundingClientRect();
if (r.width < 4 || r.height < 4) return false;
const s = getComputedStyle(el);
if (s.display==='none'||s.visibility==='hidden'||Number(s.opacity)===0) return false;
// 移出视口(translateX 隐藏的抽屉/侧拉、关闭的浮层)不算可见 —— 避免点到「隐藏但已渲染」的死控件
const vw = window.innerWidth, vh = window.innerHeight;
if (r.right <= 0 || r.left >= vw || r.bottom <= 0 || r.top >= vh) return false;
return true;
};
// 容器去重:若元素自身不是强交互元素,但内部已含强交互后代(如 .chip-wrap 里有 .chip 按钮),
// 则它是包装容器,不当候选(真控件是里面那个)。
window.__audit.isWrapper = function(el){
if (el.matches(window.__audit.SEL_STRONG)) return false;
return !!el.querySelector(window.__audit.SEL_STRONG);
};
window.__audit.label = function(el){
let t = (el.getAttribute('aria-label') || el.title || el.value || el.textContent || '').replace(/\\s+/g,' ').trim();
if (!t) t = el.getAttribute('data-key') || el.getAttribute('data-value') || el.getAttribute('data-filter') || '';
return t.slice(0,48);
};
// 确定性枚举(文档序),去重,过滤可见;同时给每个打 data-audit-idx 方便点击定位
window.__audit.collect = function(includePointer){
const strong = Array.from(document.querySelectorAll(window.__audit.SEL_STRONG));
// 弱候选:必须 cursor:pointer;且排除纯展示 <span> 徽章(无 role/tabindex/onclick,如状态 pill「reserved」)
const weak = Array.from(document.querySelectorAll(window.__audit.SEL_WEAK))
.filter(el => getComputedStyle(el).cursor === 'pointer')
.filter(el => !(el.tagName === 'SPAN' && !el.getAttribute('role') && !el.hasAttribute('tabindex') && !el.hasAttribute('onclick')));
let nodes = strong.concat(weak);
if (includePointer){
const extra = Array.from(document.querySelectorAll('div,li,span,article,section')).filter(el=>{
if (getComputedStyle(el).cursor!=='pointer') return false;
return !el.querySelector(window.__audit.SEL_STRONG); // 只取叶子可点
});
nodes = nodes.concat(extra);
}
// 文档序排序 + 去重
nodes = Array.from(new Set(nodes)).sort((a,b)=>{
const p = a.compareDocumentPosition(b);
if (p & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (p & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
});
const seen = new Set();
const ordered = [];
for (const el of nodes){
if (seen.has(el)) continue;
seen.add(el);
if (!window.__audit.isVisible(el)) continue;
if (window.__audit.isWrapper(el)) continue; // 跳过包装容器(真控件在其内部)
ordered.push(el);
}
return ordered.map((el,i)=>{
el.setAttribute('data-audit-idx', String(i));
const disabled = el.disabled || el.getAttribute('aria-disabled')==='true' || el.classList.contains('is-disabled') || el.classList.contains('disabled');
const active = el.classList.contains('active') || el.classList.contains('is-active') || el.classList.contains('selected')
|| el.getAttribute('aria-selected')==='true' || el.getAttribute('aria-checked')==='true' || el.checked===true;
return {
idx:i,
tag: el.tagName.toLowerCase(),
role: el.getAttribute('role') || '',
cls: (el.className && typeof el.className==='string') ? el.className.split(/\\s+/).slice(0,3).join('.') : '',
label: window.__audit.label(el),
href: el.getAttribute('href') || '',
disabled: !!disabled,
active: !!active
};
});
};
// 装探针:记录基线 + 启动 MutationObserver,然后点击目标
window.__audit.arm = function(idx){
const el = document.querySelector('[data-audit-idx="'+idx+'"]');
if (!el) return { ok:false, reason:'not-found' };
const overlaySel = '.modal,.drawer,[role=dialog],.toast,.chip-menu,.dropdown,.menu,.popover,[data-open=true]';
const visOverlays = ()=> Array.from(document.querySelectorAll(overlaySel)).filter(window.__audit.isVisible).length;
window.__audit.base = {
url: location.href,
overlays: visOverlays,
selfSig: el.className+'|'+el.getAttribute('aria-expanded')+'|'+el.getAttribute('aria-selected')+'|'+el.getAttribute('aria-checked')+'|'+(el.checked??''),
overlaysN: visOverlays()
};
window.__audit.mut = 0;
// 探测「触发文件选择框」:按钮 onClick 里调用隐藏 input[type=file].click() 会弹原生对话框,
// DOM 无变化 → 否则会被误判 dead。这里 hook 一下,捕获到即算「有反应」。
window.__audit.filePick = 0;
if (!window.__audit._origInputClick) {
window.__audit._origInputClick = HTMLInputElement.prototype.click;
HTMLInputElement.prototype.click = function () {
if (this.type === 'file') window.__audit.filePick = (window.__audit.filePick || 0) + 1;
return window.__audit._origInputClick.apply(this, arguments);
};
}
window.__audit.obs && window.__audit.obs.disconnect();
window.__audit.obs = new MutationObserver(muts=>{ window.__audit.mut += muts.length; });
window.__audit.obs.observe(document.body, { subtree:true, childList:true, attributes:true, characterData:true });
window.__audit.el = el;
try { el.click(); } catch(e){ return { ok:false, reason:'click-threw:'+e.message }; }
return { ok:true };
};
// 读差量
window.__audit.read = function(){
const b = window.__audit.base; const el = window.__audit.el;
window.__audit.obs && window.__audit.obs.disconnect();
const overlaySel = '.modal,.drawer,[role=dialog],.toast,.chip-menu,.dropdown,.menu,.popover,[data-open=true]';
const overlaysNow = Array.from(document.querySelectorAll(overlaySel)).filter(window.__audit.isVisible).length;
const selfSigNow = el ? (el.className+'|'+el.getAttribute('aria-expanded')+'|'+el.getAttribute('aria-selected')+'|'+el.getAttribute('aria-checked')+'|'+(el.checked??'')) : '';
return {
urlChanged: location.href !== b.url,
overlayChanged: overlaysNow !== b.overlaysN,
selfChanged: el ? (selfSigNow !== b.selfSig) : false,
mutations: window.__audit.mut,
filePick: window.__audit.filePick || 0,
isSelect: el ? el.tagName === 'SELECT' : false
};
};
// 设计稿/任意页的控件文字清单(用于 MISSING 对比)
window.__audit.labels = function(){
const strong = Array.from(document.querySelectorAll(window.__audit.SEL_STRONG));
const weak = Array.from(document.querySelectorAll(window.__audit.SEL_WEAK)).filter(el => getComputedStyle(el).cursor === 'pointer');
return strong.concat(weak)
.filter(window.__audit.isVisible)
.filter(el => !window.__audit.isWrapper(el))
.map(window.__audit.label)
.filter(Boolean);
};
`;
// 就绪门:等到 app 外壳(aside.sidebar)出现。若落到登录页(.auth-wrap)= boot 时
// api.me() 挂了(远程库抖动 / 单线程 Django 被高频 reload 打爆),返回 not-ready。
async function waitForReady(page, timeout = 8000) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const state = await page.evaluate(() => {
if (document.querySelector(".auth-wrap")) return "auth";
if (document.querySelector("aside.sidebar")) return "ready";
return "pending";
}).catch(() => "pending");
if (state === "ready") return true;
if (state === "auth") return false;
await page.waitForTimeout(200);
}
return false;
}
// 进目标路由并确保 app 真的加载出来;落登录页就重注 token 重试(治瞬时 boot 失败)。
async function gotoWithToken(page, url, tries = 3) {
for (let attempt = 1; attempt <= tries; attempt++) {
await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded" }).catch(() => {});
await page.evaluate((t) => localStorage.setItem("airshelf_token", t), TOKEN).catch(() => {});
await page.goto(url, { waitUntil: "networkidle" }).catch(() => {});
if (await waitForReady(page)) {
await page.addStyleTag({ content: `*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important}` }).catch(() => {});
return true;
}
await page.waitForTimeout(600 * attempt); // 退避后重试,给后端喘息
}
return false; // 仍未就绪 = 页面 blocked(后端/数据不可用),由调用方标记
}
const norm = (s) => s.replace(/\s+/g, "").replace(/[0-9]+/g, "").toLowerCase();
async function collectDesignLabels(context, exactName) {
const page = await context.newPage();
try {
// 设计稿镜像:宽松导航即可(shell.js 注入侧栏),不走严格就绪门 / 重试
await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded" }).catch(() => {});
await page.evaluate((t) => localStorage.setItem("airshelf_token", t), TOKEN).catch(() => {});
await page.goto(`${BASE}/exact/${exactName}`, { waitUntil: "networkidle" }).catch(() => {});
await page.waitForTimeout(1200);
await page.evaluate(PAGE_HELPERS);
return await page.evaluate(() => window.__audit.labels());
} catch {
return [];
} finally {
await page.close();
}
}
async function auditPage(context, item, ids) {
const route = item.route
.replace(":productId", ids.productId || "x")
.replace(":projectId", ids.projectId || "x");
const url = `${BASE}${route}`;
const page = await context.newPage();
// 探针:网络命中(node 侧带时间戳)/ 真·JS 异常(pageerror → error 判定)/ console.error(仅备注)
const apiHits = [];
const pageErrors = []; // 真正抛出的 JS 异常 —— 才算 error
const consoleErrs = []; // console.error(含 React 警告 / 资源 404)—— 只做备注,不改判定
page.on("request", (r) => { if (r.url().includes("/api/")) apiHits.push({ t: Date.now(), u: r.url() }); });
page.on("console", (m) => { if (m.type() === "error") consoleErrs.push({ t: Date.now(), m: m.text().slice(0, 200) }); });
page.on("pageerror", (e) => pageErrors.push({ t: Date.now(), m: String(e).slice(0, 200) }));
const ensureFresh = async () => {
const ready = await gotoWithToken(page, url);
if (!ready) return null;
await page.evaluate(PAGE_HELPERS);
return page.evaluate((ip) => window.__audit.collect(ip), INCLUDE_POINTER);
};
const recollect = async () => {
await page.evaluate(PAGE_HELPERS).catch(() => {});
return page.evaluate((ip) => window.__audit.collect(ip), INCLUDE_POINTER).catch(() => null);
};
let inventory = await ensureFresh();
if (!inventory) {
// 页面始终落登录页/未就绪 —— 后端/数据不可用,标 blocked,绝不谎报 0 dead
await page.close();
return { name: item.name, route, url, mode: MODE, blocked: true,
tally: { total: 0, works: 0, dead: 0, error: 0, disabled: 0, skipped: 0, noop: 0, blocked: 1 }, results: [], missing: [] };
}
const total = inventory.length;
const results = [];
for (let i = 0; i < total; i++) {
// 复位取基线:isolated 每个元素都 reload(确定性枚举,索引稳,不漏控件);
// quick 不 reload,仅在漂出本路由时才 reload(快但有级联漂移)。
if (MODE === "isolated") {
const fresh = await ensureFresh();
if (!fresh) { results.push({ idx: i, label: inventory[i]?.label || "", tag: "", verdict: "blocked" }); continue; }
inventory = fresh;
} else {
const cur = await page.evaluate(() => location.href).catch(() => "");
if (!(cur.startsWith(url) || cur.includes(route))) {
const fresh = await ensureFresh();
if (!fresh) { results.push({ idx: i, label: "", tag: "", verdict: "blocked" }); continue; }
inventory = fresh;
} else {
const re = await recollect();
if (re) inventory = re;
}
}
const meta = inventory[i];
if (!meta) { results.push({ idx: i, label: "", tag: "", verdict: "stale" }); continue; }
if (meta.disabled) { results.push({ ...meta, verdict: "disabled" }); continue; }
if (DESTRUCTIVE.test(meta.label)) { results.push({ ...meta, verdict: "skipped-destructive" }); continue; }
const tBefore = Date.now();
const armed = await page.evaluate((idx) => window.__audit.arm(idx), meta.idx);
if (!armed.ok) { results.push({ ...meta, verdict: "stale", detail: armed.reason }); continue; }
await page.waitForTimeout(SETTLE);
let delta;
try {
delta = await page.evaluate(() => window.__audit.read());
} catch {
delta = { urlChanged: true, overlayChanged: false, selfChanged: false, mutations: 0 }; // 上下文销毁=跳转=有反应
}
const net = apiHits.filter((h) => h.t >= tBefore).length;
const thrown = pageErrors.filter((e) => e.t >= tBefore).map((e) => e.m);
const cerr = consoleErrs.filter((e) => e.t >= tBefore).map((e) => e.m);
// 文件选择框(原生对话框)/ 原生 select(原生下拉)点击 DOM 无变化但确属可用控件,算 works
const reacted = delta.urlChanged || delta.overlayChanged || delta.selfChanged || net > 0 || delta.mutations >= 1 || delta.filePick > 0 || delta.isSelect;
// 点「当前已选中」的 tab/chip 本就该无反应 —— 记 noop-active,不算缺陷
let verdict = thrown.length ? "error" : reacted ? "works" : (meta.active ? "noop-active" : "dead");
results.push({
...meta, verdict,
signals: { url: delta.urlChanged, overlay: delta.overlayChanged, self: delta.selfChanged, mutations: delta.mutations, api: net },
...(thrown.length ? { errors: thrown } : {}),
...(cerr.length ? { consoleNote: cerr.slice(0, 2) } : {})
});
if (MODE === "quick") await page.keyboard.press("Escape").catch(() => {});
}
// MISSING:设计稿有、React 没渲染出来的控件(按文字模糊匹配,启发式)
const designLabels = await collectDesignLabels(context, item.exact);
const implSet = new Set(inventory.map((m) => norm(m.label)).filter(Boolean));
const missing = [];
const seenD = new Set();
for (const dl of designLabels) {
const n = norm(dl);
if (!n || n.length < 2 || seenD.has(n)) continue;
seenD.add(n);
if (!implSet.has(n)) missing.push(dl.slice(0, 48));
}
await page.close();
const tally = { total: results.length, works: 0, dead: 0, error: 0, disabled: 0, skipped: 0, noop: 0, blocked: 0 };
for (const r of results) {
if (r.verdict === "works") tally.works++;
else if (r.verdict === "dead") tally.dead++;
else if (r.verdict === "error") tally.error++;
else if (r.verdict === "disabled") tally.disabled++;
else if (r.verdict === "noop-active") tally.noop++;
else if (r.verdict === "blocked") tally.blocked++;
else tally.skipped++;
}
return { name: item.name, route, url, mode: MODE, tally, results, missing };
}
function writePageReport(rep) {
fs.writeFileSync(path.join(outDir, `${rep.name}.audit.json`), JSON.stringify(rep, null, 2) + "\n");
const lines = [];
lines.push(`# 功能审计 · ${rep.name}`);
lines.push("");
if (rep.blocked) {
lines.push(`> ⛔ **BLOCKED**:路由 \`${rep.route}\` 始终落到登录页/未就绪,后端或数据不可用,本页未审计。`);
lines.push("> 先确认后端 :8010 健康(\`.venv/bin/python\`)+ 远程库可达,再单独 \`--only " + rep.name + "\` 复跑。");
fs.writeFileSync(path.join(outDir, `${rep.name}.audit.md`), lines.join("\n") + "\n");
return;
}
lines.push(`路由:\`${rep.route}\` · 模式:${rep.mode}`);
lines.push(`合计 ${rep.tally.total} · ✅ works ${rep.tally.works} · ❌ **dead ${rep.tally.dead}** · 🛑 error ${rep.tally.error} · ⏭ skipped(破坏性)${rep.tally.skipped} · ◷ noop-active ${rep.tally.noop} · ⚪ disabled ${rep.tally.disabled}`);
lines.push("");
const dead = rep.results.filter((r) => r.verdict === "dead");
if (dead.length) {
lines.push("## ❌ 点了没反应(DEAD —— 重点修)");
lines.push("| # | 文字 | 标签 | 类名 |");
lines.push("|---|---|---|---|");
for (const r of dead) lines.push(`| ${r.idx} | ${r.label || "(空)"} | \`${r.tag}${r.role ? "/" + r.role : ""}\` | \`${r.cls}\` |`);
lines.push("");
}
const errs = rep.results.filter((r) => r.verdict === "error");
if (errs.length) {
lines.push("## 🛑 点击报错(ERROR)");
for (const r of errs) lines.push(`- **${r.label}** \`${r.tag}\` — ${(r.errors || [r.detail]).join("; ")}`);
lines.push("");
}
if (rep.missing.length) {
lines.push("## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)");
lines.push("> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。");
lines.push("");
for (const m of rep.missing) lines.push(`- ${m}`);
lines.push("");
}
const skip = rep.results.filter((r) => r.verdict === "skipped-destructive");
if (skip.length) {
lines.push("## ⏭ 破坏性按钮(未自动点,需人工验)");
for (const r of skip) lines.push(`- ${r.label} \`${r.tag}\``);
lines.push("");
}
fs.writeFileSync(path.join(outDir, `${rep.name}.audit.md`), lines.join("\n") + "\n");
}
(async () => {
TOKEN = await login();
const ids = await fetchIds();
let manifest = JSON.parse(fs.readFileSync(path.join(here, "pages.json"), "utf8"));
if (ONLY.length) manifest = manifest.filter((m) => ONLY.some((o) => m.name.includes(o)));
const browser = await chromium.launch({ headless: !HEADED });
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: "light" });
const summary = [];
for (const item of manifest) {
process.stdout.write(`\n[function-audit] ${item.name} … `);
try {
const rep = await auditPage(context, item, ids);
writePageReport(rep);
summary.push({ page: rep.name, blocked: !!rep.blocked, ...rep.tally, missing: rep.missing.length });
process.stdout.write(rep.blocked ? "⛔ BLOCKED(后端/数据不可用)" : `dead ${rep.tally.dead} / ${rep.tally.total}, missing ${rep.missing.length}`);
} catch (e) {
process.stdout.write(`FAILED ${e.message}`);
summary.push({ page: item.name, total: 0, works: 0, dead: 0, error: 0, disabled: 0, skipped: 0, missing: 0, failed: e.message });
}
}
await browser.close();
// 汇总
const sl = ["# 功能审计汇总", "", `生成时间:${new Date().toISOString()} · 模式:${MODE}`, ""];
sl.push("| 页面 | 合计 | ✅works | ❌dead | 🛑error | ⏭skip | 🔍missing | 状态 |");
sl.push("|---|---|---|---|---|---|---|---|");
for (const s of summary) {
const st = s.blocked ? "⛔BLOCKED" : (s.failed ? "⚠️FAILED" : "");
sl.push(`| ${s.page} | ${s.total} | ${s.works} | **${s.dead}** | ${s.error} | ${s.skipped} | ${s.missing} | ${st} |`);
}
sl.push("");
sl.push("- ⛔blocked = 页面始终落登录页/未就绪(后端或远程库不可用),**未审计**,非「0 缺陷」。修好后端再单独 `--only <page>` 复跑。");
sl.push("- ❌dead = 点了五路探针(URL/浮层/自身状态/网络/DOM)全无反应,优先修。");
sl.push("- 🔍missing = 设计稿 `/exact` 有、React 没渲染出来的控件(启发式,需人工过)。");
sl.push("- ⏭skip = 破坏性按钮(删除/充值/生成…)未自动点,需人工验。");
sl.push("- 单页明细见 `output/<page>.audit.md`。");
fs.writeFileSync(path.join(outDir, "summary.md"), sl.join("\n") + "\n");
fs.writeFileSync(path.join(outDir, "summary.json"), JSON.stringify(summary, null, 2) + "\n");
console.log("\n\n[function-audit] summary");
console.table(summary.map((s) => ({ page: s.page, total: s.total, works: s.works, dead: s.dead, error: s.error, missing: s.missing })));
console.log(`\n报告:${path.relative(repoRoot, path.join(outDir, "summary.md"))}`);
})();