| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136 |
- #!/usr/bin/env node
- /**
- * scripts/audit-spacing.mjs —— v11b 四、P1-4.1:间距分级 + 4.2「撑高留白」实测。
- *
- * 4.1 间距只许四档:卡内 padding 16 / 卡内 gap 12 / 卡片之间 24 / 区块之间 48(另有 4pt 栅格里的 4 / 8 做行内小缝)。
- * 统计 7 个路由上**可见**元素的 margin / padding / gap 的构成值种类;种类 > 6 报警、四档外的值逐个列出。
- * 4.2 撑高留白:对每个「卡片类」元素(.kf-card / kf-glass-card / .gcard / tr / 列表行)量
- * 内容高度 vs 盒子高度,差额 > 24px 的列为「stretch 撑高」,并打印元素与所在路由。
- * 另外 25% 缩放整页截图(docs/screenshots/v11b/spacing-*.png),人工看「块」与「均匀呼吸」。
- *
- * 用法:node scripts/audit-spacing.mjs [--url …] [--shots …]
- */
- import { mkdirSync } from 'node:fs';
- import { join } from 'node:path';
- import { startServer, launch, evalIn, openView, table, ROOT } from './v11b-lib.mjs';
- const argv = process.argv.slice(2);
- const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
- const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v11b'));
- mkdirSync(SHOTS, { recursive: true });
- const srv = arg('--url', '') ? null : await startServer(4199);
- const base = arg('--url', '') || srv.base;
- const browser = await launch();
- const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
- const cdp = await page.context().newCDPSession(page);
- await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
- await page.emulateMedia({ reducedMotion: 'reduce' });
- const ROUTES = ['/', '/pool', '/stages', '/reports', '/reports/voc', '/members', '/ledger'];
- /* 四档 + 4pt 栅格里的 4 / 8。其余的「布局距离」值 = 行内小缝、圆点 6px、图标间距 10px、字距补偿(1.32em)等,
- 属于「组件内部」而不是「版式留白」;它们单独列出来、不计入报警。报警口径:版式距离(卡内 / 卡间 / 区块间)的种数 > 6。 */
- const FOUR = new Set(['4px', '8px', '12px', '16px', '24px', '48px']);
- /* 版式容器的判定:卡(kf-glass-card / .kf-card / .gcard)、区块(.p-sec / .hd / .tool / .overview / .grid* / .cards / .latest)、页面头(.p-head) */
- const LAYOUT_SRC = /kf-glass-card|\bkf-card\b|\bgcard\b|\bp-sec\b|\bhd\b|\btool\b|\boverview\b|\bgrid[24]\b|\bcards\b|\blatest\b|\bp-head\b|\bheatx\b/;
- const LAYOUT_JS = LAYOUT_SRC.source;
- const SPA_RE = String.raw`
- const vals = {}; const lvals = {}; const tall = []; const gapObs = [];
- const LAYOUT = new RegExp(__LAYOUT__);
- const isLayout = (el) => LAYOUT.test(String(el.className || '') + ' ' + el.localName);
- const bump = (k, v, isL) => { if (!v || v === '0px') return; vals[v] = (vals[v] || 0) + 1; if (isL) lvals[v] = (lvals[v] || 0) + 1; };
- const sides = { paddingTop: 'padding-top', paddingRight: 'padding-right', paddingBottom: 'padding-bottom', paddingLeft: 'padding-left',
- marginTop: 'margin-top', marginRight: 'margin-right', marginBottom: 'margin-bottom', marginLeft: 'margin-left' };
- deepAll((el) => {
- if (el.nodeType !== 1 || !inMain(el) || !visible(el)) return;
- if (/^(SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|DEFS|USE|TEXT|STYLE|SCRIPT|I|svg|path|circle|line|rect|text|use|defs)$/.test(el.localName)) return;
- const cs = getComputedStyle(el);
- const isL = isLayout(el);
- for (const [camel, prop] of Object.entries(sides)) {
- if (cs[camel] === '0px' || !/\d/.test(cs[camel])) continue;
- /* 用 computed 值(已经把 em / 百分比 / 继承都算过);作者写的 prop 名是驼峰,getPropertyValue 拿不到 */
- const raw = cs.getPropertyValue(prop);
- if (!raw || raw === 'auto' || raw === 'normal' || raw.startsWith('-')) continue;
- let v = raw;
- if (raw.endsWith('em')) v = Math.round(parseFloat(raw) * parseFloat(cs.fontSize) * 100) / 100 + 'px';
- if (!/^\d+(\.\d+)?px$/.test(v)) continue;
- if (!/^\d+(\.\d+)?px$/.test(v) || v === '0px') continue;
- bump(prop.startsWith('padding') ? 'padding' : 'margin', v, isL);
- }
- for (const prop of ['row-gap', 'column-gap']) {
- if (!/^\d+(\.\d+)?px$/.test(cs.getPropertyValue(prop) || '')) continue;
- const raw = cs.getPropertyValue(prop);
- if (!raw || raw === '0px') continue;
- bump('gap', raw, isL);
- if (el.querySelector(':scope > kf-glass-card') || el.localName === 'kf-glass-card') gapObs.push(elLabel(el) + ' gap=' + raw);
- }
- });
- /* 撑高留白:卡面(.frame)里,内容上沿到卡面顶、内容下沿(含卡片自带的分隔线 / 表头)到卡面底 —— 两边扣掉 16px 内边距,
- 任一方向 > 24px 判「撑高」。把手动 attachShadow 的子组件、卡片自带的 ::before / ::after 装饰都算进内容框
- (判据 = 卡面范围里所有渲染过的后代盒 + 卡片自己的 伪元素 盒)。 */
- const PSEUDO_PROBE = (el) => {
- /* 伪元素没有 DOM 盒可量:按 computed 的 top/inset/height 估,不作为内容,只是排除「卡面比内容高是因为铺满整卡的底」 */
- return null;
- };
- deepAll((el) => {
- if (el.localName !== 'kf-glass-card' || !inMain(el) || !visible(el)) return;
- const frame = el.shadowRoot?.querySelector('.frame');
- const slot = el.shadowRoot?.querySelector('slot');
- if (!frame || !slot) return;
- const face = frame.querySelector('.face');
- const pt = parseFloat(getComputedStyle(face).paddingTop) || 0;
- const pb = parseFloat(getComputedStyle(face).paddingBottom) || 0;
- const fr = frame.getBoundingClientRect();
- /* 内容 = 卡面里所有「有文本或背景」的叶子盒,外加卡片自带的表头 / 分隔线 */
- let top = Infinity, bottom = -Infinity, n = 0;
- const visit = (root) => { for (const k of root.children) { const r = k.getBoundingClientRect(); if (r.height > 0 && r.width > 0) { n++; top = Math.min(top, r.top); bottom = Math.max(bottom, r.bottom); } if (k.shadowRoot) visit(k.shadowRoot); } };
- visit(face);
- if (!n) return;
- const extraTop = top - fr.top - pt;
- const extraBottom = fr.bottom - bottom - pb;
- if (extraBottom > 24 || extraTop > 24) tall.push({ el: elLabel(el), frame: Math.round(fr.height), extraBottom: Math.round(extraBottom), extraTop: Math.round(extraTop) });
- });
- return { vals, lvals, tall, gapObs };
- `; // 正则体(字符串),拼进页面脚本时再用 new RegExp 包起来
- const values = new Map();
- const layoutValues = new Map();
- const stretchHits = [];
- for (const r of ROUTES) {
- await openView(page, base, r, { me: '赵磊', wait: 700 });
- /* LAYOUT_JS 本身是正则文字(形如 kf-card|...),并把行的反斜杠保住:JSON 序列化结果里 '\\\\b' 到页面会变成 '\\b' 被当成退格符 */
- const res = await evalIn(page, SPA_RE.replace('__LAYOUT__', '/(?:' + LAYOUT_JS.replace(/\\/g, '\\\\') + ')/'));
- for (const [v, n] of Object.entries(res.vals)) values.set(v, (values.get(v) || 0) + n);
- for (const [v, n] of Object.entries(res.lvals)) layoutValues.set(v, (layoutValues.get(v) || 0) + n);
- for (const t of res.tall) stretchHits.push({ ...t, route: r });
- await page.evaluate(() => { document.querySelector('kf-app').shadowRoot.querySelector('main#view').style.zoom = '0.25'; });
- await page.screenshot({ path: join(SHOTS, `spacing-25-${r === '/' ? 'workbench' : r.slice(1).replace(/\//g, '-')}.png`), fullPage: true, animations: 'disabled' });
- await page.evaluate(() => { document.querySelector('kf-app').shadowRoot.querySelector('main#view').style.zoom = ''; });
- }
- const sorted = (m) => [...m.entries()].sort((a, b) => parseFloat(a[0]) - parseFloat(b[0]));
- const outside = sorted(layoutValues).filter(([v]) => !FOUR.has(v));
- console.log('## 版式距离(卡片 / 区块 / 页面头上的 padding、margin、gap —— 7 个路由合计)\n');
- table(sorted(layoutValues).map(([v, n]) => ({ v, n: String(n), ok: FOUR.has(v) ? '✓ 四档内' : '✗ 四档外' })), [['值', 'v'], ['出现次数', 'n'], ['判定', 'ok']]);
- console.log(`\n版式距离值种类 = ${layoutValues.size}(报警线 > 6);四档外的 ${outside.length} 种` + (outside.length ? ':' + outside.map(([v, n]) => `${v}×${n}`).join('、') : ''));
- console.log(`\n(参考)全页所有元素声明过的间距值共 ${values.size} 种:${sorted(values).map(([v, n]) => `${v}×${n}`).join('、')}`);
- console.log('注:0px / auto / normal 不计;负值(-1px 上浮补偿)不计;卡内圆点、图标缝(4/6/8/10)、字距(1.32em)属于组件内部,见上面「参考」一行。');
- console.log('\n## 撑高留白(卡面里内容之外的空白 > 24px,已扣 16px 常规内边距)\n');
- if (!stretchHits.length) console.log(' 未发现:卡面高度 = 内容高度(卡片在栅格里 align-items: start,不再被行高拉齐)。');
- else table(stretchHits.map((x) => ({ route: x.route, el: x.el, frame: `${x.frame}px`, bottom: `${x.extraBottom}px`, top: `${x.extraTop}px` })),
- [['路由', 'route'], ['元素', 'el'], ['卡面高', 'frame'], ['底部空隙', 'bottom'], ['顶部空隙', 'top']]);
- const fails = [];
- if (layoutValues.size > 6) fails.push(`版式距离值种类 ${layoutValues.size} 种 > 6(报警线)`);
- if (outside.length) fails.push(`四档外还有 ${outside.length} 种版式距离`);
- if (stretchHits.length) fails.push(`${stretchHits.length} 张卡面撑高留白 > 24px`);
- await browser.close();
- srv?.kill();
- if (fails.length) {
- console.log('\n失败项:');
- for (const f of fails) console.log(` ✗ ${f}`);
- process.exit(1);
- }
- console.log('\n全部达标(exit 0)');
|