zyc 890cb9ab67
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m2s
chore(core/qa): function-audit toolchain + parity/audit reports + pixel-perfect skill
- qa/function-audit: playwright 行为审计工具(audit.mjs/verify-modals.mjs/pages.json)
  + 18 页审计产出(*.audit.md/json、summary、运行日志)
- qa/visual-parity: 调试/测量辅助脚本(_dbg*.mjs/_measure.mjs/_off.mjs)
- core/还原度核对报告.md: 18 页 pixelmatch 核对结果(含 vite 代理陈旧坑记录)
- core/还原与接口待办.md: 逐页还原度/真实数据/交互接入待办总表
- .claude/skills/pixel-perfect-react: 像素级还原 React 的 SKILL 文档
- frontend/public/_devlogin.html: 临时本地登录辅助页(可删)

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

480 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 逐页「功能审计」驱动 —— 区别于 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 });
// 只跳「单击即不可逆」的真实付费/真生成/真导出 + 弹窗里的最终确认按钮。
// 触发型(删除/移除/下线/充值/退出登录 = 点了只是开二次确认弹窗或跳转)不在此列,照常点验。
const DESTRUCTIVE = /(微信支付|支付宝|立即支付|去支付|确认支付|确认充值|立即生成|生成脚本|生成基础资产|生成故事板|生成分镜|提交片段|提交视频|提交生成|提交导出|重新导出|导出\s*MP4|确认删除|确认移除|确认退出|确认下线)/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"))}`);
})();