Files
jcgl-dev/docs/frontend/_src/verify_jcxx.cjs
T
zfjia 329e27b515 初始提交:三端设计文档与 dev 代码基线
docs/:系统梳理、架构优化方案、CR-02 三端功能设计修订(含 §15 三端一致性同步与平台统计分析)、公寓端/应用端/平台端分册、公寓统计分析与排名报表、公寓端小步迭代开发计划(IT-0~IT-8)

dev/:RuoYi-Vue-master(后端)与 RuoYi-Vue2-master(前端)官方上游基线,未改动
2026-11-10 13:43:05 +08:00

394 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================================
* JcXx.optimized.vue 验收脚本
* 1) 真实 SFC 编译:vue-template-compiler / @babel/parser / sass
* 2) 功能集合零变化:script 哈希 + 模板指令/列/按钮/弹窗集合比对
* 3) 优化前 / 优化后同一份静态 DOM + 两份真实 CSS,多视口截图与布局断言
* 用法:node verify_jcxx.cjs
* ==========================================================================*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ROOT = path.resolve(__dirname, '..', '..', '..');
const VUE = path.join(ROOT, 'YktVue');
const SRC = path.join(VUE, 'src', 'views', 'JcXx.vue');
const OUT = path.join(VUE, 'src', 'views', 'JcXx.optimized.vue');
const UI = path.join(ROOT, 'docs', 'frontend', 'ui');
const req = p => require(path.join(VUE, 'node_modules', p));
const compiler = req('vue-template-compiler');
const babel = req('@babel/parser');
const sass = req('sass');
let chromium;
const PW = 'C:/Users/zfjia/AppData/Roaming/npm/node_modules/@playwright/cli/node_modules/playwright';
const EXE = 'C:/Users/zfjia/AppData/Local/ms-playwright/chromium_headless_shell-1243/chrome-headless-shell-win64/chrome-headless-shell.exe';
try { ({ chromium } = require(PW)); } catch (e) { ({ chromium } = require('playwright')); }
const beforeVue = fs.readFileSync(SRC, 'utf8');
const afterVue = fs.readFileSync(OUT, 'utf8');
function parts(s) {
// 注意:模板内部有大量 <template slot-scope>,必须取最后一个 </template>
const a = s.indexOf('<template>') + '<template>'.length;
const b = s.lastIndexOf('</template>');
const c = s.indexOf('<script>'), d = s.indexOf('</script>') + 9;
const e = s.indexOf('<style'), f = s.indexOf('</style>');
return {
template: s.slice(a, b),
script: s.slice(c, d),
scriptBody: s.slice(s.indexOf('>', c) + 1, d - 9),
style: s.slice(s.indexOf('>', e) + 1, f)
};
}
const B = parts(beforeVue), A = parts(afterVue);
const sha = s => crypto.createHash('sha256').update(s).digest('hex');
let pass = 0, fail = 0;
const results = [];
function T(name, cond, detail) {
if (cond) { pass++; results.push(' PASS ' + name); }
else { fail++; results.push(' FAIL ' + name + (detail ? ' ' + detail : '')); }
}
// ---------------------------------------------------------------- 1. 编译
const rcB = compiler.compile(B.template);
const rcA = compiler.compile(A.template);
T('SFC-01 优化前模板可由 vue-template-compiler 编译', rcB.errors.length === 0, JSON.stringify(rcB.errors));
T('SFC-02 优化后模板可由 vue-template-compiler 编译', rcA.errors.length === 0, JSON.stringify(rcA.errors));
let jsErr = '';
try {
babel.parse(A.scriptBody, {
sourceType: 'module',
plugins: ['objectRestSpread', 'dynamicImport', 'optionalChaining',
'nullishCoalescingOperator', 'classProperties']
});
} catch (e) { jsErr = e.message; }
T('SFC-03 优化后 <script> 通过 Babel 语法解析', !jsErr, jsErr);
const compileSass = scss => sass.renderSync({ data: scss, outputStyle: 'expanded' }).css.toString();
const cssB = compileSass(B.style);
const cssA = compileSass(A.style);
T('SFC-04 优化前 style 可通过 sass 编译', cssB.length > 10000);
T('SFC-05 优化后 style 可通过 sass 编译', cssA.length > cssB.length);
// ---------------------------------------------------------------- 2. 功能集合
T('FUN-01 <script> 段逐字节一致(功能/接口/定时器/状态机全部保留)',
B.script === A.script && sha(B.script) === sha(A.script));
function inventory(tpl) {
const pick = re => (tpl.match(re) || []).sort();
return {
events: pick(/@[a-zA-Z][\w.-]*(?:=|\s|>)/g).map(x => x.replace(/[=>\s]/g, '')).sort(),
models: pick(/v-model(?:\.\w+)?="[^"]+"/g),
binds: pick(/:[a-zA-Z][\w.-]*="[^"]+"/g),
refs: pick(/ref="[^"]+"/g),
slots: pick(/slot-scope="[^"]+"/g),
vif: pick(/v-(?:if|else-if|else|show|for|html|once)(?:="[^"]+")?/g),
cols: pick(/<el-table-column[^>]*?\b(?:prop|label)="[^"]+"/g)
.map(x => (x.match(/\bprop="([^"]+)"/) || [, ''])[1] + '|' +
(x.match(/\blabel="([^"]+)"/) || [, ''])[1]),
tags: ['el-dialog', 'el-table', 'el-table-column', 'el-button', 'el-input',
'el-select', 'el-form', 'el-form-item', 'el-tab-pane', 'el-checkbox',
'audio', 'el-result'].map(t => t + '=' + (tpl.match(new RegExp('<' + t + '\\b', 'g')) || []).length)
};
}
const ib = inventory(B.template), ia = inventory(A.template);
Object.keys(ib).forEach(k => {
if (k === 'events') {
// 唯一预期差异:tab_click -> tab-click(Element UI 实际派发 tab-click;处理函数体为空)
const nb = ib.events.map(x => x === '@tab_click' ? '@tab-click' : x).sort();
T('FUN-02 事件绑定集合一致(仅修正 tab_click 拼写)',
JSON.stringify(nb) === JSON.stringify(ia.events),
JSON.stringify({ before: ib.events.filter(x => !ia.events.includes(x)), after: ia.events.filter(x => !nb.includes(x)) }));
} else {
T('FUN-02 ' + k + ' 集合与数量一致',
JSON.stringify(ib[k]) === JSON.stringify(ia[k]),
JSON.stringify({ before: ib[k].length, after: ia[k].length }));
}
});
T('FUN-03 未新增/删除任何按钮、表格列、弹窗',
ib.tags.join('|') === ia.tags.join('|'), ib.tags.join('|'));
T('FUN-04 所有弹窗带统一 jc-dialog 作用域类名、宽弹窗带 jc-wide-dialog',
(A.template.match(/<el-dialog/g) || []).length === (A.template.match(/class="jc-dialog/g) || []).length &&
(A.template.match(/jc-wide-dialog/g) || []).length === 4);
T('FUN-05 原样式核心规则全部保留,优化层仅做追加与 4 处修正',
['.marquee-container', '.center-time-section', '.form-item-container',
'.bottom-duty-bar', '@keyframes marquee', '.history-jcxx-form-row',
'.sync-warning-content', '.person-photo-panel', '.jl-add-form']
.every(k => A.style.includes(k)) && cssA.length > cssB.length);
// ---------------------------------------------------------------- 3. 静态布局快照
const elementCss = fs.readFileSync(
path.join(VUE, 'node_modules', 'element-ui', 'lib', 'theme-chalk', 'index.css'), 'utf8');
const ORANGE = 'background: linear-gradient(135deg, #e6a23c, #f7ba2a); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(230,162,60,.3); color: white;';
const TEAL = 'background: linear-gradient(135deg, #0b5858, #0f6b6b); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(11,88,88,.3); color: white;';
const BLUE = 'background: linear-gradient(135deg, #409EFF, #66b3ff); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(64,158,255,.3); color: white;';
const GREY = 'background: linear-gradient(135deg, #909399, #b1b3b8); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(144,147,153,.3); color: white;';
const RED = 'background: linear-gradient(135deg, #f56c6c, #ff7979); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(245,108,108,.3); color: white;';
const btn = (label, style, extra) =>
`<button class="el-button el-button--small" style="${style}${extra || ''}"><span class="buttonFont-small">${label}</span></button>`;
const COLS = ['序号', '状态', '到寓时间', '单位', '机车号', '姓名', '房间号', '出发车次',
'开车时间', '叫乘时间', '复叫时间', '叫乘日期', '应离寓时间', '接车方式', '操作'];
const ROWS = [
{ bg: '#F56C6C', st: 'jc-st-alarm', v: ['1', '叫乘报警', '11-08 21:40', '机务段', 'HXD3C-0131', '冯建国', '305', 'K671', '08:35', '06:35', '06:45', '11-09', '07:05', '站接'] },
{ bg: '#00FF00', st: 'jc-st-normal', v: ['2', '正在呼叫', '11-08 22:05', '机务段', 'HXD3C-0132', '蒋卫东', '517', 'T118', '09:10', '07:10', '07:20', '11-09', '07:40', '库出'] },
{ bg: '#FFF200', st: 'jc-st-normal', v: ['3', '待叫乘', '11-08 22:20', '客运段', 'HXD3C-0133', '李红军', '206', 'Z267', '10:05', '08:05', '08:15', '11-09', '08:35', '站接'] },
{ bg: '#B3E19D', st: 'jc-st-normal', v: ['4', '待离寓', '11-08 20:15', '机务段', 'HXD3C-0134', '王志强', '408', 'K42', '07:40', '05:40', '05:50', '11-09', '06:10', '库出'] },
{ bg: '', st: 'jc-st-muted', v: ['5', '已入寓', '11-09 01:10', '客运段', 'HXD3C-0135', '赵明亮', '612', 'K671', '14:20', '12:20', '12:30', '11-09', '12:50', '站接'] }
];
function makeBody() {
const search = ['机车号', '车次', '房间号', '姓名'].map((k, i) =>
`<div class="el-col el-col-6" style="padding-left:7.5px;padding-right:7.5px">
<div class="form-item-container"><label class="form-label">${k}</label>
<div class="el-input"><input class="el-input__inner" placeholder="请输入${k}" id="q${i}"></div></div></div>`).join('');
const topBtns = ['房间信息', '设备信息', '人员维护', '交路管理', '历史数据']
.map(x => btn(x, ORANGE, 'height:32px;')).join('');
const row2Btns = ['换乘', '出乘'].map(x => btn(x, TEAL, 'height:32px;width:84px;')).join('');
const row3Btns = ['语音临叫', '喊话临叫', '停止呼叫', '停止报警']
.map(x => btn(x, TEAL, 'height:32px;')).join('');
const ths = COLS.map(c => `<th class="el-table__cell"><div class="cell">${c}</div></th>`).join('');
const trs = ROWS.map((r, ri) => {
const tds = r.v.map((v, ci) => {
let inner = v;
if (ci === 1) inner = `<span class="jc-st ${r.st}">${v}</span>`;
return `<td class="el-table__cell"><div class="cell">${inner}</div></td>`;
});
tds.push(`<td class="el-table__cell"><div class="cell">${btn('暂停报警', BLUE, 'height:28px;')}</div></td>`);
return `<tr class="el-table__row${ri === 0 ? ' current-row' : ''}" style="background-color:${r.bg};font-size:20px;font-weight:600">${tds.join('')}</tr>`;
}).join('');
return `<div class="app_contianer jc-opt-root" id="app">
<div class="el-card"><div class="el-card__body">
<div class="el-row jc-header-row" style="height:150px;margin-left:-7.5px;margin-right:-7.5px">
<div class="el-col el-col-9 jc-header-search" style="height:100%;display:flex;flex-direction:column;justify-content:center;padding-left:7.5px;padding-right:7.5px">
<div class="el-row top-left-marquee-row" style="height:30%;margin-top:40px">
<div class="marquee-container" style="background-color:rgb(2,145,145);height:100%">
<div class="marquee-content"><span>305 房间叫乘时间已过,请值班员关注</span><span class="marquee-repeat-text">305 房间叫乘时间已过,请值班员关注</span></div>
</div>
</div>
<div class="top-left-search-area" style="flex:1;display:flex;flex-direction:column;justify-content:center;padding:10px 20px">
<div class="el-row top-left-input-row" style="margin-bottom:15px">${search}</div>
<div class="el-row top-left-action-row">
<div class="el-col el-col-24" style="display:flex;justify-content:space-between;align-items:center">
${btn('取消叫乘', ORANGE)}
<div style="display:flex;align-items:center;gap:12px">${btn('查询', BLUE)}${btn('重置', GREY)}</div>
</div>
</div>
</div>
</div>
<div class="el-col el-col-6 jc-header-time" style="height:100%;padding-left:7.5px;padding-right:7.5px">
<div class="el-row"><div class="center-time-section"><div class="date-time">
<p class="text-24 text-white">2026-11-09 星期一<br>09:03:21</p>
</div></div></div>
</div>
<div class="el-col el-col-9 jc-header-actions" style="height:100%;padding-left:7.5px;padding-right:7.5px">
<div class="el-row" style="height:50%;padding:8px 20px 8px 0px;display:flex;align-items:center">
<div class="el-col el-col-18"><div style="display:flex;gap:5px;justify-content:space-between;align-items:center">${topBtns}</div></div>
<div class="el-col el-col-1"></div>
<div class="el-col el-col-5"><div style="display:flex;align-items:center;gap:3px">
<span class="jc-user-label" style="font-size:12px;color:#666;width:45%">当前用户: 张值班</span>
${btn('退出', RED, 'height:32px;width:55%;')}
</div></div>
</div>
<div class="el-row" style="height:50%;padding:8px 20px 8px 0px;display:flex;align-items:center">
<div class="el-col el-col-7"><div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-start">${row2Btns}</div></div>
<div class="el-col el-col-17"><div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-start;margin-left:14px">${row3Btns}</div></div>
</div>
</div>
</div>
</div></div>
<div class="el-card" style="margin-top:10px"><div class="el-card__body">
<div class="el-tabs el-tabs--card"><div class="el-tabs__header is-top"><div class="el-tabs__nav-wrap"><div class="el-tabs__nav-scroll">
<div class="el-tabs__nav is-top"><div class="el-tabs__item is-top is-active">机务人员和客运人员</div><div class="el-tabs__item is-top">其他人员</div></div>
</div></div></div></div>
<div class="el-table main-jcxx-table el-table--border" style="width:100%;height:600px">
<div class="el-table__header-wrapper"><table class="el-table__header" style="width:100%"><thead><tr style="height:54px">${ths}</tr></thead></table></div>
<div class="el-table__body-wrapper" style="height:546px"><table class="el-table__body" style="width:100%"><tbody>${trs}</tbody></table></div>
</div>
</div></div>
<div class="bottom-duty-bar">
<div class="bottom-stay-summary"><span>在寓班次:14</span><span>在寓人次:30</span></div>
<div class="latest-sync-time" role="status">最新同步时间:09:03:18</div>
<div class="bottom-duty-info">
${btn('交接班', ORANGE, 'position:relative;right:20px;top:2px;')}
<span style="font-size:26px;margin-right:80px;position:relative;top:6px">值班员:张值班</span>
</div>
</div>
<div class="bottom-right-div" id="notice">有新的交路信息,请及时处理!</div>
<!-- 宽弹窗骨架:默认隐藏,验收时按需显示以测量视口约束 -->
<div class="el-dialog__wrapper jc-dialog jc-wide-dialog" id="wideWrap" style="display:none">
<div class="el-dialog" style="width:1800px"><div class="el-dialog__header"><span class="el-dialog__title">历史叫乘信息</span></div>
<div class="el-dialog__body"><div class="el-form history-jcxx-form"><div class="history-jcxx-form-row">
<div class="history-jcxx-field-box"><label class="history-jcxx-inline-label">机车号</label><div class="el-input"><input class="el-input__inner"></div></div>
<div class="history-jcxx-field-box"><label class="history-jcxx-inline-label">车次</label><div class="el-input"><input class="el-input__inner"></div></div>
</div></div></div>
</div>
</div>
</div>`;
}
function makeHtml(css, label) {
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<title>JcXx ${label}</title>
<style>${elementCss}</style>
<style>${css}</style>
<style>body{margin:0;background:#f3f6f5}#app{font-family:"Helvetica Neue",Helvetica,"PingFang SC","Microsoft YaHei",Arial,sans-serif}
/* 快照专用:模拟 el-table 运行时的内部横向滚动,避免 15 列被静态表格压扁 */
.main-jcxx-table .el-table__header-wrapper,
.main-jcxx-table .el-table__body-wrapper{overflow-x:auto}
.main-jcxx-table table{min-width:1500px}
</style>
</head><body>${makeBody()}</body></html>`;
}
const pb = path.join(__dirname, 'preview-before.html');
const pa = path.join(__dirname, 'preview-after.html');
fs.writeFileSync(pb, makeHtml(cssB, '优化前'), 'utf8');
fs.writeFileSync(pa, makeHtml(cssA, '优化后'), 'utf8');
const URLB = require('url').pathToFileURL(pb).href;
const URLA = require('url').pathToFileURL(pa).href;
// ---------------------------------------------------------------- 4. 多视口断言
const VIEWPORTS = [
{ w: 1920, h: 1080 }, { w: 1440, h: 900 }, { w: 1366, h: 768 },
{ w: 1280, h: 800 }, { w: 1024, h: 768 }
];
const probe = () => {
const R = s => {
const e = document.querySelector(s);
if (!e) return null;
const b = e.getBoundingClientRect();
return { t: Math.round(b.top), b: Math.round(b.bottom), l: Math.round(b.left), r: Math.round(b.right), w: Math.round(b.width), h: Math.round(b.height) };
};
const root = document.querySelector('#app');
const bar = R('.bottom-duty-bar');
const wrap = document.querySelector('#wideWrap');
wrap.style.display = 'block';
const dlg = R('.jc-wide-dialog .el-dialog');
wrap.style.display = 'none';
return {
vw: window.innerWidth, vh: window.innerHeight,
docW: document.documentElement.scrollWidth,
header: R('.jc-header-row'), search: R('.top-left-search-area'),
leftCol: R('.jc-header-search'), time: R('.center-time-section'),
actions: R('.jc-header-actions'), topBtns: R('.jc-header-actions .el-col-18 > div'),
bar, left: R('.bottom-stay-summary'), mid: R('.latest-sync-time'), right: R('.bottom-duty-info'),
notice: R('.bottom-right-div'), table: R('.main-jcxx-table'),
dlg,
paddingBottom: parseFloat(getComputedStyle(root).paddingBottom) || 0,
headerH: Math.round(document.querySelector('.jc-header-row').getBoundingClientRect().height)
};
};
(async () => {
const browser = await chromium.launch({ executablePath: EXE, headless: true });
const ctx = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const beforeIssues = {}, afterIssues = {};
for (const v of VIEWPORTS) {
const tag = `${v.w}x${v.h}`;
for (const [kind, url, bucket] of [['before', URLB, beforeIssues], ['after', URLA, afterIssues]]) {
const p = await ctx.newPage();
await p.setViewportSize({ width: v.w, height: v.h });
const errs = [];
p.on('pageerror', e => errs.push(e.message));
await p.goto(url);
await p.waitForTimeout(120);
const m = await p.evaluate(probe);
const issues = [];
if (m.docW > m.vw + 1) issues.push('横向溢出 ' + m.docW + '>' + m.vw);
if (m.search.b > m.leftCol.b + 2) issues.push('顶部查询区被裁切');
if (m.time.r > m.vw + 1) issues.push('时间卡越界');
if (m.topBtns.r > m.actions.r + 2) issues.push('右上按钮组越界');
if (m.left.r > m.mid.l + 2 || m.mid.r > m.right.l + 2) issues.push('吸底三段重叠');
if (m.notice.b > m.bar.t + 2) issues.push('右下提示被吸底条压住');
if (m.paddingBottom < 50) issues.push('底部无留白内容被遮挡');
if (m.dlg.r > m.vw + 1) issues.push('宽弹窗横向越界');
if (errs.length) issues.push('JS 错误 ' + errs.join('|'));
bucket[tag] = issues;
const shot = path.join(UI, `JcXx-${kind === 'before' ? '优化前' : '优化后'}-${tag}.png`);
await p.screenshot({ path: shot, fullPage: false });
await p.close();
}
T(`LAY-${tag} 优化后无横向溢出/裁切/重叠/遮挡/弹窗越界`,
afterIssues[tag].length === 0, JSON.stringify(afterIssues[tag]));
results.push(` └ 优化前同视口问题:${beforeIssues[tag].length ? beforeIssues[tag].join(';') : '无'}`);
}
// ------------------------------------------------------------ 交互与状态反馈
const p = await ctx.newPage();
await p.setViewportSize({ width: 1920, height: 1080 });
await p.goto(URLA);
await p.waitForTimeout(120);
await p.hover('.main-jcxx-table .el-table__body tr:nth-child(3) td');
const hoverAfter = await p.evaluate(() =>
getComputedStyle(document.querySelector('.main-jcxx-table .el-table__body tr:nth-child(3) td')).backgroundImage);
T('FB-01 表格行 hover 有可见反馈(保留行内状态底色)',
hoverAfter !== 'none' && hoverAfter.includes('gradient'), hoverAfter);
const current = await p.evaluate(() => {
const td = document.querySelector('.main-jcxx-table tr.current-row > td');
const cs = getComputedStyle(td);
return { bg: cs.backgroundColor, img: cs.backgroundImage, trBg: td.parentElement.style.backgroundColor };
});
T('FB-02 选中行不再用纯蓝底抹掉报警行状态色',
current.bg !== 'rgb(25, 144, 241)' && current.img.includes('gradient'),
JSON.stringify(current));
const grad = await p.evaluate(() => {
const b = document.querySelector('.top-left-action-row .el-button');
return getComputedStyle(b).backgroundImage;
});
await p.hover('.top-left-action-row .el-button');
const gradHover = await p.evaluate(() =>
getComputedStyle(document.querySelector('.top-left-action-row .el-button')).backgroundImage);
T('FB-03 橙色按钮 hover 不再被强制刷成蓝色渐变',
gradHover === grad && gradHover.includes('230, 162, 60'), gradHover);
const shadowBefore = await p.evaluate(() =>
getComputedStyle(document.querySelector('#q0')).boxShadow);
await p.focus('#q0');
await p.waitForTimeout(160); // headless 下 :focus 样式重算 + .3s 过渡起效
const focusFeedback = await p.evaluate(() => {
void document.body.offsetHeight; // 强制同步重排后再取计算样式
const c = getComputedStyle(document.querySelector('#q0'));
return { shadow: c.boxShadow, outlineStyle: c.outlineStyle };
});
T('FB-04 键盘焦点产生可见反馈(光晕变化 + 轮廓)',
focusFeedback.shadow !== shadowBefore && focusFeedback.outlineStyle !== 'none',
JSON.stringify({ before: shadowBefore, after: focusFeedback }));
const stateWeight = await p.evaluate(() =>
getComputedStyle(document.querySelector('.main-jcxx-table .jc-st-alarm')).fontWeight);
T('FB-05 报警状态文字权重高于普通单元格', Number(stateWeight) >= 700, stateWeight);
const noticeCursor = await p.evaluate(() =>
getComputedStyle(document.querySelector('#notice')).cursor);
T('FB-06 右下角提示有可点击指针反馈', noticeCursor === 'pointer', noticeCursor);
T('FB-07 优化层含 prefers-reduced-motion 降级', cssA.includes('prefers-reduced-motion'));
await p.close();
await browser.close();
console.log('\n================ JcXx 优化版验收 ================');
console.log(results.join('\n'));
console.log('--------------------------------------------------');
const bTotal = Object.values(beforeIssues).reduce((n, x) => n + x.length, 0);
const aTotal = Object.values(afterIssues).reduce((n, x) => n + x.length, 0);
console.log(`优化前 5 个视口累计布局问题:${bTotal} 项`);
console.log(`优化后 5 个视口累计布局问题:${aTotal} 项`);
console.log(`断言:${pass}/${pass + fail} 通过`);
console.log(`截图:${VIEWPORTS.length * 2} 张 -> ${UI}`);
console.log('==================================================');
if (fail) process.exit(1);
})().catch(e => { console.error(e); process.exit(1); });