verify-visual-v12.mjs 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  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.waitForTimeout(200);
  57. try { await page.waitForFunction(() => window.scrollY === 0, null, { timeout: 3000 }); } catch { await page.evaluate(() => window.scrollTo(0, 0)); }
  58. await page.waitForFunction((name) => {
  59. const a = document.querySelector('kf-app');
  60. const v = a?.shadowRoot?.querySelector(name);
  61. return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0;
  62. }, VIEW_OF[p], { timeout: 15000 });
  63. await page.waitForTimeout(700);
  64. };
  65. /** 页面里执行:所有「测量」都放在这一段里,避免每次 evaluate 的字符串拼装差异 */
  66. const MEASURE = `(async () => {
  67. ${DEEP}
  68. const root = () => [document, ...[...document.querySelectorAll('*')].map((e) => e.shadowRoot).filter(Boolean)];
  69. const all = [];
  70. const collect = (r) => { for (const el of r.querySelectorAll('*')) { all.push(el); if (el.shadowRoot) collect(el.shadowRoot); } };
  71. collect(document);
  72. /* 可见:跨 shadow 链上无 display:none / visibility:hidden / opacity<.05;自身盒 ≥ 8×4 或带可见文字 */
  73. const vis = (el) => {
  74. const r = el.getBoundingClientRect();
  75. if (r.width < 8 || r.height < 4) return false;
  76. if (!shownByStyle(el)) return false;
  77. return true;
  78. };
  79. const hasText = (el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
  80. const PAGES = ${JSON.stringify(PAGES)};
  81. const page = location.pathname.replace(/\\/+$/, '') || '/';
  82. const out = { page, radii: {}, controls: [], thickBorders: [], blackText: [], fontSizes: {}, rows: [], blanks: [], clickables: 0, hoverFail: [], scrollH: 0, lastContentBottom: 0, filterLayout: null, wbCols: null };
  83. /* ---- 1. 圆角 ---- */
  84. const radKinds = {};
  85. const radWhere = {};
  86. for (const el of all) {
  87. if (!vis(el)) continue;
  88. const s = getComputedStyle(el);
  89. const parts = [s.borderTopLeftRadius, s.borderTopRightRadius, s.borderBottomLeftRadius, s.borderBottomRightRadius]
  90. .map((v) => v.split(' ')[0]);
  91. /* 量条 / 复选框 / 色点这类 ≤ 6px 的小件:形状由尺寸决定(4px 高的进度条圆角 2px = 胶囊),
  92. 不作为「圆角体系」计数;8px 以上才算容器圆角,必须落在 8 / 12 / 20。 */
  93. const box = el.getBoundingClientRect();
  94. const tiny = box.width <= 24 || box.height <= 6;
  95. if (tiny) continue;
  96. for (const p of parts) {
  97. const n = Math.round(parseFloat(p) || 0);
  98. const pct = p.includes('%');
  99. /* 百分比 = 圆形(头像 / 环),不算圆角档;9999px 以上记作胶囊 */
  100. if (pct) { radKinds.circle = (radKinds.circle || 0) + 1; continue; }
  101. if (n > 9000 || p.includes('calc') ) { /* 未解析 */ }
  102. const k = n;
  103. radKinds[k] = (radKinds[k] || 0) + 1;
  104. if (!radWhere[k]) radWhere[k] = elLabel(el).slice(0, 48);
  105. }
  106. }
  107. out.radii = radKinds; out.radiiWhere = radWhere;
  108. /* ---- 2. 原生控件 ---- */
  109. const ctrlSel = 'button, input, select, textarea';
  110. for (const el of all) {
  111. if (!el.matches?.(ctrlSel)) continue;
  112. if (!vis(el)) continue;
  113. const s = getComputedStyle(el);
  114. const tag = elLabel(el);
  115. const bg = s.backgroundColor;
  116. const bgImg = s.backgroundImage;
  117. const reasons = [];
  118. /* 文本框(input)在 Chrome 里 appearance 恒为 auto,但给了 rgba 半透明底色就是已覆盖默认外观;
  119. select / textarea 必须显式 appearance:none(select 默认画下拉箭头与系统边框)。 */
  120. const carriesOwnLook = bg !== 'rgba(0, 0, 0, 0)' || bgImg !== 'none' || (parseFloat(s.borderTopWidth) || 0) > 0;
  121. const needsAppearance = el.localName === 'select' || el.localName === 'textarea' || (el.localName === 'input' && (s.appearance !== 'none' && !carriesOwnLook));
  122. if (needsAppearance && el.localName !== 'button' && s.appearance !== 'none' && s.webkitAppearance !== 'none') reasons.push('appearance:' + s.appearance);
  123. /* 浏览器默认按钮外观:灰底 rgb(239,239,239|221,221,221|192,192,192) + 2px outset */
  124. const m = bg.match(/rgba?\\((\\d+), (\\d+), (\\d+)/);
  125. 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);
  126. const bw = parseFloat(s.borderTopWidth) || 0;
  127. if (el.localName === 'button' && bw >= 2 && /outset|inset|groove|ridge/.test(s.borderTopStyle)) reasons.push('浏览器浮雕边 ' + s.borderTopStyle + bw + 'px');
  128. if (/^(Times|serif)/.test(s.fontFamily) || s.fontFamily === 'none') reasons.push('字族泄漏:' + s.fontFamily.slice(0, 24));
  129. if ((el.localName === 'button' || el.localName === 'input') && s.borderRadius === '0px' && bw > 0) reasons.push('无圆角有边框');
  130. const r = el.getBoundingClientRect();
  131. 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) });
  132. }
  133. /* ---- 3. 描边 > 1px ---- */
  134. for (const el of all) {
  135. if (!vis(el)) continue;
  136. const s = getComputedStyle(el);
  137. const widths = [s.borderTopWidth, s.borderRightWidth, s.borderBottomWidth, s.borderLeftWidth]
  138. .map((v) => parseFloat(v) || 0).filter((w) => w > 1 && s.borderTopStyle !== 'none');
  139. const ow = parseFloat(s.outlineWidth) || 0;
  140. 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 });
  141. }
  142. /* ---- 4. 纯黑文字 ---- */
  143. for (const el of all) {
  144. if (!vis(el) || !hasText(el)) continue;
  145. /* 只量「直接承载文字」的元素,且字形真的落在里面(子元素另有颜色的不算) */
  146. const s = getComputedStyle(el);
  147. if (s.color === 'rgb(0, 0, 0)') out.blackText.push({ tag: elLabel(el).slice(0, 50), text: [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('').trim().slice(0, 20) });
  148. }
  149. /* ---- 5. 字号档 ---- */
  150. for (const el of all) {
  151. if (!vis(el) || !hasText(el)) continue;
  152. /* 表单控件的字族 / 字号按惯例是 1rem(16px);正文排版档不按控件算 */
  153. if (el.matches('button, input, select, textarea, option')) continue;
  154. const fs = Math.round(parseFloat(getComputedStyle(el).fontSize) * 100) / 100;
  155. out.fontSizes[fs] = (out.fontSizes[fs] || 0) + 1;
  156. }
  157. /* ---- 6. 同排卡高:grid / flex 容器里直接子项按 top 分行 ---- */
  158. const rowSeen = new Set();
  159. for (const el of all) {
  160. const s = getComputedStyle(el);
  161. if (!/grid|flex/.test(s.display)) continue;
  162. const kids = [...el.children].filter((k) => vis(k) && k.getBoundingClientRect().height > 40);
  163. if (kids.length < 2) continue;
  164. /* 整页布局容器(.shell:侧栏 + 主列)不是「同排卡片」:它的两个子项一高一低是页面结构本身 */
  165. if (el.matches('.shell, body, main, #view, .main')) continue;
  166. const tops = new Map();
  167. for (const k of kids) {
  168. const r = k.getBoundingClientRect();
  169. const key = Math.round(r.top / 4) * 4;
  170. if (!tops.has(key)) tops.set(key, []);
  171. tops.get(key).push(r.height);
  172. }
  173. for (const [top, hs] of tops) {
  174. if (hs.length < 2) continue;
  175. const spread = Math.round((Math.max(...hs) - Math.min(...hs)) * 10) / 10;
  176. const id = elLabel(el) + '@' + top;
  177. if (rowSeen.has(id)) continue; rowSeen.add(id);
  178. out.rows.push({ container: elLabel(el).slice(0, 40), top, n: hs.length, spread, heights: hs.map((h) => Math.round(h)) });
  179. }
  180. }
  181. /* ---- 7. 卡内连续空白:卡片内部「有内容的直接子块」之间的竖直间隙 ----
  182. 卡片 = .kf-card / .frame / kf-glass-card 里的 .wrap;取每张卡内容列的直接子项(渲染树里有文字或有尺寸的),
  183. 相邻子项底 → 下一项顶,> 24px 记一次(padding 之外)。 */
  184. const cardRoots = [];
  185. for (const el of all) {
  186. if (!el.matches) continue;
  187. if (el.matches('kf-glass-card')) cardRoots.push(el);
  188. }
  189. for (const card of cardRoots) {
  190. const face = card.shadowRoot?.querySelector('.face');
  191. if (!face || !vis(card)) continue;
  192. /* 卡里的内容列:slot 的第一个元素里的 .wrap 或直接子块 */
  193. const slot = face.querySelector('slot');
  194. const inst = slot ? slot.assignedElements()[0] : null;
  195. if (!inst) continue;
  196. const col = inst.querySelector(':scope > .wrap') || inst.querySelector(':scope > .rc') || inst;
  197. const kids = [...col.children].filter((k) => {
  198. const r = k.getBoundingClientRect();
  199. return r.height > 0 && getComputedStyle(k).position !== 'absolute';
  200. });
  201. for (let i = 1; i < kids.length; i++) {
  202. const a = kids[i - 1].getBoundingClientRect(), b = kids[i].getBoundingClientRect();
  203. const gap = Math.round(b.top - a.bottom);
  204. /* 兄弟间 gap(flex gap / margin)为 0 时不算空白:内容是紧挨着的 */
  205. const cs = getComputedStyle(col);
  206. const declared = parseFloat(cs.rowGap) || 0;
  207. const extra = Math.round(gap - declared);
  208. 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 });
  209. }
  210. }
  211. /* ---- 8. hover:穷举可点元素 ---- */
  212. const clickables = [];
  213. const seen = new Set();
  214. for (const el of all) {
  215. if (!el.matches?.('button, a[href], summary, [role="button"], [role="tab"], [role="menuitemcheckbox"], [role="menuitemradio"], [data-link]')) continue;
  216. if (!vis(el)) continue;
  217. const r = el.getBoundingClientRect();
  218. /* 只要中心点真的是它自己(不被遮挡),才 hover */
  219. const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
  220. /* 视口外(需要滚动才看得到)的先跳过;量之前画面已滚到顶 */
  221. if (cx < 0 || cy < 0 || cx > innerWidth || cy > innerHeight) continue;
  222. const key = elLabel(el) + '@' + Math.round(cx) + ',' + Math.round(cy);
  223. if (seen.has(key)) continue; seen.add(key);
  224. clickables.push({ el, cx, cy, sig: [getComputedStyle(el).backgroundColor, getComputedStyle(el).borderTopColor, getComputedStyle(el).backgroundImage, getComputedStyle(el).color, getComputedStyle(el).boxShadow, getComputedStyle(el).transform] });
  225. }
  226. out.clickables = clickables.length;
  227. /* 悬停必须用「真实指针」:合成 pointerover 事件不会让 :hover 命中(v11 实测 :hover 规则对合成事件无效),
  228. 所以逐个别移动鼠标,并在移动后把命中的元素收集回来由外圈逐一核对。 */
  229. /* clickables 本身不进返回值(元素引用不可序列化):坐标 + 签名 + 标签即可 */
  230. 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) }));
  231. /* ---- 9. 底部空白 ---- */
  232. out.scrollH = Math.round(document.documentElement.scrollHeight);
  233. out.viewportH = innerHeight;
  234. const mainEl = main();
  235. let bottom = 0;
  236. const scan = (root) => {
  237. for (const el of root.querySelectorAll('*')) {
  238. if (!vis(el)) continue;
  239. if (!hasText(el) && !el.matches('img, svg, kf-glass-card, kf-opportunity-card, kf-empty, canvas')) continue;
  240. /* 忽略整块容器的盒,只看「叶子」内容与其卡片 */
  241. const r = el.getBoundingClientRect();
  242. bottom = Math.max(bottom, r.bottom + scrollY);
  243. if (el.shadowRoot) scan(el.shadowRoot);
  244. }
  245. };
  246. scan(mainEl);
  247. out.lastContentBottom = Math.round(bottom);
  248. /* ---- 10. 筛选区(P1)与 11. 工作台两栏(P3) ---- */
  249. const q = (sel) => appR().querySelector('main#view')?.firstElementChild?.shadowRoot?.querySelector(sel) || null;
  250. if (page === '/pool') {
  251. const filters = q('.filters');
  252. const tb = q('.filters[data-toolbar]');
  253. const seg = filters ? filters.querySelector('.seg') : null;
  254. const search = filters ? filters.querySelector('.frow.toolbar input[type="search"]') : null;
  255. const chips = [...(q('.filters')?.querySelectorAll('.kf-chip') || [])];
  256. const chipSizes = chips.map((c) => { const r = c.getBoundingClientRect(); return [Math.round(r.height), getComputedStyle(c).fontSize]; });
  257. const segBtns = seg ? [...seg.querySelectorAll('button')] : [];
  258. const segGap = seg ? getComputedStyle(seg).gap : '';
  259. const segBorders = seg ? segBtns.map((b) => { const s = getComputedStyle(b); return s.borderRightWidth + ' ' + (s.borderRadius || ''); }) : [];
  260. out.filterLayout = {
  261. container: !!tb, filtersInsideToolbar: tb ? tb.contains(filters) : false,
  262. seg: !!seg, segGap, segBtnHeights: segBtns.map((b) => Math.round(b.getBoundingClientRect().height)),
  263. chipHeights: chipSizes.map((x) => x[0]), chipFonts: [...new Set(chipSizes.map((x) => x[1]))],
  264. searchH: search ? Math.round(search.getBoundingClientRect().height) : null,
  265. bottomDelta: search && filters ? Math.round(search.getBoundingClientRect().bottom - filters.getBoundingClientRect().bottom) : null,
  266. /* 底边对齐:搜索框底边 vs 它所在那一行(.frow.toolbar)里排序段与 chip 的底边 —— 三者差 ≤ 2px 即「同一底边」 */
  267. rowBottomDelta: (() => {
  268. if (!search) return null;
  269. const row = search.closest('.frow');
  270. const segEl = row?.querySelector('.seg');
  271. const sb = search.getBoundingClientRect().bottom;
  272. const others = [segEl, ...(row ? row.querySelectorAll('.kf-chip') : [])].filter(Boolean).map((e) => e.getBoundingClientRect().bottom);
  273. return others.length ? Math.round(Math.max(...others.map((o) => Math.abs(o - sb))) * 10) / 10 : null;
  274. })(),
  275. segBorder: segBorders,
  276. };
  277. }
  278. if (page === '/') {
  279. const g = q('.grid2');
  280. if (g) {
  281. const ks = [...g.children].map((k) => Math.round(k.getBoundingClientRect().height));
  282. out.wbCols = { heights: ks, diff: ks.length === 2 ? Math.abs(ks[0] - ks[1]) : null };
  283. /* 左卡(或「还没选」空态卡)内的连续空白 */
  284. const first = g.children[0];
  285. const card = first.shadowRoot?.querySelector('.frame');
  286. if (card) {
  287. let maxGap = 0;
  288. const face = card.querySelector('.face');
  289. const inst = face?.querySelector('slot')?.assignedElements()[0];
  290. if (inst) {
  291. const leaves = [...inst.querySelectorAll('*')].filter((e) => vis(e) && hasText(e));
  292. const rs = leaves.map((e) => e.getBoundingClientRect()).sort((a, b) => a.top - b.top);
  293. for (let i = 1; i < rs.length; i++) maxGap = Math.max(maxGap, Math.round(rs[i].top - rs[i - 1].bottom));
  294. }
  295. out.wbCols.leftMaxGap = maxGap;
  296. }
  297. }
  298. }
  299. return out;
  300. })()`;
  301. const results = [];
  302. for (const p of PAGES) {
  303. await gotoView(p);
  304. const r = await page.evaluate(MEASURE);
  305. /* 悬停:逐个用真实鼠标移到元素中心;鼠标离开后等过渡回落 */
  306. await page.mouse.move(2, 2);
  307. await page.waitForTimeout(60);
  308. for (const c of r.pendingHover || []) {
  309. await page.mouse.move(c.x, c.y, { steps: 1 });
  310. await page.waitForTimeout(240); /* 等过渡(最长 240ms)走完 */
  311. const after = await page.evaluate(([x, y]) => {
  312. const el = document.elementFromPoint(x, y);
  313. if (!el) return null;
  314. const s = getComputedStyle(el);
  315. return [s.backgroundColor, s.borderTopColor, s.backgroundImage, s.color, s.boxShadow, s.transform, s.cursor].join('|');
  316. }, [c.x, c.y]);
  317. if (after === c.sig) r.hoverFail.push({ tag: c.tag, text: c.text });
  318. /* 兜底核对:悬停点上的元素(含 shadow 链)应真的处于 :hover(CSS 被别的规则覆盖时这里能抓出来) */
  319. await page.mouse.move(2, 2);
  320. await page.waitForTimeout(40);
  321. }
  322. delete r.pendingHover;
  323. results.push(r);
  324. }
  325. /* ============ 汇总 ============ */
  326. const ALLOWED_RADII = new Set([0, 8, 12, 20, 9999]);
  327. const sum = (k) => results.reduce((n, r) => n + (r.radii[k] || 0), 0);
  328. const radiusKinds = {};
  329. for (const r of results) for (const [k, n] of Object.entries(r.radii)) radiusKinds[k] = (radiusKinds[k] || 0) + n;
  330. const badRadii = Object.entries(radiusKinds).filter(([k]) => !ALLOWED_RADII.has(Number(k)) && k !== 'circle');
  331. const controls = results.flatMap((r) => r.controls.map((c) => ({ ...c, page: r.page })));
  332. const nativeControls = controls.filter((c) => c.reasons.length);
  333. const thick = results.flatMap((r) => r.thickBorders.map((x) => ({ ...x, page: r.page })));
  334. /* 焦点环(outline 2px)只在聚焦时出现,还没聚焦时 computed outline 是 none / 宽度 0 */
  335. const thickReal = thick.filter((x) => x.w > 1);
  336. const bright = thick.filter((x) => x.ow > 1);
  337. const blacks = results.flatMap((r) => r.blackText.map((x) => ({ ...x, page: r.page })));
  338. const fonts = {};
  339. for (const r of results) for (const [k, n] of Object.entries(r.fontSizes)) fonts[k] = (fonts[k] || 0) + n;
  340. const fontKinds = Object.keys(fonts).map(Number).sort((a, b) => a - b);
  341. const rows = results.flatMap((r) => r.rows.map((x) => ({ ...x, page: r.page })));
  342. const badRows = rows.filter((x) => x.spread > 2);
  343. const blanks = results.flatMap((r) => r.blanks.map((x) => ({ ...x, page: r.page })));
  344. const hoverFails = results.flatMap((r) => r.hoverFail.map((x) => ({ ...x, page: r.page })));
  345. const clickables = results.reduce((n, r) => n + r.clickables, 0);
  346. const bottomGaps = results.map((r) => ({ page: r.page, gap: Math.max(0, r.scrollH - r.lastContentBottom), viewportGap: null }));
  347. const pool = results.find((r) => r.page === '/pool');
  348. const wb = results.find((r) => r.page === '/');
  349. const checks = [];
  350. const check = (name, ok, detail) => { checks.push({ name, ok, detail }); console.log(`\n${JUDGE ? (ok ? '✓' : '✗') : '·'} ${name}\n ${detail}`); };
  351. console.log(`\n===== v12 视觉验收 · tag=${TAG} · 页面 ${PAGES.join(' ')} =====`);
  352. check('圆角种类 ≤ 3 且 ∈ {8,12,20}(0 只允许分割线/非容器)', badRadii.length === 0 && Object.keys(radiusKinds).length <= 3,
  353. `容器圆角(≤6px 的量条/色点不计)${Object.keys(radiusKinds).length}:${Object.entries(radiusKinds).map(([k, n]) => `${k}px×${n}`).join('、')}` +
  354. (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(';')}` : ''));
  355. check('原生外观控件 = 0', nativeControls.length === 0,
  356. `控件总数 ${controls.length},原生外观 ${nativeControls.length}` +
  357. (nativeControls.length ? '\n ' + nativeControls.slice(0, 12).map((c) => `${c.page} ${c.tag} 「${c.text}」${c.reasons.join(',')}`).join('\n ') : ''));
  358. check('描边 > 1px = 0', thickReal.length === 0,
  359. `> 1px border ${thickReal.length}` + (thickReal.length ? ':' + thickReal.slice(0, 10).map((x) => `${x.page} ${x.tag} ${x.w}px`).join('、') : ''));
  360. check('纯黑 rgb(0,0,0) = 0', blacks.length === 0,
  361. `${blacks.length}` + (blacks.length ? ':' + blacks.slice(0, 10).map((x) => `${x.page} ${x.tag}「${x.text}」`).join('、') : ''));
  362. check('字号档 ≤ 5 且无 13.3333px', fontKinds.length <= 5 && !fontKinds.some((f) => Math.abs(f - 13.3333) < 0.01),
  363. `${fontKinds.length} 档:${fontKinds.map((f) => `${f}×${fonts[f]}`).join(' / ')}`);
  364. check('同排卡片高度差 ≤ 2px', badRows.length === 0,
  365. `测了 ${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 ') : ''));
  366. check('卡内连续空白 ≤ 24px', blanks.length === 0,
  367. `超 24px 的位置 ${blanks.length}` + (blanks.length ? '\n ' + blanks.slice(0, 8).map((x) => `${x.page} ${x.between} +${x.extra}px`).join('\n ') : ''));
  368. check('可点元素 hover 反馈', hoverFails.length === 0,
  369. `可点 ${clickables},无反馈 ${hoverFails.length}` + (hoverFails.length ? '\n ' + hoverFails.slice(0, 12).map((x) => `${x.page} ${x.tag}「${x.text}」`).join('\n ') : ''));
  370. check('P1 筛选栏:一个容器 + 分段排序 + 与搜索框等高底对齐', (() => {
  371. const f = pool?.filterLayout;
  372. if (!f) return false;
  373. /* 分段控件:整块(段与段之间无缝)+ 五个按钮等高。段间分隔是 1px 边框,不是 gap —— 所以 gap 可以是 0 或 normal */
  374. const segOk = f.seg && (f.segGap === '0px' || f.segGap === 'normal') && new Set(f.segBtnHeights).size === 1 && f.segBtnHeights[0] >= 28 && f.segBtnHeights[0] <= 32;
  375. const chipOk = f.chipHeights.every((h) => h >= 28 && h <= 32) && f.chipFonts.length === 1 && f.chipFonts[0] === '12px';
  376. /* 底边对齐:搜索框与筛选容器里最后一个 chip 行的底边(不是容器 padding 底) */
  377. return f.container && f.filtersInsideToolbar && segOk && chipOk && f.searchH !== null && f.rowBottomDelta !== null && f.rowBottomDelta <= 2;
  378. })(), 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');
  379. check('P3 工作台两栏等高(差 ≤ 2px)且左卡无 > 32px 连续空白', Boolean(wb?.wbCols) && wb.wbCols.diff !== null && wb.wbCols.diff <= 2 && wb.wbCols.leftMaxGap <= 32,
  380. wb?.wbCols ? `两栏高 [${wb.wbCols.heights}],差 ${wb.wbCols.diff}px,左卡最大连续空白 ${wb.wbCols.leftMaxGap}px` : '未量 /');
  381. /* ============ 深滚动测量(同一页,滚到底再量):视口外可点元素 hover + 卡片底线对齐 ============ */
  382. const deep = [];
  383. for (const p of PAGES) {
  384. await gotoView(p);
  385. const info = await page.evaluate(() => ({ h: document.documentElement.scrollHeight, vh: innerHeight }));
  386. await page.mouse.move(2, 2);
  387. const stops = [];
  388. for (let y = 0; y < info.h; y += Math.floor(info.vh * 0.8)) stops.push(y);
  389. const seen = new Set();
  390. const fail = [];
  391. let total = 0;
  392. const baseline = [];
  393. for (const y of stops) {
  394. await page.evaluate((yy) => window.scrollTo(0, yy), y);
  395. await page.waitForTimeout(120);
  396. const batch = await page.evaluate(([yy, vh]) => {
  397. const all = [];
  398. const collect = (r) => { for (const el of r.querySelectorAll('*')) { all.push(el); if (el.shadowRoot) collect(el.shadowRoot); } };
  399. collect(document);
  400. const shown = (el) => { for (let q = el; q; q = q.parentElement || q.getRootNode()?.host) { const s = getComputedStyle(q); if (s.display === 'none' || s.visibility === 'hidden' || Number(s.opacity) < 0.05) return false; } return true; };
  401. const lbl = (el) => el.localName + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/ +/).join('.') : '');
  402. const out = [];
  403. for (const el of all) {
  404. if (!el.matches?.('button, a[href], summary, [role="button"], [role="menuitemcheckbox"], [role="menuitemradio"]')) continue;
  405. const r = el.getBoundingClientRect();
  406. if (r.width < 8 || r.height < 4 || !shown(el)) continue;
  407. const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
  408. if (cy < 0 || cy > vh || cx < 0 || cx > innerWidth) continue;
  409. /* <details> 收起时里面的行:box 有高度但用户看不到、指针到不了 —— 跳过(不是「没有反馈」) */
  410. const det = el.closest('details');
  411. if (det && !det.open && el !== det.firstElementChild) continue;
  412. if (r.height < 28 && el.parentElement && el.parentElement.classList.contains('mor')) continue;
  413. const s = getComputedStyle(el);
  414. /* 用「元素自己的中心点」而不是估计坐标:表格单元格里的按钮在右侧,元素中心才是它自己 */
  415. out.push({ key: cx + ',' + cy, x: cx, y: cy,
  416. tag: lbl(el).slice(0, 40), text: (el.textContent || '').trim().slice(0, 12),
  417. sig: [s.backgroundColor, s.borderTopColor, s.backgroundImage, s.color, s.boxShadow, s.transform, s.cursor].join('|') });
  418. }
  419. return out;
  420. }, [y, info.vh]);
  421. /* 先全部「先悬停、后核对」再离开 —— 挨在一起的兄弟元素(分段控件 / 一行里的 chip)在移开再移入之间
  422. 指针可能恰好停在两者接缝上,:hover 归谁取决于逐像素命中,不能当成「没有反馈」 */
  423. const probes = batch.filter((x) => !seen.has(x.key));
  424. for (const x of probes) seen.add(x.key);
  425. total += probes.length;
  426. for (const b of batch) {
  427. if (!probes.includes(b)) continue;
  428. await page.mouse.move(b.x, b.y);
  429. await page.waitForTimeout(220);
  430. const after = await page.evaluate(([x, yy]) => {
  431. const el = document.elementFromPoint(x, yy);
  432. if (!el) return null;
  433. const s = getComputedStyle(el);
  434. return [s.backgroundColor, s.borderTopColor, s.backgroundImage, s.color, s.boxShadow, s.transform, s.cursor].join('|');
  435. }, [b.x, b.y]);
  436. if (after === b.sig) fail.push({ tag: b.tag, text: b.text });
  437. }
  438. await page.mouse.move(2, 2);
  439. }
  440. /* 卡片底线:机会卡里的「来源行 .src」与「操作条 .acts」的页面坐标底边(滚动位置无关,只量一次) */
  441. {
  442. const rows = await page.evaluate(() => {
  443. const cards = [];
  444. const collect = (r) => { for (const el of r.querySelectorAll('*')) { if (el.matches?.('kf-opportunity-card')) cards.push(el); if (el.shadowRoot) collect(el.shadowRoot); } };
  445. collect(document);
  446. return cards.map((c) => {
  447. /* .src / .acts 在机会卡自己的 shadow root 里(卡片外面还套了一层 kf-glass-card) */
  448. const src = c.shadowRoot.querySelector('.src'), acts = c.shadowRoot.querySelector('.acts');
  449. const r = c.getBoundingClientRect();
  450. /* 行键用「行顶 + 行高」:同一排卡的 top 相同、height 相同,换一个滚动位置也不会串到别的行 */
  451. return { top: Math.round((r.top + scrollY) / 4) * 4, h: Math.round(r.height), src: src ? src.getBoundingClientRect().bottom + scrollY : null, acts: acts ? acts.getBoundingClientRect().bottom + scrollY : null };
  452. });
  453. });
  454. baseline.push(...rows);
  455. }
  456. /* 滚到底:视口底部到最后一个可见内容(文字 / 卡片 / 页脚)的距离 */
  457. await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
  458. await page.waitForTimeout(150);
  459. const viewportGap = await page.evaluate(() => {
  460. /* 页面底部留白 = 视口底(innerHeight)− 主列里最后一个**画出来的东西**的底边(文字节点 / 卡片 / 按钮)。
  461. 纯布局容器(main / #view / .shell)的盒子不算内容 —— 它们的盒子本来就到视口底。 */
  462. const main = document.querySelector('kf-app').shadowRoot.querySelector('main#view');
  463. if (!main) return -1;
  464. let bottom = 0;
  465. const walk = (r) => {
  466. for (const n of r.querySelectorAll('*')) {
  467. if (n.localName === 'script' || n.localName === 'style') continue;
  468. const b = n.getBoundingClientRect();
  469. if (b.height < 1 || b.bottom <= 0) continue;
  470. const own = [...n.childNodes].some((c) => c.nodeType === 3 && c.textContent.trim());
  471. if (own) bottom = Math.max(bottom, Math.min(b.bottom, innerHeight));
  472. if (n.shadowRoot) walk(n.shadowRoot);
  473. }
  474. };
  475. walk(main);
  476. return Math.max(0, Math.round(innerHeight - bottom));
  477. });
  478. const byRow = new Map();
  479. for (const c of baseline) { const k = c.top + ':' + c.h; if (!byRow.has(k)) byRow.set(k, []); byRow.get(k).push(c); }
  480. const misaligned = [];
  481. for (const cs of byRow.values()) {
  482. if (cs.length < 2) continue;
  483. for (const key of ['src', 'acts']) {
  484. const vals = cs.map((c) => c[key]).filter((v) => v != null);
  485. if (vals.length < 2) continue;
  486. const spread = Math.round((Math.max(...vals) - Math.min(...vals)) * 10) / 10;
  487. if (spread > 2) misaligned.push({ key, n: vals.length, spread });
  488. }
  489. }
  490. deep.push({ page: p, viewportGap, total, fail, rowCount: [...byRow.values()].filter((c) => c.length > 1).length, misaligned });
  491. }
  492. const deepFail = deep.reduce((n, d) => n + d.fail.length, 0);
  493. const mis = deep.flatMap((d) => d.misaligned.map((m) => ({ ...m, page: d.page })));
  494. check('滚动后视口外可点元素 hover 反馈', deepFail === 0,
  495. deep.map((d) => `${d.page} 测 ${d.total} 个,无反馈 ${d.fail.length}${d.fail.length ? '(' + d.fail.slice(0, 4).map((f) => f.tag + '「' + f.text + '」').join('、') + ')' : ''}`).join(';'));
  496. check('P2 同排卡「来源行 / 操作条」底边差 ≤ 2px', mis.length === 0,
  497. mis.length ? mis.slice(0, 8).map((m) => `${m.page} ${m.key} ${m.spread}px`).join('、') : `测了 ${deep.reduce((n, d) => n + d.rowCount, 0)} 个多卡行:全部 ≤ 2px`);
  498. for (const b of bottomGaps) b.viewportGap = deep.find((d) => d.page === b.page)?.viewportGap ?? 0;
  499. /* 页面底部空白 = 视口下的空白 OR 页面比内容多出来的空白,两者必须同时 ≤ 120px:
  500. - 短页(/):滚不动,视口底部到底栏或最后内容的距离;
  501. - 长页:滚到底,视口底部到最后一块内容的距离(卷到底之后看到的空白)。
  502. 任务书的「页面总高 - 栅格底」这种量法不对:内容是长页时它就是整屏高度,跟留白无关。 */
  503. check('页面底部空白 ≤ 120px(视口下 + 滚到底两处都量)', bottomGaps.every((b) => b.viewportGap >= 0 && Math.min(b.gap, b.viewportGap) <= 120),
  504. bottomGaps.map((b) => `${b.page} 滚动余量 ${b.gap}px / 滚到底视口留白 ${b.viewportGap}px`).join(';'));
  505. if (errors.length) console.log(`\n页面错误 ${errors.length}:\n ${errors.slice(0, 10).join('\n ')}`);
  506. const fail = checks.filter((c) => !c.ok);
  507. console.log(`\n${JUDGE ? '判分' : '基线(不判分)'}:${checks.length - fail.length}/${checks.length} 达标${errors.length ? `,页面错误 ${errors.length}` : ''}`);
  508. if (JSON_OUT) {
  509. mkdirSync(dirname(join(ROOT, JSON_OUT)), { recursive: true });
  510. writeFileSync(join(ROOT, JSON_OUT), JSON.stringify({ tag: TAG, at: new Date().toISOString(), pages: results, checks, errors }, null, 2));
  511. }
  512. await browser.close();
  513. srv.kill();
  514. process.exit(JUDGE && (fail.length || errors.length) ? 1 : 0);