#!/usr/bin/env node /** * scripts/verify-card.mjs —— 本轮(v7「卡片 / 视觉优先」)验收的实测脚本,只量不猜。 * * 量五类东西: * 1. 字数:页面模板里的中文字符(去注释),对照任务书上限 * 2. 句式:被点名删除的解释性文案是否零命中(去注释后 grep) * 3. 玻璃:卡片 computed style(background-image / background-color / backdrop-filter / 内衬), * 背景光场是否真的画出来(关掉卡片前后、关掉光场前后的像素差) * 4. 对比度:按 WCAG 公式,逐个文字节点量「前景 vs 实际合成底色」的比值,取最小值 * 5. 截图 + 交互(维度叠加 / chips / 排序 / 视图 / 分页 / 刷新保留)实测 * * 用法:node scripts/verify-card.mjs [--url http://127.0.0.1:4173/] [--shots docs/screenshots/v7] */ import { readFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; 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 STATIC_ONLY = argv.includes('--static-only'); const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v7')); mkdirSync(SHOTS, { recursive: true }); let fails = 0; const line = (ok, name, detail = '') => { console.log(`${ok ? '✓' : '✗'} ${name}${detail ? `\n ${detail}` : ''}`); if (!ok) fails++; }; /* ---------------- 1. 字数 + 2. 句式(静态) ---------------- */ const LIMITS = [ ['src/pages/view-report-type.js', '类型页', 300], ['src/pages/view-workbench.js', '工作台', 350], ['src/pages/view-stages.js', '阶段页', 300], ['src/pages/view-report.js', '报告详情', 250], ['src/pages/view-reports.js', '报告中心', 200], ]; const strip = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/[^\n]*/g, '$1'); console.log('—— 字数(去注释后的中文字符)——'); for (const [f, name, cap] of LIMITS) { const n = (strip(readFileSync(join(ROOT, f), 'utf8')).match(/[一-鿿]/g) || []).length; line(n <= cap, `${name} ≤ ${cap}`, `实测 ${n} 个中文字符(${f})`); } const BANNED = ['这页在回答', '换一个阶段', '实心竖线', '淡色底线', '按对位对象横向排开', '近 7 天新增', '人均', '只有登记信息', '手头事实', '读图:', '暂无数据', '这阶段在回答', '这章在回答', '本类型全部条目']; const walk = (dir, out = []) => { if (!existsSync(dir)) return out; for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) walk(p, out); else if (p.endsWith('.js')) out.push(p); } return out; }; const uiFiles = ['src/pages', 'src/components', 'src/layout'].flatMap((d) => walk(join(ROOT, d))); const hits = []; for (const f of uiFiles) { const body = strip(readFileSync(f, 'utf8')); for (const b of BANNED) if (body.includes(b)) hits.push(`${f.replace(ROOT + '/', '')}: 「${b}」`); } line(hits.length === 0, `解释性句式零命中(${BANNED.length} 条句式 × ${uiFiles.length} 个界面文件)`, hits.join(';') || `全部 0 命中`); /* ---------------- 浏览器 ---------------- */ async function loadPlaywright() { for (const c of [join(ROOT, 'node_modules/playwright-core'), '/opt/data/work/kangshifu/testing/node_modules/playwright-core']) { if (existsSync(c)) return (await import(join(c, 'index.js'))).default ?? (await import(`${c}/index.js`)); } return import('playwright'); } let server = null; let base = arg('--url', ''); if (!base) { const { writeConfig } = await import('./config.mjs'); const { prefix } = writeConfig({ env: 'local' }); const port = Number(process.env.VERIFY_PORT || 4176); server = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' }); await new Promise((r) => setTimeout(r, 700)); base = `http://127.0.0.1:${port}${prefix}`; } process.on('exit', () => server?.kill()); const pw = await loadPlaywright(); const browser = await pw.chromium.launch({ executablePath: process.env.CHROME_PATH || process.env.AGENT_BROWSER_EXECUTABLE_PATH || undefined, args: ['--no-sandbox', '--disable-dev-shm-usage'] }); const ctx = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 }); const page = await ctx.newPage(); const url = (p) => new URL(p, base).href; const go = async (p, ms = 1200) => { await page.goto(url(p), { waitUntil: 'networkidle' }); await page.waitForTimeout(ms); }; const shot = (name, opts = {}) => page.screenshot({ path: join(SHOTS, name), ...opts }); /* 读页面里某处像素的平均 RGB(把整页截图交给页面自己的 canvas 解码) */ async function avgRGB(region) { const b64 = (await page.screenshot({ clip: region })).toString('base64'); return page.evaluate(async (b64) => { const img = new Image(); img.src = 'data:image/png;base64,' + b64; await img.decode(); const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; const g = c.getContext('2d'); g.drawImage(img, 0, 0); const d = g.getImageData(0, 0, c.width, c.height).data; let r = 0, gg = 0, b = 0, n = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; gg += d[i + 1]; b += d[i + 2]; n++; } return [Math.round(r / n), Math.round(gg / n), Math.round(b / n)]; }, b64); } if (STATIC_ONLY) { console.log(`\n${fails === 0 ? 'ALL OK' : `FAILED ${fails}`} · 静态项(--static-only)`); process.exit(fails ? 1 : 0); } /* ---------------- 3. 玻璃 + 光场 ---------------- */ await go('/reports/competitor', 1400); const glass = await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); const host = v.shadowRoot.querySelector('.cards kf-glass-card'); const frame = host.shadowRoot.querySelector('.frame'); const face = host.shadowRoot.querySelector('.face'); const cs = getComputedStyle(frame); const app = document.querySelector('kf-app'); const before = getComputedStyle(app, '::before'); return { frameBgImage: cs.backgroundImage, frameBgColor: cs.backgroundColor, backdrop: cs.backdropFilter || cs.webkitBackdropFilter, borderTop: `${cs.borderTopWidth} ${cs.borderTopStyle} ${cs.borderTopColor}`, borderEdge: (cs.backgroundImage.match(/linear-gradient\(135deg[^)]*\)[^,]*\)?/g) || [''])[0], shadow: cs.boxShadow, faceBg: getComputedStyle(face).backgroundColor, sceneBefore: { z: before.zIndex, filter: before.filter, bg: before.backgroundImage.slice(0, 160), content: before.content }, ambientInDoc: Boolean(document.querySelector('.kf-ambient')), ambientInApp: Boolean(app.shadowRoot.querySelector('.kf-ambient')), }; }); const whiteAlpha = (glass.frameBgImage.match(/rgba\(255, 255, 255, ([\d.]+)\)/g) || []).map((s) => Number(s.match(/([\d.]+)\)$/)[1])); line(/rgba\(255, 255, 255, 0\.5\d/.test(glass.frameBgImage) && glass.frameBgColor === 'rgba(0, 0, 0, 0)', '16a 卡片白面实测半透明(computed background)', `background-image 里的白层 = ${JSON.stringify(whiteAlpha)};background-color = ${glass.frameBgColor}`); line(/blur\(24px\) saturate\(1\.?8\)/.test(glass.backdrop), '16b backdrop-filter 真的生效', `computed backdrop-filter = ${glass.backdrop}`); line(/linear-gradient\(135deg/.test(glass.borderEdge), '11 发丝边是 135° 渐变(不是 1px 灰线)', `border = ${glass.borderTop};边层 = ${glass.borderEdge.slice(0, 110)}`); line(/inset/.test(glass.shadow) && glass.shadow.includes('0px 1px 0px'), '内高光在', `box-shadow = ${glass.shadow.slice(0, 120)}`); line(glass.sceneBefore.content === '""' && glass.sceneBefore.z === '0' && /blur\(\d+px\)/.test(glass.sceneBefore.filter), '15a 背景光场是应用根上的正层级伪元素(DOM 里能查到)', `kf-app::before → z-index=${glass.sceneBefore.z},filter=${glass.sceneBefore.filter},content=${glass.sceneBefore.content}`); line(!glass.ambientInApp, '15b 旧负层级光场节点已移除(根因:负 z-index 层在长页面里整组不绘制)', `kf-app shadow 内 .kf-ambient 存在?${glass.ambientInApp};document 内?${glass.ambientInDoc}`); /* 光场可见性:同一区域「光场开 / 关」两帧的像素差;「卡片开 / 关」两帧的像素差 */ const region = await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type'); const host = v.shadowRoot.querySelector('.cards kf-glass-card'); host.scrollIntoView({ block: 'center' }); const r = host.getBoundingClientRect(); return { x: Math.round(r.x + 40), y: Math.round(r.y + 40), width: Math.round(r.width - 60), height: 50 }; }); const withScene = await avgRGB(region); await page.screenshot({ path: join(SHOTS, '15-bg-sheen-on.png') }); const bgRegion = { x: 280, y: 820, width: 760, height: 120 }; await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); v.shadowRoot.querySelector('.cards').style.visibility = 'hidden'; }); await page.waitForTimeout(150); const bgOn = await avgRGB(bgRegion); await page.evaluate(() => { const s = document.createElement('style'); s.textContent = 'kf-app{--x:0} kf-app::before,kf-app::after{display:none!important}'; s.id = 'kf-scene-off'; document.head.appendChild(s); }); await page.waitForTimeout(150); const noScene = await avgRGB(region); const bgOff = await avgRGB(bgRegion); await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); v.shadowRoot.querySelector('.cards').style.visibility = ''; }); await page.screenshot({ path: join(SHOTS, '15-bg-sheen-off.png') }); await page.evaluate(() => document.getElementById('kf-scene-off').remove()); const diff = Math.max(...bgOn.map((v, i) => Math.abs(v - bgOff[i]))); const cardDiff = Math.max(...withScene.map((v, i) => Math.abs(v - noScene[i]))); line(cardDiff >= 10, '15c 光场可见(同位置开 / 关光场的像素差)', `卡片区域(玻璃背后)开 / 关光场平均色差 = ${cardDiff};空白背景区 = ${diff};开 = rgb(${bgOn}),关 = rgb(${bgOff})`); /* 卡片关掉后,背景色斑还在(截一张无卡片的背景) */ await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); v.shadowRoot.querySelector('.cards').style.visibility = 'hidden'; }); await page.waitForTimeout(150); await shot('15-bg-cards-hidden.png'); await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); v.shadowRoot.querySelector('.cards').style.visibility = ''; }); await page.screenshot({ path: join(SHOTS, '01-type-competitor-cards.png') }); /* ---------------- 4. 对比度(WCAG,逐个文字节点) ---------------- */ await page.waitForTimeout(600); // 等卡片组件把插槽内容分配完(.rc 在 light DOM) const contrast = await page.evaluate(async () => { const lum = ([r, g, b]) => { const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; const parse = (s) => { const m = String(s).match(/rgba?\(([^)]+)\)/); if (!m) return [0, 0, 0, 1]; const p = m[1].split(/[,\s/]+/).filter(Boolean).map(Number); return [p[0], p[1], p[2], p.length > 3 ? p[3] : 1]; }; // 从文字节点往上,把每一层半透明背景按 alpha 叠起来 → 实际看到的底色 const bgUnder = (el) => { const layers = []; for (let n = el; n && n !== document.documentElement; n = n.parentElement || (n.getRootNode().host ?? null)) { const cs = getComputedStyle(n); const c = parse(cs.backgroundColor); layers.push([c, cs.backgroundImage]); if (c[3] === 1) break; } let base = [230, 234, 246]; const bodyBg = parse(getComputedStyle(document.body).backgroundColor); if (bodyBg[3] > 0) base = bodyBg.slice(0, 3); for (const [c] of layers.reverse()) { const a = c[3]; base = [c[0] * a + base[0] * (1 - a), c[1] * a + base[1] * (1 - a), c[2] * a + base[2] * (1 - a)]; } return base; }; const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); const out = []; for (const host of [...v.shadowRoot.querySelectorAll('.cards kf-glass-card')].filter((h) => h.querySelector('.rc'))) { const card = host.querySelector('.rc'); // 卡片内容在宿主元素的 light DOM 里���插槽分配) if (!card) continue; for (const el of card.querySelectorAll('h3, .lead, .k, .v, .v.dim, .src, .src .who, .src .it, .src .tm, .badge, .fresh, .seg + *')) { const t = [...el.childNodes].filter((n) => n.nodeType === 3 || (n.nodeType === 1 && n.textContent.trim() && !n.matches('svg'))).map((n) => n.textContent).join('').trim(); if (!t) continue; const cs = getComputedStyle(el); const fg = parse(cs.color); const bg = bgUnder(el); // 前景也按自身 alpha 叠到底色上(文字没有透明度时 fg[3]=1,不变) const a = fg[3]; const comp = [fg[0] * a + bg[0] * (1 - a), fg[1] * a + bg[1] * (1 - a), fg[2] * a + bg[2] * (1 - a)]; const L1 = lum(comp), L2 = lum(bg); const ratio = (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05); break; // 每张卡只量一处标题 + 后面统一量 } // 量这张卡里每个有文字的节点,取最小的 for (const el of card.querySelectorAll('h3, .lead, .k, .v, .src, .src .who, .src .it, .src .tm, .badge, .fresh')) { const t = el.textContent.trim(); if (!t) continue; const cs = getComputedStyle(el); const fg = parse(cs.color); const bg = bgUnder(el); const L1 = lum(fg), L2 = lum(bg); const ratio = (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05); out.push({ text: t.slice(0, 14), cls: el.className || el.localName, fg: cs.color, bg: bg.map((x) => Math.round(x)), ratio: Math.round(ratio * 100) / 100, size: parseFloat(cs.fontSize) }); } } // 徽标是白字压色块:单独算(前景白 vs 徽标自己的背景) const badges = []; for (const b of v.shadowRoot.querySelectorAll('.cards .badge')) { const cs = getComputedStyle(b); const fg = parse(cs.color); const bg = parse(cs.backgroundColor); const L1 = lum(fg), L2 = lum(bg.slice(0, 3)); badges.push({ text: b.textContent.trim(), fg: cs.color, bg: cs.backgroundColor, ratio: Math.round(((Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05)) * 100) / 100 }); } return { nodes: out, badges }; }).catch((e) => ({ error: String(e) })); if (contrast.error || !contrast.nodes?.length) line(false, '17 对比度测量失败', contrast.error || '没有量到文字节点'); else { const small = contrast.nodes.filter((n) => n.ratio < 4.5); const big = contrast.nodes.filter((n) => n.size >= 18.66 && n.ratio < 3); const min = contrast.nodes.reduce((m, n) => (n.ratio < m.ratio ? n : m), contrast.nodes[0]); const minBadge = contrast.badges.reduce((m, n) => (n.ratio < m.ratio ? n : m), contrast.badges[0]); line(small.length === 0 && big.length === 0, '17 文字对比度 ≥ 4.5:1(正文)/ 3:1(大字号)', `${contrast.nodes.length} 个文字节点 + ${contrast.badges.length} 个徽标;最低 = ${min.ratio}:1(「${min.text}」 ${min.fg} on rgb(${min.bg}));徽标最低 = ${minBadge.ratio}:1(「${minBadge.text}」);低于 4.5 的 ${small.length} 个`); } /* ---------------- 8. 缩略图在(缩略图画在卡片内部,需要翻到带 report 的卡) ---------------- */ await go('/reports/competitor?full=1', 1200); await page.waitForTimeout(600); // 等卡片组件把插槽内容分配完(.rc 在 light DOM) const thumbInfo = await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type'); const t = v.shadowRoot.querySelector('.cards .thumb'); return t ? { w: Math.round(t.getBoundingClientRect().width), bars: t.querySelectorAll('i').length, dots: t.querySelectorAll('.dots b').length } : null; }); line(Boolean(thumbInfo), '8 有报告结构的卡显示缩略图(章节条 + 结论点)', thumbInfo ? `${thumbInfo.w}px 宽,${thumbInfo.bars} 根章节条,${thumbInfo.dots} 个结论点` : 'competitor?full=1 下没有带 report 的卡'); await page.evaluate(() => { const card = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type').shadowRoot.querySelector('.cards .thumb')?.closest('kf-glass-card'); card?.scrollIntoView({ block: 'center' }); }); await page.waitForTimeout(200); await page.screenshot({ path: join(SHOTS, '08-card-with-thumbnail.png') }); /* ---------------- 6. 来源四要素 ---------------- */ await go('/reports/competitor', 1200); const src = await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); const row = v.shadowRoot.querySelector('.cards .src'); return { text: row.textContent.replace(/\s+/g, ' ').trim(), icons: row.querySelectorAll('svg').length, inBottom: Math.round(row.getBoundingClientRect().bottom - row.closest('.rc').getBoundingClientRect().bottom) }; }); line(src.icons >= 3 && src.text.includes('周涛'), '6 卡片底部来源四要素(图标打头)', `读出:「${src.text}」;图标 ${src.icons} 个;距卡片底 ${src.inBottom}px`); /* ---------------- 9–14 交互 ---------------- */ const V = () => page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type') || document.querySelector('kf-app').shadowRoot.querySelector('view-reports'); const root = v.shadowRoot.querySelector('view-report-type') || v; const s = root.shadowRoot; return { n: s.querySelectorAll('.cards kf-glass-card, .row').length, count: s.querySelector('[data-count]')?.textContent.replace(/\s+/g, ' ').trim(), url: decodeURIComponent(location.search), order: [...s.querySelectorAll('.cards .rc h3, .row .t')].slice(0, 3).map((x) => x.textContent.trim()) }; }); const click = (sel) => page.evaluate((sel) => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view'); const root = v.querySelector('view-reports')?.shadowRoot.querySelector('view-report-type') || v.querySelector('view-report-type'); const el = root.shadowRoot.querySelector(sel); if (!el) throw new Error('missing ' + sel); el.click(); }, sel).then(() => page.waitForTimeout(300)); await go('/reports/competitor', 1200); const dims = await page.evaluate(() => [...document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelectorAll('.dd')].map((d) => `${d.dataset.dim}(${d.querySelectorAll('.opt').length})`)); line(dims.length >= 6, '9a 分类维度数', dims.join(' ')); const counts0 = await V(); await click('details[data-dim="stage"] summary'); await page.waitForTimeout(80); await click('details[data-dim="stage"] [data-opt="stage:S3"]'); const c1 = await V(); await click('details[data-dim="ev"] summary'); await page.waitForTimeout(80); await click('details[data-dim="ev"] [data-opt="ev:中"]'); const c2 = await V(); await click('details[data-dim="cat"] summary'); await page.waitForTimeout(80); await click('details[data-dim="cat"] [data-opt="cat:拌面"]'); const c3 = await V(); await click('details[data-dim="who"] summary'); await page.waitForTimeout(80); await click('details[data-dim="who"] [data-opt="who:陈静"]'); const c4 = await V(); await click('details[data-dim="m"] summary'); await page.waitForTimeout(80); await click('details[data-dim="m"] [data-opt="m:skill"]'); const c5 = await V(); let c6 = c5; if (await page.evaluate(() => document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type').shadowRoot.querySelector('[data-opt="pri:A"]'))) { await click('details[data-dim="pri"] summary'); await page.waitForTimeout(80); await click('details[data-dim="pri"] [data-opt="pri:A"]'); c6 = await V(); } await click('[data-clear]'); for (const [dim, v] of [['stage', 'S3'], ['ev', '中'], ['cat', '拌面'], ['who', '陈静'], ['m', 'skill']]) { await click(`details[data-dim="${dim}"] summary`); await page.waitForTimeout(80); await click(`details[data-dim="${dim}"] [data-opt="${dim}:${v}"]`); } await click('details[data-dim="pri"] summary'); await page.waitForTimeout(200); await page.screenshot({ path: join(SHOTS, '09-six-dimensions.png') }); await page.mouse.click(5, 5); line([c1, c2, c3, c4, c5, c6].every((x) => x.n < counts0.n), '9b 六个维度逐个叠加,每叠加一层数量都变', `全部 ${counts0.count} → S3 ${c1.count} → +证据中 ${c2.count} → +拌面 ${c3.count} → +陈静 ${c4.count} → +Skill ${c5.count} → +A级 ${c6.count};URL = ${c6.url}`); await click('[data-clear]'); const chips = await page.evaluate(() => [...document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelectorAll('[data-chip]')].map((c) => c.dataset.chip)); const chipResults = []; for (const k of ['hasEv', 'mine', 'full', 'recent']) { await click(`[data-chip="${k}"]`); chipResults.push([k, await V()]); await click(`[data-chip="${k}"]`); } await click('[data-chip="full"]'); await page.screenshot({ path: join(SHOTS, '10-chips-active.png') }); await click('[data-clear]'); line(chips.length >= 4, '10 快捷 chips + 清除按钮', `${chips.join(' / ')} + [data-clear];逐个点击后计数:${chipResults.map(([k, x]) => `${k} ${x.count}`).join(',')}(mine 未选身份时不生效)`); await click('[data-sort="score"]'); const s1 = await V(); await click('[data-sort="new"]'); const s2 = await V(); await click('[data-sort="ev"]'); const s3 = await V(); line(new Set([s1.order.join('|'), s2.order.join('|'), s3.order.join('|')]).size === 3, '11 三种排序,顺序真的不同', `评分:${s1.order.join(' / ')}|最新:${s2.order.join(' / ')}|证据:${s3.order.join(' / ')}`); await click('[data-view="list"]'); const list = await page.evaluate(() => { const s = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot; const r = s.querySelector('.row'); return { rows: s.querySelectorAll('.row').length, h: r && Math.round(r.getBoundingClientRect().height) }; }); await page.screenshot({ path: join(SHOTS, '12-compact-list.png') }); await click('[data-view="cards"]'); line(Boolean(list.rows), '12 卡片流 / 紧凑列表可切换', `列表模式一行高 ${list.h}px,一屏可放 ~${list.h ? Math.floor(820 / list.h) : 0} 条;卡片模式一屏 3 张(大卡)`); await page.screenshot({ path: join(SHOTS, '12-card-flow.png') }); await click('[data-page="2"]'); const pg = await page.evaluate(() => { const s = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot; return { pager: s.querySelector('[data-pager] .sz')?.textContent.trim(), pages: s.querySelectorAll('[data-pager] button[data-page]').length - 2 }; }); await page.screenshot({ path: join(SHOTS, '13-pagination.png') }); line(Boolean(pg.pager), '13 分页(数量可见,页码可跳)', `第 2 页后:页码 ${pg.pages} 个,「${pg.pager}」`); await page.reload({ waitUntil: 'networkidle' }); await page.waitForTimeout(1000); const after = await V(); line(after.url === '?page=2' || after.url.includes('page=2'), '14 刷新后筛选 / 页码还在(URL 记住)', `刷新后 URL = ${after.url || '(空)'}`); /* 空态 + 悬浮 + 其它页截图 */ await go('/reports/competitor?stage=S3&who=不存在的人', 900); await shot('18-empty-filter.png'); await go('/reports/concept', 900); await shot('18-empty-type.png'); await go('/reports/competitor', 1200); await page.hover('kf-app >> view-report-type >> kf-glass-card'); await page.waitForTimeout(300); await shot('19-card-hover.png'); const hoverT = await page.evaluate(() => { const h = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card'); return getComputedStyle(h.shadowRoot.querySelector('.frame')).transform; }); line(hoverT !== 'none', '卡片悬停上浮 + 高光', `hover 时 transform = ${hoverT}`); await go('/reports', 3200); await shot('04-reports-center.png'); await go('/reports/voc', 1200); await shot('02-type-voc.png'); await go('/stages', 1200); await shot('06-stages.png'); await go('/', 1500); await shot('05-workbench.png'); await go('/reports', 3200); await click('[data-view="list"]').catch(() => {}); await shot('12b-reports-center-list.png'); await browser.close(); console.log(`\n${fails === 0 ? 'ALL OK' : `FAILED ${fails}`} · 截图 → ${SHOTS}`); process.exit(fails ? 1 : 0);