verify-visual-v12.mjs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/verify-visual-v12.mjs —— v12 深度视觉重构验收(任务书「二、硬性数值规范」逐项实测)。
  4. *
  5. * 两种用法:
  6. * node scripts/verify-visual-v12.mjs --tag before 只打印现状,**不判分**(改前基线)
  7. * node scripts/verify-visual-v12.mjs 判分:任一项不达标 exit 1
  8. * --page /pool 只量一页(调试用)
  9. * --json out.json 结果落盘(before-after 文档从这里抄数字,不手填)
  10. *
  11. * 量什么、怎么量(每项都打印分子/分母,不打印「通过」两个字):
  12. * 1. 圆角种类 所有可见元素(跨 shadow)的 computed border-radius,去重后按半径归类;只允许 0 / 8 / 12 / 20 / 9999(胶囊)
  13. * 2. 原生控件 button/input/select/textarea/summary:外观被显式覆盖(非 appearance:auto / 非浏览器默认灰底 / 有字族与圆角)
  14. * 3. 描边 可见元素的 border-width / outline-width 中 > 1px 的数量
  15. * 4. 纯黑 computed color === rgb(0, 0, 0) 且带可见文字的元素数
  16. * 5. 字号档 可见文字的 font-size 去重(容差 0.1px)
  17. * 6. 同排卡高 同一个 grid 容器里的直接子项,按 top 分行(±4px),行内 height 极差
  18. * 7. 卡内空白 卡片内部相邻两行内容之间的竖直间隙,> 24px 记一次
  19. * 8. hover 每个可点元素 pointerover 前后,背景色 / 边框色 / 背景图 / 颜色 / 阴影任一变化
  20. * 9. 底部空白 document 可滚高度 - 最后一块主内容底边
  21. * 10. 框内布局 筛选容器是一个整体、排序是分段控件、搜索框与筛选区底边差(P1)
  22. * 11. 左右等高 工作台两栏首行卡片高度差(P3)
  23. *
  24. * 注意(验收脚本的老坑,见 memory kf-verify-browser-quirks):
  25. * - 视图异步挂载:goto 后等 kf-app → view-* 的 shadowRoot 有子节点,再等 300ms;
  26. * - 跨 shadow 的可见性要沿 parentElement → getRootNode().host 一路走;
  27. * - hover 必须用真实指针(page.mouse.move):合成 pointerover / mouseover 事件不会让 :hover 命中。
  28. */
  29. import { mkdirSync, writeFileSync } from 'node:fs';
  30. import { join, dirname } from 'node:path';
  31. import { fileURLToPath } from 'node:url';
  32. const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  33. const argv = process.argv.slice(2);
  34. const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
  35. const TAG = arg('--tag', 'after');
  36. const JUDGE = TAG !== 'before';
  37. const ONLY = arg('--page', '');
  38. const JSON_OUT = arg('--json', '');
  39. if (!process.env.CHROME_PATH) {
  40. const pinned = '/opt/hermes/.playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell';
  41. const { existsSync } = await import('node:fs');
  42. if (existsSync(pinned)) process.env.CHROME_PATH = pinned;
  43. }
  44. const { startServer, launch, DEEP } = await import('./v11b-lib.mjs');
  45. const srv = await startServer(Number(process.env.VERIFY_PORT || 4193));
  46. const browser = await launch();
  47. const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
  48. const page = await ctx.newPage();
  49. const errors = [];
  50. page.on('pageerror', (e) => errors.push(e.message));
  51. page.on('console', (m) => { if (m.type() === 'error' && !/favicon/.test(m.text())) errors.push(m.text()); });
  52. const VIEW_OF = { '/': 'view-workbench', '/pool': 'view-pool', '/stages': 'view-stages', '/reports': 'view-reports' };
  53. const PAGES = ONLY ? [ONLY] : ['/', '/pool', '/stages', '/reports'];
  54. const gotoView = async (p) => {
  55. await page.goto(new URL(p, srv.base).href, { waitUntil: 'networkidle' });
  56. await page.waitForFunction((name) => {
  57. const a = document.querySelector('kf-app');
  58. const v = a?.shadowRoot?.querySelector(name);
  59. return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0;
  60. }, VIEW_OF[p], { timeout: 15000 });
  61. await page.waitForTimeout(700);
  62. };
  63. /** 页面里执行:所有「测量」都放在这一段里,避免每次 evaluate 的字符串拼装差异 */
  64. const MEASURE = `(async () => {
  65. ${DEEP}
  66. const root = () => [document, ...[...document.querySelectorAll('*')].map((e) => e.shadowRoot).filter(Boolean)];
  67. const all = [];
  68. const collect = (r) => { for (const el of r.querySelectorAll('*')) { all.push(el); if (el.shadowRoot) collect(el.shadowRoot); } };
  69. collect(document);
  70. /* 可见:跨 shadow 链上无 display:none / visibility:hidden / opacity<.05;自身盒 ≥ 8×4 或带可见文字 */
  71. const vis = (el) => {
  72. const r = el.getBoundingClientRect();
  73. if (r.width < 8 || r.height < 4) return false;
  74. if (!shownByStyle(el)) return false;
  75. return true;
  76. };
  77. const hasText = (el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
  78. const PAGES = ${JSON.stringify(PAGES)};
  79. const page = location.pathname.replace(/\\/+$/, '') || '/';
  80. const out = { page, radii: {}, controls: [], thickBorders: [], blackText: [], fontSizes: {}, rows: [], blanks: [], clickables: 0, hoverFail: [], scrollH: 0, lastContentBottom: 0, filterLayout: null, wbCols: null };
  81. /* ---- 1. 圆角 ---- */
  82. const radKinds = {};
  83. const radWhere = {};
  84. for (const el of all) {
  85. if (!vis(el)) continue;
  86. const s = getComputedStyle(el);
  87. const parts = [s.borderTopLeftRadius, s.borderTopRightRadius, s.borderBottomLeftRadius, s.borderBottomRightRadius]
  88. .map((v) => v.split(' ')[0]);
  89. /* 量条 / 复选框 / 色点这类 ≤ 6px 的小件:形状由尺寸决定(4px 高的进度条圆角 2px = 胶囊),
  90. 不作为「圆角体系」计数;8px 以上才算容器圆角,必须落在 8 / 12 / 20。 */
  91. const box = el.getBoundingClientRect();
  92. const tiny = box.width <= 24 || box.height <= 6;
  93. if (tiny) continue;
  94. for (const p of parts) {
  95. const n = Math.round(parseFloat(p) || 0);
  96. const pct = p.includes('%');
  97. /* 百分比 = 圆形(头像 / 环),不算圆角档;9999px 以上记作胶囊 */
  98. if (pct) { radKinds.circle = (radKinds.circle || 0) + 1; continue; }
  99. if (n > 9000 || p.includes('calc') ) { /* 未解析 */ }
  100. const k = n;
  101. radKinds[k] = (radKinds[k] || 0) + 1;
  102. if (!radWhere[k]) radWhere[k] = elLabel(el).slice(0, 48);
  103. }
  104. }
  105. out.radii = radKinds; out.radiiWhere = radWhere;
  106. /* ---- 2. 原生控件 ---- */
  107. const ctrlSel = 'button, input, select, textarea';
  108. for (const el of all) {
  109. if (!el.matches?.(ctrlSel)) continue;
  110. if (!vis(el)) continue;
  111. const s = getComputedStyle(el);
  112. const tag = elLabel(el);
  113. const bg = s.backgroundColor;
  114. const bgImg = s.backgroundImage;
  115. const reasons = [];
  116. /* 文本框(input)在 Chrome 里 appearance 恒为 auto,但给了 rgba 半透明底色就是已覆盖默认外观;
  117. select / textarea 必须显式 appearance:none(select 默认画下拉箭头与系统边框)。 */
  118. const carriesOwnLook = bg !== 'rgba(0, 0, 0, 0)' || bgImg !== 'none' || (parseFloat(s.borderTopWidth) || 0) > 0;
  119. const needsAppearance = el.localName === 'select' || el.localName === 'textarea' || (el.localName === 'input' && (s.appearance !== 'none' && !carriesOwnLook));
  120. if (needsAppearance && el.localName !== 'button' && s.appearance !== 'none' && s.webkitAppearance !== 'none') reasons.push('appearance:' + s.appearance);
  121. /* 浏览器默认按钮外观:灰底 rgb(239,239,239|221,221,221|192,192,192) + 2px outset */
  122. const m = bg.match(/rgba?\\((\\d+), (\\d+), (\\d+)/);
  123. 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);
  124. const bw = parseFloat(s.borderTopWidth) || 0;
  125. if (el.localName === 'button' && bw >= 2 && /outset|inset|groove|ridge/.test(s.borderTopStyle)) reasons.push('浏览器浮雕边 ' + s.borderTopStyle + bw + 'px');
  126. if (/^(Times|serif)/.test(s.fontFamily) || s.fontFamily === 'none') reasons.push('字族泄漏:' + s.fontFamily.slice(0, 24));
  127. if ((el.localName === 'button' || el.localName === 'input') && s.borderRadius === '0px' && bw > 0) reasons.push('无圆角有边框');
  128. const r = el.getBoundingClientRect();
  129. 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) });
  130. }
  131. /* ---- 3. 描边 > 1px ---- */
  132. for (const el of all) {
  133. if (!vis(el)) continue;
  134. const s = getComputedStyle(el);
  135. const widths = [s.borderTopWidth, s.borderRightWidth, s.borderBottomWidth, s.borderLeftWidth]
  136. .map((v) => parseFloat(v) || 0).filter((w) => w > 1 && s.borderTopStyle !== 'none');
  137. const ow = parseFloat(s.outlineWidth) || 0;
  138. 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 });
  139. }
  140. /* ---- 4. 纯黑文字 ---- */
  141. for (const el of all) {
  142. if (!vis(el) || !hasText(el)) continue;
  143. /* 只量「直接承载文字」的元素,且字形真的落在里面(子元素另有颜色的不算) */
  144. const s = getComputedStyle(el);
  145. if (s.color === 'rgb(0, 0, 0)') out.blackText.push({ tag: elLabel(el).slice(0, 50), text: txt(el).trim().slice(0, 20) });
  146. }
  147. /* ---- 5. 字号档 ---- */
  148. for (const el of all) {
  149. if (!vis(el) || !hasText(el)) continue;
  150. /* 只看兄弟文本节点真的有字形的元素 */
  151. const fs = Math.round(parseFloat(getComputedStyle(el).fontSize) * 100) / 100;
  152. out.fontSizes[fs] = (out.fontSizes[fs] || 0) + 1;
  153. }
  154. /* ---- 6. 同排卡高:grid / flex 容器里直接子项按 top 分行 ---- */
  155. const rowSeen = new Set();
  156. for (const el of all) {
  157. const s = getComputedStyle(el);
  158. if (!/grid|flex/.test(s.display)) continue;
  159. const kids = [...el.children].filter((k) => vis(k) && k.getBoundingClientRect().height > 40);
  160. if (kids.length < 2) continue;
  161. /* 整页布局容器(.shell:侧栏 + 主列)不是「同排卡片」:它的两个子项一高一低是页面结构本身 */
  162. if (el.matches('.shell, body, main, #view, .main')) continue;
  163. const tops = new Map();
  164. for (const k of kids) {
  165. const r = k.getBoundingClientRect();
  166. const key = Math.round(r.top / 4) * 4;
  167. if (!tops.has(key)) tops.set(key, []);
  168. tops.get(key).push(r.height);
  169. }
  170. for (const [top, hs] of tops) {
  171. if (hs.length < 2) continue;
  172. const spread = Math.round((Math.max(...hs) - Math.min(...hs)) * 10) / 10;
  173. const id = elLabel(el) + '@' + top;
  174. if (rowSeen.has(id)) continue; rowSeen.add(id);
  175. out.rows.push({ container: elLabel(el).slice(0, 40), top, n: hs.length, spread, heights: hs.map((h) => Math.round(h)) });
  176. }
  177. }
  178. /* ---- 7. 卡内连续空白:卡片内部「有内容的直接子块」之间的竖直间隙 ----
  179. 卡片 = .kf-card / .frame / kf-glass-card 里的 .wrap;取每张卡内容列的直接子项(渲染树里有文字或有尺寸的),
  180. 相邻子项底 → 下一项顶,> 24px 记一次(padding 之外)。 */
  181. const cardRoots = [];
  182. for (const el of all) {
  183. if (!el.matches) continue;
  184. if (el.matches('kf-glass-card')) cardRoots.push(el);
  185. }
  186. for (const card of cardRoots) {
  187. const face = card.shadowRoot?.querySelector('.face');
  188. if (!face || !vis(card)) continue;
  189. /* 卡里的内容列:slot 的第一个元素里的 .wrap 或直接子块 */
  190. const slot = face.querySelector('slot');
  191. const inst = slot ? slot.assignedElements()[0] : null;
  192. if (!inst) continue;
  193. const col = inst.querySelector(':scope > .wrap') || inst.querySelector(':scope > .rc') || inst;
  194. const kids = [...col.children].filter((k) => {
  195. const r = k.getBoundingClientRect();
  196. return r.height > 0 && getComputedStyle(k).position !== 'absolute';
  197. });
  198. for (let i = 1; i < kids.length; i++) {
  199. const a = kids[i - 1].getBoundingClientRect(), b = kids[i].getBoundingClientRect();
  200. const gap = Math.round(b.top - a.bottom);
  201. /* 兄弟间 gap(flex gap / margin)为 0 时不算空白:内容是紧挨着的 */
  202. const cs = getComputedStyle(col);
  203. const declared = parseFloat(cs.rowGap) || 0;
  204. const extra = Math.round(gap - declared);
  205. 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 });
  206. }
  207. }
  208. /* ---- 8. hover:穷举可点元素 ---- */
  209. const clickables = [];
  210. const seen = new Set();
  211. for (const el of all) {
  212. if (!el.matches?.('button, a[href], summary, [role="button"], [role="tab"], [role="menuitemcheckbox"], [role="menuitemradio"], [data-link]')) continue;
  213. if (!vis(el)) continue;
  214. const r = el.getBoundingClientRect();
  215. /* 只要中心点真的是它自己(不被遮挡),才 hover */
  216. const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
  217. if (cx < 0 || cy < 0 || cx > innerWidth || cy > innerHeight) continue;
  218. const key = elLabel(el) + '@' + Math.round(cx) + ',' + Math.round(cy);
  219. if (seen.has(key)) continue; seen.add(key);
  220. clickables.push({ el, cx, cy, sig: [getComputedStyle(el).backgroundColor, getComputedStyle(el).borderTopColor, getComputedStyle(el).backgroundImage, getComputedStyle(el).color, getComputedStyle(el).boxShadow, getComputedStyle(el).transform] });
  221. }
  222. out.clickables = clickables.length;
  223. /* 悬停必须用「真实指针」:合成 pointerover 事件不会让 :hover 命中(v11 实测 :hover 规则对合成事件无效),
  224. 所以逐个别移动鼠标,并在移动后把命中的元素收集回来由外圈逐一核对。 */
  225. /* clickables 本身不进返回值(元素引用不可序列化):坐标 + 签名 + 标签即可 */
  226. 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) }));
  227. /* ---- 9. 底部空白 ---- */
  228. out.scrollH = Math.round(document.documentElement.scrollHeight);
  229. const mainEl = main();
  230. let bottom = 0;
  231. const scan = (root) => {
  232. for (const el of root.querySelectorAll('*')) {
  233. if (!vis(el)) continue;
  234. if (!hasText(el) && !el.matches('img, svg, kf-glass-card, kf-opportunity-card, kf-empty, canvas')) continue;
  235. /* 忽略整块容器的盒,只看「叶子」内容与其卡片 */
  236. const r = el.getBoundingClientRect();
  237. bottom = Math.max(bottom, r.bottom + scrollY);
  238. if (el.shadowRoot) scan(el.shadowRoot);
  239. }
  240. };
  241. scan(mainEl);
  242. out.lastContentBottom = Math.round(bottom);
  243. /* ---- 10. 筛选区(P1)与 11. 工作台两栏(P3) ---- */
  244. const q = (sel) => appR().querySelector('main#view')?.firstElementChild?.shadowRoot?.querySelector(sel) || null;
  245. if (page === '/pool') {
  246. const filters = q('.filters');
  247. const tb = q('.filters[data-toolbar]');
  248. const seg = filters ? filters.querySelector('.seg') : null;
  249. const search = filters ? filters.querySelector('.frow.toolbar input[type="search"]') : null;
  250. const chips = [...(q('.filters')?.querySelectorAll('.kf-chip') || [])];
  251. const chipSizes = chips.map((c) => { const r = c.getBoundingClientRect(); return [Math.round(r.height), getComputedStyle(c).fontSize]; });
  252. const segBtns = seg ? [...seg.querySelectorAll('button')] : [];
  253. const segGap = seg ? getComputedStyle(seg).gap : '';
  254. const segBorders = seg ? segBtns.map((b) => { const s = getComputedStyle(b); return s.borderRightWidth + ' ' + (s.borderRadius || ''); }) : [];
  255. out.filterLayout = {
  256. container: !!tb, filtersInsideToolbar: tb ? tb.contains(filters) : false,
  257. seg: !!seg, segGap, segBtnHeights: segBtns.map((b) => Math.round(b.getBoundingClientRect().height)),
  258. chipHeights: chipSizes.map((x) => x[0]), chipFonts: [...new Set(chipSizes.map((x) => x[1]))],
  259. searchH: search ? Math.round(search.getBoundingClientRect().height) : null,
  260. bottomDelta: search && filters ? Math.round(search.getBoundingClientRect().bottom - filters.getBoundingClientRect().bottom) : null,
  261. /* 底边对齐:搜索框底边 vs 它所在那一行(.frow.toolbar)里排序段与 chip 的底边 —— 三者差 ≤ 2px 即「同一底边」 */
  262. rowBottomDelta: (() => {
  263. if (!search) return null;
  264. const row = search.closest('.frow');
  265. const segEl = row?.querySelector('.seg');
  266. const sb = search.getBoundingClientRect().bottom;
  267. const others = [segEl, ...(row ? row.querySelectorAll('.kf-chip') : [])].filter(Boolean).map((e) => e.getBoundingClientRect().bottom);
  268. return others.length ? Math.round(Math.max(...others.map((o) => Math.abs(o - sb))) * 10) / 10 : null;
  269. })(),
  270. segBorder: segBorders,
  271. };
  272. }
  273. if (page === '/') {
  274. const g = q('.grid2');
  275. if (g) {
  276. const ks = [...g.children].map((k) => Math.round(k.getBoundingClientRect().height));
  277. out.wbCols = { heights: ks, diff: ks.length === 2 ? Math.abs(ks[0] - ks[1]) : null };
  278. /* 左卡(或「还没选」空态卡)内的连续空白 */
  279. const first = g.children[0];
  280. const card = first.shadowRoot?.querySelector('.frame');
  281. if (card) {
  282. let maxGap = 0;
  283. const face = card.querySelector('.face');
  284. const inst = face?.querySelector('slot')?.assignedElements()[0];
  285. if (inst) {
  286. const leaves = [...inst.querySelectorAll('*')].filter((e) => vis(e) && hasText(e));
  287. const rs = leaves.map((e) => e.getBoundingClientRect()).sort((a, b) => a.top - b.top);
  288. for (let i = 1; i < rs.length; i++) maxGap = Math.max(maxGap, Math.round(rs[i].top - rs[i - 1].bottom));
  289. }
  290. out.wbCols.leftMaxGap = maxGap;
  291. }
  292. }
  293. }
  294. return out;
  295. })()`;
  296. const results = [];
  297. for (const p of PAGES) {
  298. await gotoView(p);
  299. const r = await page.evaluate(MEASURE);
  300. /* 悬停:逐个用真实鼠标移到元素中心;鼠标离开后等过渡回落 */
  301. await page.mouse.move(2, 2);
  302. await page.waitForTimeout(60);
  303. for (const c of r.pendingHover || []) {
  304. await page.mouse.move(c.x, c.y, { steps: 1 });
  305. await page.waitForTimeout(240); /* 等过渡(最长 240ms)走完 */
  306. const after = await page.evaluate(([x, y]) => {
  307. const el = document.elementFromPoint(x, y);
  308. if (!el) return null;
  309. const s = getComputedStyle(el);
  310. return [s.backgroundColor, s.borderTopColor, s.backgroundImage, s.color, s.boxShadow, s.transform, s.cursor].join('|');
  311. }, [c.x, c.y]);
  312. if (after === c.sig) r.hoverFail.push({ tag: c.tag, text: c.text });
  313. await page.mouse.move(2, 2);
  314. await page.waitForTimeout(40);
  315. }
  316. delete r.pendingHover;
  317. results.push(r);
  318. }
  319. /* ============ 汇总 ============ */
  320. const ALLOWED_RADII = new Set([0, 8, 12, 20, 9999]);
  321. const sum = (k) => results.reduce((n, r) => n + (r.radii[k] || 0), 0);
  322. const radiusKinds = {};
  323. for (const r of results) for (const [k, n] of Object.entries(r.radii)) radiusKinds[k] = (radiusKinds[k] || 0) + n;
  324. const badRadii = Object.entries(radiusKinds).filter(([k]) => !ALLOWED_RADII.has(Number(k)) && k !== 'circle');
  325. const controls = results.flatMap((r) => r.controls.map((c) => ({ ...c, page: r.page })));
  326. const nativeControls = controls.filter((c) => c.reasons.length);
  327. const thick = results.flatMap((r) => r.thickBorders.map((x) => ({ ...x, page: r.page })));
  328. /* 焦点环(outline 2px)只在聚焦时出现,还没聚焦时 computed outline 是 none / 宽度 0 */
  329. const thickReal = thick.filter((x) => x.w > 1);
  330. const bright = thick.filter((x) => x.ow > 1);
  331. const blacks = results.flatMap((r) => r.blackText.map((x) => ({ ...x, page: r.page })));
  332. const fonts = {};
  333. for (const r of results) for (const [k, n] of Object.entries(r.fontSizes)) fonts[k] = (fonts[k] || 0) + n;
  334. const fontKinds = Object.keys(fonts).map(Number).sort((a, b) => a - b);
  335. const rows = results.flatMap((r) => r.rows.map((x) => ({ ...x, page: r.page })));
  336. const badRows = rows.filter((x) => x.spread > 2);
  337. const blanks = results.flatMap((r) => r.blanks.map((x) => ({ ...x, page: r.page })));
  338. const hoverFails = results.flatMap((r) => r.hoverFail.map((x) => ({ ...x, page: r.page })));
  339. const clickables = results.reduce((n, r) => n + r.clickables, 0);
  340. const bottomGaps = results.map((r) => ({ page: r.page, gap: Math.max(0, r.scrollH - r.lastContentBottom) }));
  341. const pool = results.find((r) => r.page === '/pool');
  342. const wb = results.find((r) => r.page === '/');
  343. const checks = [];
  344. const check = (name, ok, detail) => { checks.push({ name, ok, detail }); console.log(`\n${JUDGE ? (ok ? '✓' : '✗') : '·'} ${name}\n ${detail}`); };
  345. console.log(`\n===== v12 视觉验收 · tag=${TAG} · 页面 ${PAGES.join(' ')} =====`);
  346. check('圆角种类 ≤ 3 且 ∈ {8,12,20}(0 只允许分割线/非容器)', badRadii.length === 0 && Object.keys(radiusKinds).length <= 3,
  347. `容器圆角(≤6px 的量条/色点不计)${Object.keys(radiusKinds).length}:${Object.entries(radiusKinds).map(([k, n]) => `${k}px×${n}`).join('、')}` +
  348. (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(';')}` : ''));
  349. check('原生外观控件 = 0', nativeControls.length === 0,
  350. `控件总数 ${controls.length},原生外观 ${nativeControls.length}` +
  351. (nativeControls.length ? '\n ' + nativeControls.slice(0, 12).map((c) => `${c.page} ${c.tag} 「${c.text}」${c.reasons.join(',')}`).join('\n ') : ''));
  352. check('描边 > 1px = 0', thickReal.length === 0,
  353. `> 1px border ${thickReal.length}` + (thickReal.length ? ':' + thickReal.slice(0, 10).map((x) => `${x.page} ${x.tag} ${x.w}px`).join('、') : ''));
  354. check('纯黑 rgb(0,0,0) = 0', blacks.length === 0,
  355. `${blacks.length}` + (blacks.length ? ':' + blacks.slice(0, 10).map((x) => `${x.page} ${x.tag}「${x.text}」`).join('、') : ''));
  356. check('字号档 ≤ 5 且无 13.3333px', fontKinds.length <= 5 && !fontKinds.some((f) => Math.abs(f - 13.3333) < 0.01),
  357. `${fontKinds.length} 档:${fontKinds.map((f) => `${f}×${fonts[f]}`).join(' / ')}`);
  358. check('同排卡片高度差 ≤ 2px', badRows.length === 0,
  359. `测了 ${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 ') : ''));
  360. check('卡内连续空白 ≤ 24px', blanks.length === 0,
  361. `超 24px 的位置 ${blanks.length}` + (blanks.length ? '\n ' + blanks.slice(0, 8).map((x) => `${x.page} ${x.between} +${x.extra}px`).join('\n ') : ''));
  362. check('可点元素 hover 反馈', hoverFails.length === 0,
  363. `可点 ${clickables},无反馈 ${hoverFails.length}` + (hoverFails.length ? '\n ' + hoverFails.slice(0, 12).map((x) => `${x.page} ${x.tag}「${x.text}」`).join('\n ') : ''));
  364. check('页面底部空白 ≤ 120px', bottomGaps.every((b) => b.gap <= 120),
  365. bottomGaps.map((b) => `${b.page} ${b.gap}px`).join(','));
  366. check('P1 筛选栏:一个容器 + 分段排序 + 与搜索框等高底对齐', (() => {
  367. const f = pool?.filterLayout;
  368. if (!f) return false;
  369. const segOk = f.seg && f.segGap === '0px' && new Set(f.segBtnHeights).size === 1;
  370. const chipOk = f.chipHeights.every((h) => h >= 28 && h <= 32) && f.chipFonts.length === 1 && f.chipFonts[0] === '12px';
  371. /* 底边对齐:搜索框与筛选容器里最后一个 chip 行的底边(不是容器 padding 底) */
  372. return f.container && f.filtersInsideToolbar && segOk && chipOk && f.searchH !== null && f.rowBottomDelta !== null && f.rowBottomDelta <= 2;
  373. })(), 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');
  374. check('P3 工作台两栏等高(差 ≤ 2px)且左卡无 > 32px 连续空白', Boolean(wb?.wbCols) && wb.wbCols.diff !== null && wb.wbCols.diff <= 2 && wb.wbCols.leftMaxGap <= 32,
  375. wb?.wbCols ? `两栏高 [${wb.wbCols.heights}],差 ${wb.wbCols.diff}px,左卡最大连续空白 ${wb.wbCols.leftMaxGap}px` : '未量 /');
  376. if (errors.length) console.log(`\n页面错误 ${errors.length}:\n ${errors.slice(0, 10).join('\n ')}`);
  377. const fail = checks.filter((c) => !c.ok);
  378. console.log(`\n${JUDGE ? '判分' : '基线(不判分)'}:${checks.length - fail.length}/${checks.length} 达标${errors.length ? `,页面错误 ${errors.length}` : ''}`);
  379. if (JSON_OUT) {
  380. mkdirSync(dirname(join(ROOT, JSON_OUT)), { recursive: true });
  381. writeFileSync(join(ROOT, JSON_OUT), JSON.stringify({ tag: TAG, at: new Date().toISOString(), pages: results, checks, errors }, null, 2));
  382. }
  383. await browser.close();
  384. srv.kill();
  385. process.exit(JUDGE && (fail.length || errors.length) ? 1 : 0);