| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420 |
- #!/usr/bin/env node
- /**
- * scripts/verify-visual-v12.mjs —— v12 深度视觉重构验收(任务书「二、硬性数值规范」逐项实测)。
- *
- * 两种用法:
- * node scripts/verify-visual-v12.mjs --tag before 只打印现状,**不判分**(改前基线)
- * node scripts/verify-visual-v12.mjs 判分:任一项不达标 exit 1
- * --page /pool 只量一页(调试用)
- * --json out.json 结果落盘(before-after 文档从这里抄数字,不手填)
- *
- * 量什么、怎么量(每项都打印分子/分母,不打印「通过」两个字):
- * 1. 圆角种类 所有可见元素(跨 shadow)的 computed border-radius,去重后按半径归类;只允许 0 / 8 / 12 / 20 / 9999(胶囊)
- * 2. 原生控件 button/input/select/textarea/summary:外观被显式覆盖(非 appearance:auto / 非浏览器默认灰底 / 有字族与圆角)
- * 3. 描边 可见元素的 border-width / outline-width 中 > 1px 的数量
- * 4. 纯黑 computed color === rgb(0, 0, 0) 且带可见文字的元素数
- * 5. 字号档 可见文字的 font-size 去重(容差 0.1px)
- * 6. 同排卡高 同一个 grid 容器里的直接子项,按 top 分行(±4px),行内 height 极差
- * 7. 卡内空白 卡片内部相邻两行内容之间的竖直间隙,> 24px 记一次
- * 8. hover 每个可点元素 pointerover 前后,背景色 / 边框色 / 背景图 / 颜色 / 阴影任一变化
- * 9. 底部空白 document 可滚高度 - 最后一块主内容底边
- * 10. 框内布局 筛选容器是一个整体、排序是分段控件、搜索框与筛选区底边差(P1)
- * 11. 左右等高 工作台两栏首行卡片高度差(P3)
- *
- * 注意(验收脚本的老坑,见 memory kf-verify-browser-quirks):
- * - 视图异步挂载:goto 后等 kf-app → view-* 的 shadowRoot 有子节点,再等 300ms;
- * - 跨 shadow 的可见性要沿 parentElement → getRootNode().host 一路走;
- * - hover 必须用真实指针(page.mouse.move):合成 pointerover / mouseover 事件不会让 :hover 命中。
- */
- import { mkdirSync, writeFileSync } from 'node:fs';
- import { join, dirname } from 'node:path';
- import { fileURLToPath } from 'node:url';
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
- const argv = process.argv.slice(2);
- const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
- const TAG = arg('--tag', 'after');
- const JUDGE = TAG !== 'before';
- const ONLY = arg('--page', '');
- const JSON_OUT = arg('--json', '');
- if (!process.env.CHROME_PATH) {
- const pinned = '/opt/hermes/.playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell';
- const { existsSync } = await import('node:fs');
- if (existsSync(pinned)) process.env.CHROME_PATH = pinned;
- }
- const { startServer, launch, DEEP } = await import('./v11b-lib.mjs');
- const srv = await startServer(Number(process.env.VERIFY_PORT || 4193));
- const browser = await launch();
- const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
- const page = await ctx.newPage();
- const errors = [];
- page.on('pageerror', (e) => errors.push(e.message));
- page.on('console', (m) => { if (m.type() === 'error' && !/favicon/.test(m.text())) errors.push(m.text()); });
- const VIEW_OF = { '/': 'view-workbench', '/pool': 'view-pool', '/stages': 'view-stages', '/reports': 'view-reports' };
- const PAGES = ONLY ? [ONLY] : ['/', '/pool', '/stages', '/reports'];
- const gotoView = async (p) => {
- await page.goto(new URL(p, srv.base).href, { waitUntil: 'networkidle' });
- await page.waitForFunction((name) => {
- const a = document.querySelector('kf-app');
- const v = a?.shadowRoot?.querySelector(name);
- return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0;
- }, VIEW_OF[p], { timeout: 15000 });
- await page.waitForTimeout(700);
- };
- /** 页面里执行:所有「测量」都放在这一段里,避免每次 evaluate 的字符串拼装差异 */
- const MEASURE = `(async () => {
- ${DEEP}
- const root = () => [document, ...[...document.querySelectorAll('*')].map((e) => e.shadowRoot).filter(Boolean)];
- const all = [];
- const collect = (r) => { for (const el of r.querySelectorAll('*')) { all.push(el); if (el.shadowRoot) collect(el.shadowRoot); } };
- collect(document);
- /* 可见:跨 shadow 链上无 display:none / visibility:hidden / opacity<.05;自身盒 ≥ 8×4 或带可见文字 */
- const vis = (el) => {
- const r = el.getBoundingClientRect();
- if (r.width < 8 || r.height < 4) return false;
- if (!shownByStyle(el)) return false;
- return true;
- };
- const hasText = (el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
- const PAGES = ${JSON.stringify(PAGES)};
- const page = location.pathname.replace(/\\/+$/, '') || '/';
- const out = { page, radii: {}, controls: [], thickBorders: [], blackText: [], fontSizes: {}, rows: [], blanks: [], clickables: 0, hoverFail: [], scrollH: 0, lastContentBottom: 0, filterLayout: null, wbCols: null };
- /* ---- 1. 圆角 ---- */
- const radKinds = {};
- const radWhere = {};
- for (const el of all) {
- if (!vis(el)) continue;
- const s = getComputedStyle(el);
- const parts = [s.borderTopLeftRadius, s.borderTopRightRadius, s.borderBottomLeftRadius, s.borderBottomRightRadius]
- .map((v) => v.split(' ')[0]);
- /* 量条 / 复选框 / 色点这类 ≤ 6px 的小件:形状由尺寸决定(4px 高的进度条圆角 2px = 胶囊),
- 不作为「圆角体系」计数;8px 以上才算容器圆角,必须落在 8 / 12 / 20。 */
- const box = el.getBoundingClientRect();
- const tiny = box.width <= 24 || box.height <= 6;
- if (tiny) continue;
- for (const p of parts) {
- const n = Math.round(parseFloat(p) || 0);
- const pct = p.includes('%');
- /* 百分比 = 圆形(头像 / 环),不算圆角档;9999px 以上记作胶囊 */
- if (pct) { radKinds.circle = (radKinds.circle || 0) + 1; continue; }
- if (n > 9000 || p.includes('calc') ) { /* 未解析 */ }
- const k = n;
- radKinds[k] = (radKinds[k] || 0) + 1;
- if (!radWhere[k]) radWhere[k] = elLabel(el).slice(0, 48);
- }
- }
- out.radii = radKinds; out.radiiWhere = radWhere;
- /* ---- 2. 原生控件 ---- */
- const ctrlSel = 'button, input, select, textarea';
- for (const el of all) {
- if (!el.matches?.(ctrlSel)) continue;
- if (!vis(el)) continue;
- const s = getComputedStyle(el);
- const tag = elLabel(el);
- const bg = s.backgroundColor;
- const bgImg = s.backgroundImage;
- const reasons = [];
- /* 文本框(input)在 Chrome 里 appearance 恒为 auto,但给了 rgba 半透明底色就是已覆盖默认外观;
- select / textarea 必须显式 appearance:none(select 默认画下拉箭头与系统边框)。 */
- const carriesOwnLook = bg !== 'rgba(0, 0, 0, 0)' || bgImg !== 'none' || (parseFloat(s.borderTopWidth) || 0) > 0;
- const needsAppearance = el.localName === 'select' || el.localName === 'textarea' || (el.localName === 'input' && (s.appearance !== 'none' && !carriesOwnLook));
- if (needsAppearance && el.localName !== 'button' && s.appearance !== 'none' && s.webkitAppearance !== 'none') reasons.push('appearance:' + s.appearance);
- /* 浏览器默认按钮外观:灰底 rgb(239,239,239|221,221,221|192,192,192) + 2px outset */
- const m = bg.match(/rgba?\\((\\d+), (\\d+), (\\d+)/);
- if (m && Math.abs(+m[1] - +m[2]) < 3 && Math.abs(+m[2] - +m[3]) < 3 && +m[1] >= 180 && +m[1] <= 245 && bgImg === 'none') reasons.push('灰底' + bg);
- const bw = parseFloat(s.borderTopWidth) || 0;
- if (el.localName === 'button' && bw >= 2 && /outset|inset|groove|ridge/.test(s.borderTopStyle)) reasons.push('浏览器浮雕边 ' + s.borderTopStyle + bw + 'px');
- if (/^(Times|serif)/.test(s.fontFamily) || s.fontFamily === 'none') reasons.push('字族泄漏:' + s.fontFamily.slice(0, 24));
- if ((el.localName === 'button' || el.localName === 'input') && s.borderRadius === '0px' && bw > 0) reasons.push('无圆角有边框');
- const r = el.getBoundingClientRect();
- out.controls.push({ tag, w: Math.round(r.width), h: Math.round(r.height), bg, bw, r: s.borderRadius, font: s.fontSize, reasons, text: (el.textContent || el.placeholder || el.getAttribute('aria-label') || '').trim().slice(0, 16) });
- }
- /* ---- 3. 描边 > 1px ---- */
- for (const el of all) {
- if (!vis(el)) continue;
- const s = getComputedStyle(el);
- const widths = [s.borderTopWidth, s.borderRightWidth, s.borderBottomWidth, s.borderLeftWidth]
- .map((v) => parseFloat(v) || 0).filter((w) => w > 1 && s.borderTopStyle !== 'none');
- const ow = parseFloat(s.outlineWidth) || 0;
- if (widths.length || (s.outlineStyle !== 'none' && ow > 1)) out.thickBorders.push({ tag: elLabel(el).slice(0, 50), w: Math.max(...widths, 0), ow, o: s.outlineOffset });
- }
- /* ---- 4. 纯黑文字 ---- */
- for (const el of all) {
- if (!vis(el) || !hasText(el)) continue;
- /* 只量「直接承载文字」的元素,且字形真的落在里面(子元素另有颜色的不算) */
- const s = getComputedStyle(el);
- if (s.color === 'rgb(0, 0, 0)') out.blackText.push({ tag: elLabel(el).slice(0, 50), text: txt(el).trim().slice(0, 20) });
- }
- /* ---- 5. 字号档 ---- */
- for (const el of all) {
- if (!vis(el) || !hasText(el)) continue;
- /* 只看兄弟文本节点真的有字形的元素 */
- const fs = Math.round(parseFloat(getComputedStyle(el).fontSize) * 100) / 100;
- out.fontSizes[fs] = (out.fontSizes[fs] || 0) + 1;
- }
- /* ---- 6. 同排卡高:grid / flex 容器里直接子项按 top 分行 ---- */
- const rowSeen = new Set();
- for (const el of all) {
- const s = getComputedStyle(el);
- if (!/grid|flex/.test(s.display)) continue;
- const kids = [...el.children].filter((k) => vis(k) && k.getBoundingClientRect().height > 40);
- if (kids.length < 2) continue;
- const tops = new Map();
- for (const k of kids) {
- const r = k.getBoundingClientRect();
- const key = Math.round(r.top / 4) * 4;
- if (!tops.has(key)) tops.set(key, []);
- tops.get(key).push(r.height);
- }
- for (const [top, hs] of tops) {
- if (hs.length < 2) continue;
- const spread = Math.round((Math.max(...hs) - Math.min(...hs)) * 10) / 10;
- const id = elLabel(el) + '@' + top;
- if (rowSeen.has(id)) continue; rowSeen.add(id);
- out.rows.push({ container: elLabel(el).slice(0, 40), top, n: hs.length, spread, heights: hs.map((h) => Math.round(h)) });
- }
- }
- /* ---- 7. 卡内连续空白:卡片内部「有内容的直接子块」之间的竖直间隙 ----
- 卡片 = .kf-card / .frame / kf-glass-card 里的 .wrap;取每张卡内容列的直接子项(渲染树里有文字或有尺寸的),
- 相邻子项底 → 下一项顶,> 24px 记一次(padding 之外)。 */
- const cardRoots = [];
- for (const el of all) {
- if (!el.matches) continue;
- if (el.matches('kf-glass-card')) cardRoots.push(el);
- }
- for (const card of cardRoots) {
- const face = card.shadowRoot?.querySelector('.face');
- if (!face || !vis(card)) continue;
- /* 卡里的内容列:slot 的第一个元素里的 .wrap 或直接子块 */
- const slot = face.querySelector('slot');
- const inst = slot ? slot.assignedElements()[0] : null;
- if (!inst) continue;
- const col = inst.querySelector(':scope > .wrap') || inst.querySelector(':scope > .rc') || inst;
- const kids = [...col.children].filter((k) => {
- const r = k.getBoundingClientRect();
- return r.height > 0 && getComputedStyle(k).position !== 'absolute';
- });
- for (let i = 1; i < kids.length; i++) {
- const a = kids[i - 1].getBoundingClientRect(), b = kids[i].getBoundingClientRect();
- const gap = Math.round(b.top - a.bottom);
- /* 兄弟间 gap(flex gap / margin)为 0 时不算空白:内容是紧挨着的 */
- const cs = getComputedStyle(col);
- const declared = parseFloat(cs.rowGap) || 0;
- const extra = Math.round(gap - declared);
- if (extra > 24) out.blanks.push({ card: elLabel(card).slice(0, 30), between: elLabel(kids[i - 1]).slice(0, 30) + ' → ' + elLabel(kids[i]).slice(0, 30), extra, gap });
- }
- }
- /* ---- 8. hover:穷举可点元素 ---- */
- const clickables = [];
- const seen = new Set();
- for (const el of all) {
- if (!el.matches?.('button, a[href], summary, [role="button"], [role="tab"], [role="menuitemcheckbox"], [role="menuitemradio"], [data-link]')) continue;
- if (!vis(el)) continue;
- const r = el.getBoundingClientRect();
- /* 只要中心点真的是它自己(不被遮挡),才 hover */
- const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
- if (cx < 0 || cy < 0 || cx > innerWidth || cy > innerHeight) continue;
- const key = elLabel(el) + '@' + Math.round(cx) + ',' + Math.round(cy);
- if (seen.has(key)) continue; seen.add(key);
- clickables.push({ el, cx, cy, sig: [getComputedStyle(el).backgroundColor, getComputedStyle(el).borderTopColor, getComputedStyle(el).backgroundImage, getComputedStyle(el).color, getComputedStyle(el).boxShadow, getComputedStyle(el).transform] });
- }
- out.clickables = clickables.length;
- /* 悬停必须用「真实指针」:合成 pointerover 事件不会让 :hover 命中(v11 实测 :hover 规则对合成事件无效),
- 所以逐个别移动鼠标,并在移动后把命中的元素收集回来由外圈逐一核对。 */
- /* clickables 本身不进返回值(元素引用不可序列化):坐标 + 签名 + 标签即可 */
- out.pendingHover = clickables.map((c) => ({ x: c.cx, y: c.cy, sig: c.sig.join('|'), tag: elLabel(c.el).slice(0, 40), text: (c.el.textContent || '').trim().slice(0, 12) }));
- /* ---- 9. 底部空白 ---- */
- out.scrollH = Math.round(document.documentElement.scrollHeight);
- const mainEl = main();
- let bottom = 0;
- const scan = (root) => {
- for (const el of root.querySelectorAll('*')) {
- if (!vis(el)) continue;
- if (!hasText(el) && !el.matches('img, svg, kf-glass-card, kf-opportunity-card, kf-empty, canvas')) continue;
- /* 忽略整块容器的盒,只看「叶子」内容与其卡片 */
- const r = el.getBoundingClientRect();
- bottom = Math.max(bottom, r.bottom + scrollY);
- if (el.shadowRoot) scan(el.shadowRoot);
- }
- };
- scan(mainEl);
- out.lastContentBottom = Math.round(bottom);
- /* ---- 10. 筛选区(P1)与 11. 工作台两栏(P3) ---- */
- const q = (sel) => appR().querySelector('main#view')?.firstElementChild?.shadowRoot?.querySelector(sel) || null;
- if (page === '/pool') {
- const filters = q('.filters');
- const tb = q('.filters[data-toolbar]');
- const seg = filters ? filters.querySelector('.seg') : null;
- const search = filters ? filters.querySelector('.frow.toolbar input[type="search"]') : null;
- const chips = [...(q('.filters')?.querySelectorAll('.kf-chip') || [])];
- const chipSizes = chips.map((c) => { const r = c.getBoundingClientRect(); return [Math.round(r.height), getComputedStyle(c).fontSize]; });
- const segBtns = seg ? [...seg.querySelectorAll('button')] : [];
- const segGap = seg ? getComputedStyle(seg).gap : '';
- const segBorders = seg ? segBtns.map((b) => { const s = getComputedStyle(b); return s.borderRightWidth + ' ' + (s.borderRadius || ''); }) : [];
- out.filterLayout = {
- container: !!tb, filtersInsideToolbar: tb ? tb.contains(filters) : false,
- seg: !!seg, segGap, segBtnHeights: segBtns.map((b) => Math.round(b.getBoundingClientRect().height)),
- chipHeights: chipSizes.map((x) => x[0]), chipFonts: [...new Set(chipSizes.map((x) => x[1]))],
- searchH: search ? Math.round(search.getBoundingClientRect().height) : null,
- bottomDelta: search && filters ? Math.round(search.getBoundingClientRect().bottom - filters.getBoundingClientRect().bottom) : null,
- /* 底边对齐:搜索框底边 vs 它所在那一行(.frow.toolbar)里排序段与 chip 的底边 —— 三者差 ≤ 2px 即「同一底边」 */
- rowBottomDelta: (() => {
- if (!search) return null;
- const row = search.closest('.frow');
- const segEl = row?.querySelector('.seg');
- const sb = search.getBoundingClientRect().bottom;
- const others = [segEl, ...(row ? row.querySelectorAll('.kf-chip') : [])].filter(Boolean).map((e) => e.getBoundingClientRect().bottom);
- return others.length ? Math.round(Math.max(...others.map((o) => Math.abs(o - sb))) * 10) / 10 : null;
- })(),
- segBorder: segBorders,
- };
- }
- if (page === '/') {
- const g = q('.grid2');
- if (g) {
- const ks = [...g.children].map((k) => Math.round(k.getBoundingClientRect().height));
- out.wbCols = { heights: ks, diff: ks.length === 2 ? Math.abs(ks[0] - ks[1]) : null };
- /* 左卡(或「还没选」空态卡)内的连续空白 */
- const first = g.children[0];
- const card = first.shadowRoot?.querySelector('.frame');
- if (card) {
- let maxGap = 0;
- const face = card.querySelector('.face');
- const inst = face?.querySelector('slot')?.assignedElements()[0];
- if (inst) {
- const leaves = [...inst.querySelectorAll('*')].filter((e) => vis(e) && hasText(e));
- const rs = leaves.map((e) => e.getBoundingClientRect()).sort((a, b) => a.top - b.top);
- for (let i = 1; i < rs.length; i++) maxGap = Math.max(maxGap, Math.round(rs[i].top - rs[i - 1].bottom));
- }
- out.wbCols.leftMaxGap = maxGap;
- }
- }
- }
- return out;
- })()`;
- const results = [];
- for (const p of PAGES) {
- await gotoView(p);
- const r = await page.evaluate(MEASURE);
- /* 悬停:逐个用真实鼠标移到元素中心;鼠标离开后等过渡回落 */
- await page.mouse.move(2, 2);
- await page.waitForTimeout(60);
- for (const c of r.pendingHover || []) {
- await page.mouse.move(c.x, c.y, { steps: 1 });
- await page.waitForTimeout(240); /* 等过渡(最长 240ms)走完 */
- const after = await page.evaluate(([x, y]) => {
- const el = document.elementFromPoint(x, y);
- if (!el) return null;
- const s = getComputedStyle(el);
- return [s.backgroundColor, s.borderTopColor, s.backgroundImage, s.color, s.boxShadow, s.transform, s.cursor].join('|');
- }, [c.x, c.y]);
- if (after === c.sig) r.hoverFail.push({ tag: c.tag, text: c.text });
- await page.mouse.move(2, 2);
- await page.waitForTimeout(40);
- }
- delete r.pendingHover;
- results.push(r);
- }
- /* ============ 汇总 ============ */
- const ALLOWED_RADII = new Set([0, 8, 12, 20, 9999]);
- const sum = (k) => results.reduce((n, r) => n + (r.radii[k] || 0), 0);
- const radiusKinds = {};
- for (const r of results) for (const [k, n] of Object.entries(r.radii)) radiusKinds[k] = (radiusKinds[k] || 0) + n;
- const badRadii = Object.entries(radiusKinds).filter(([k]) => !ALLOWED_RADII.has(Number(k)) && k !== 'circle');
- const controls = results.flatMap((r) => r.controls.map((c) => ({ ...c, page: r.page })));
- const nativeControls = controls.filter((c) => c.reasons.length);
- const thick = results.flatMap((r) => r.thickBorders.map((x) => ({ ...x, page: r.page })));
- /* 焦点环(outline 2px)只在聚焦时出现,还没聚焦时 computed outline 是 none / 宽度 0 */
- const thickReal = thick.filter((x) => x.w > 1);
- const bright = thick.filter((x) => x.ow > 1);
- const blacks = results.flatMap((r) => r.blackText.map((x) => ({ ...x, page: r.page })));
- const fonts = {};
- for (const r of results) for (const [k, n] of Object.entries(r.fontSizes)) fonts[k] = (fonts[k] || 0) + n;
- const fontKinds = Object.keys(fonts).map(Number).sort((a, b) => a - b);
- const rows = results.flatMap((r) => r.rows.map((x) => ({ ...x, page: r.page })));
- const badRows = rows.filter((x) => x.spread > 2);
- const blanks = results.flatMap((r) => r.blanks.map((x) => ({ ...x, page: r.page })));
- const hoverFails = results.flatMap((r) => r.hoverFail.map((x) => ({ ...x, page: r.page })));
- const clickables = results.reduce((n, r) => n + r.clickables, 0);
- const bottomGaps = results.map((r) => ({ page: r.page, gap: Math.max(0, r.scrollH - r.lastContentBottom) }));
- const pool = results.find((r) => r.page === '/pool');
- const wb = results.find((r) => r.page === '/');
- const checks = [];
- const check = (name, ok, detail) => { checks.push({ name, ok, detail }); console.log(`\n${JUDGE ? (ok ? '✓' : '✗') : '·'} ${name}\n ${detail}`); };
- console.log(`\n===== v12 视觉验收 · tag=${TAG} · 页面 ${PAGES.join(' ')} =====`);
- check('圆角种类 ≤ 3 且 ∈ {8,12,20}(0 只允许分割线/非容器)', badRadii.length === 0 && Object.keys(radiusKinds).length <= 3,
- `容器圆角(≤6px 的量条/色点不计)${Object.keys(radiusKinds).length}:${Object.entries(radiusKinds).map(([k, n]) => `${k}px×${n}`).join('、')}` +
- (badRadii.length ? `\n 违禁:${badRadii.map(([k, n]) => `${k}px×${n}(如 ${results.flatMap((r) => Object.entries(r.radiiWhere || {}).filter(([kk]) => kk === k).map(([, w]) => w)).slice(0, 3).join(' / ')})`).join(';')}` : ''));
- check('原生外观控件 = 0', nativeControls.length === 0,
- `控件总数 ${controls.length},原生外观 ${nativeControls.length}` +
- (nativeControls.length ? '\n ' + nativeControls.slice(0, 12).map((c) => `${c.page} ${c.tag} 「${c.text}」${c.reasons.join(',')}`).join('\n ') : ''));
- check('描边 > 1px = 0', thickReal.length === 0,
- `> 1px border ${thickReal.length}` + (thickReal.length ? ':' + thickReal.slice(0, 10).map((x) => `${x.page} ${x.tag} ${x.w}px`).join('、') : ''));
- check('纯黑 rgb(0,0,0) = 0', blacks.length === 0,
- `${blacks.length}` + (blacks.length ? ':' + blacks.slice(0, 10).map((x) => `${x.page} ${x.tag}「${x.text}」`).join('、') : ''));
- check('字号档 ≤ 5 且无 13.3333px', fontKinds.length <= 5 && !fontKinds.some((f) => Math.abs(f - 13.3333) < 0.01),
- `${fontKinds.length} 档:${fontKinds.map((f) => `${f}×${fonts[f]}`).join(' / ')}`);
- check('同排卡片高度差 ≤ 2px', badRows.length === 0,
- `测了 ${rows.length} 行,超差 ${badRows.length}` + (badRows.length ? '\n ' + badRows.slice(0, 8).map((x) => `${x.page} ${x.container} n=${x.n} 极差 ${x.spread}px [${x.heights.join(',')}]`).join('\n ') : ''));
- check('卡内连续空白 ≤ 24px', blanks.length === 0,
- `超 24px 的位置 ${blanks.length}` + (blanks.length ? '\n ' + blanks.slice(0, 8).map((x) => `${x.page} ${x.between} +${x.extra}px`).join('\n ') : ''));
- check('可点元素 hover 反馈', hoverFails.length === 0,
- `可点 ${clickables},无反馈 ${hoverFails.length}` + (hoverFails.length ? '\n ' + hoverFails.slice(0, 12).map((x) => `${x.page} ${x.tag}「${x.text}」`).join('\n ') : ''));
- check('页面底部空白 ≤ 120px', bottomGaps.every((b) => b.gap <= 120),
- bottomGaps.map((b) => `${b.page} ${b.gap}px`).join(','));
- check('P1 筛选栏:一个容器 + 分段排序 + 与搜索框等高底对齐', (() => {
- const f = pool?.filterLayout;
- if (!f) return false;
- const segOk = f.seg && f.segGap === '0px' && new Set(f.segBtnHeights).size === 1;
- const chipOk = f.chipHeights.every((h) => h >= 28 && h <= 32) && f.chipFonts.length === 1 && f.chipFonts[0] === '12px';
- /* 底边对齐:搜索框与筛选容器里最后一个 chip 行的底边(不是容器 padding 底) */
- return f.container && f.filtersInsideToolbar && segOk && chipOk && f.searchH !== null && f.rowBottomDelta !== null && f.rowBottomDelta <= 2;
- })(), pool ? `容器 ${pool.filterLayout.container},filters 在内 ${pool.filterLayout.filtersInsideToolbar},seg gap=${pool.filterLayout.segGap} 按钮高[${pool.filterLayout.segBtnHeights}],chip 高[${[...new Set(pool.filterLayout.chipHeights)]}] 字号[${pool.filterLayout.chipFonts}],搜索框高 ${pool.filterLayout.searchH},同行底边差 ${pool.filterLayout.rowBottomDelta}px,与容器底边差 ${pool.filterLayout.bottomDelta}px` : '未量 /pool');
- check('P3 工作台两栏等高(差 ≤ 2px)且左卡无 > 32px 连续空白', Boolean(wb?.wbCols) && wb.wbCols.diff !== null && wb.wbCols.diff <= 2 && wb.wbCols.leftMaxGap <= 32,
- wb?.wbCols ? `两栏高 [${wb.wbCols.heights}],差 ${wb.wbCols.diff}px,左卡最大连续空白 ${wb.wbCols.leftMaxGap}px` : '未量 /');
- if (errors.length) console.log(`\n页面错误 ${errors.length}:\n ${errors.slice(0, 10).join('\n ')}`);
- const fail = checks.filter((c) => !c.ok);
- console.log(`\n${JUDGE ? '判分' : '基线(不判分)'}:${checks.length - fail.length}/${checks.length} 达标${errors.length ? `,页面错误 ${errors.length}` : ''}`);
- if (JSON_OUT) {
- mkdirSync(dirname(join(ROOT, JSON_OUT)), { recursive: true });
- writeFileSync(join(ROOT, JSON_OUT), JSON.stringify({ tag: TAG, at: new Date().toISOString(), pages: results, checks, errors }, null, 2));
- }
- await browser.close();
- srv.kill();
- process.exit(JUDGE && (fail.length || errors.length) ? 1 : 0);
|