Files
aurora-admin/tools/verify-nav-responsive.mjs
aurora-admin f1fbfc2ddb
Regression / regression (push) Canceled after 0s
feat(品牌标识): 几何 K 图标(favicon/顶栏标记/theme-color) + 并行会话成果入库
## 品牌标识(本次会话)

起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」,
那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」,
移动端站已是「K」;移动端文档站则完全没有 favicon)。

- 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边;
  描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」
- 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark);
  顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28))
- 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg)
- 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留)
- 移动端 7 个模板补 favicon(此前计数 0)

验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/
theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致;
两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。

## 并行会话成果(本次一并入库)

- 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具
  + IconPreview 预览页 + ICON-SPEC.md 冻结规格
- 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁
- PC 组件:103 个大后台组件 / 组件11 批次
- uni-app:PC 端试点 + 移动端端实现 + 真实编译验证

## 工程

- .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库)
- CHANGELOG 补品牌标识条目
- ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
2026-09-21 10:05:48 +08:00

248 lines
14 KiB
JavaScript
Raw Permalink 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.
#!/usr/bin/env node
/**
* verify-nav-responsive.mjs — 顶栏响应式验收(≤1366px 折叠为汉堡 + 抽屉)
*
* 前置:仓库根目录起 dev-server(node site/dev-server.js)
* 依赖:npm i -D playwright(CI 专用;运行时零依赖不变)
* 运行:node tools/verify-nav-responsive.mjs
* 退出码:0 全通过 / 1 有断言失败 / 2 环境问题
*
* 为什么需要它:顶栏的窄屏行为只在真实布局下才成立 —— 元素"可见"不等于"点得到"
* (遮罩层叠、visibility 过渡都会让点击静默失效),也不等于"文字没顶出 60px 栏"。
* 本脚本把这两类都测了,改动前实测到的缺陷即由它定位。
*
* 断言:
* A. 18 个宽度 × 中英双语:顶栏无横向溢出;无链接文字顶出 60px 顶栏(出血);
* 宽屏(>1366)链接必须单行且在视口内;窄屏(≤1366)汉堡可见。
* B. 抽屉交互:点汉堡打开 → 7 条链接全部落在视口内、遮罩出现、页面滚动被锁;
* Esc / 点遮罩 / 点链接 三种方式都能关闭;aria-expanded 同步;
* 打开时焦点进抽屉、关闭时焦点还给汉堡;放大回宽屏自动复位(不留滚动锁)。
*
* 断点依据(改动前实测,Chromium):
* 中文 1280px 起 7 条链接全部折行,1100px 起文字顶出顶栏,960px 起顶栏溢出;
* 英文 1366px 起顶栏溢出,1200px 起最右链接已不可达,480px 起 7 条全部不可见。
* 英文是约束方,故折叠阈值取 1366px。
*/
import { chromium } from 'playwright';
const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311';
const URL = `${BASE}/site/`;
const COLLAPSE = 1366; // 与 style.css 的 @media (max-width: 1366px) 必须一致
const WIDTHS = [1600, 1440, 1401, 1400, 1367, 1366, 1320, 1280, 1100, 1024, 960, 900, 820, 768, 640, 480, 375, 320];
const failures = [];
function assert(ok, msg) {
if (!ok) failures.push(msg);
console.log(`[nav-responsive] ${ok ? 'OK ' : 'FAIL'} ${msg}`);
}
/* 在页面上下文里量顶栏:溢出、出血、链接可达性 */
const probeLayout = (collapse) => {
const q = (s) => document.querySelector(s);
const inner = q('.topbar-inner');
const nav = q('.topnav');
const btn = q('#nav-toggle');
const tb = q('.topbar').getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const lines = (el) => {
const r = document.createRange();
r.selectNodeContents(el);
const rects = [...r.getClientRects()].filter((x) => x.width > 0.5 && x.height > 0.5);
return { n: rects.length, top: rects.length ? Math.min(...rects.map((x) => x.top)) : 0, bottom: rects.length ? Math.max(...rects.map((x) => x.bottom)) : 0 };
};
const links = [...nav.querySelectorAll('a')].map((a) => {
const b = a.getBoundingClientRect();
const t = lines(a);
const inViewX = b.left >= 0 && b.right <= vw;
return {
text: a.textContent.trim(),
inView: inViewX && b.top >= 0 && b.top < 900,
lines: t.n,
// 只判视口内链接:抽屉关闭时链接被 translateX 移出视口属预期,不是缺陷
bleed: inViewX && (t.bottom > tb.bottom + 0.5 || t.top < tb.top - 0.5),
};
});
return {
overflow: inner.scrollWidth - inner.clientWidth,
bleed: links.filter((x) => x.bleed).map((x) => x.text),
inView: links.filter((x) => x.inView).length,
total: links.length,
maxLines: Math.max(...links.map((x) => x.lines)),
hamburger: btn ? getComputedStyle(btn).display !== 'none' : false,
drawerOpen: document.body.classList.contains('nav-open'),
navVisibility: getComputedStyle(nav).visibility,
backdrop: q('#nav-backdrop') ? getComputedStyle(q('#nav-backdrop')).display !== 'none' : false,
bodyOverflow: getComputedStyle(document.body).overflow,
drawerLeft: Math.round(nav.getBoundingClientRect().left),
expanded: btn ? btn.getAttribute('aria-expanded') : null,
};
};
const browser = await chromium.launch();
try {
/* 环境自检:服务未起或页面结构缺失时报 exit 2(同 run-regression.mjs 的口径),
否则在 CI 上会退化成一串看不懂的断言失败。 */
{
const probe = await browser.newPage({ viewport: { width: 1440, height: 900 } });
try {
const res = await probe.goto(URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
if (!res || !res.ok()) throw new Error(`HTTP ${res ? res.status() : 'no response'}`);
/* 必须用 attached:汉堡按钮在 >1366px 是 display:none(设计如此),
默认的 visible 状态会在这里误报环境失败。 */
await probe.waitForSelector('#nav-toggle', { state: 'attached', timeout: 8000 });
} catch (err) {
console.error(`[nav-responsive] ENV FAIL: 无法加载 ${URL}(${err.message})`);
console.error('[nav-responsive] 先起服务:node site/dev-server.js');
await browser.close();
process.exit(2);
}
/* 字体度量基准:宽度断言依赖字体度量,CI 上缺 CJK 字体时中文会退化成豆腐块/拉丁回退,
宽度随之改变。打印基准值,好让日后的失败一眼可定位到"字体变了"而不是"布局坏了"。 */
const m = await probe.evaluate(() => {
const c = document.createElement('canvas').getContext('2d');
c.font = '14px sans-serif';
return {
han1: +c.measureText('国').width.toFixed(1),
han4: +c.measureText('快速开始').width.toFixed(1),
lat: +c.measureText('Getting Started').width.toFixed(1),
};
});
console.log(`[nav-responsive] 字体度量基准 14px:中文单字=${m.han1}px 快速开始=${m.han4}px "Getting Started"=${m.lat}px`);
await probe.close();
}
/* 字体容忍度诊断:把顶栏所有承载文字的元素字号乘以 k,二分找出仍不折行/不溢出的最大 k。
用途是把"CI 字体与开发机不同"这一风险变成可见的余量数字 —— 若某天 k 掉到接近 1,
说明余量被吃掉,此时失败信息能直接指向字体而不是"布局坏了"。
本机实测:k≈1.175(Verdana 最宽也只有 1.058,Arial/Segoe UI 为 0.918)。 */
{
const p = await browser.newPage({ viewport: { width: COLLAPSE + 1, height: 900 } });
await p.addInitScript(() => { try { localStorage.setItem('kole-lang', 'en'); } catch (e) {} });
await p.goto(URL, { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(1200);
await p.evaluate(() => {
/* 承载顶栏文字的元素清单:随 UI 走。2026-09-20 技术栈选择器从 <label>+<select> 改为
胶囊触发器后,文字落在 .fw-cur 上(原 .fw-label 已不存在)—— 清单不跟着改,
字体容忍度就会少算一个元素,把余量报得偏乐观。 */
const SEL = ['.logo-text', '.topnav a', '.search-trigger .st-text', '.search-trigger kbd', '.fw-cur', '.fw-menu .fw-opt', '.lang-cur'];
window.__b = [];
SEL.forEach((s) => document.querySelectorAll(s).forEach((el) => window.__b.push([el, parseFloat(getComputedStyle(el).fontSize)])));
window.__k = (k) => window.__b.forEach(([el, base]) => { el.style.fontSize = (base * k) + 'px'; });
window.__ok = () => {
const inner = document.querySelector('.topbar-inner');
const nav = document.querySelector('.topnav');
const lc = (el) => { const r = document.createRange(); r.selectNodeContents(el); return [...r.getClientRects()].filter((x) => x.width > 0.5 && x.height > 0.5).length; };
return inner.scrollWidth - inner.clientWidth === 0 && Math.max(...[...nav.querySelectorAll('a')].map(lc)) === 1;
};
});
let lo = 1, hi = 1.6;
for (let i = 0; i < 12; i++) {
const mid = (lo + hi) / 2;
await p.evaluate((k) => window.__k(k), mid);
await p.waitForTimeout(60);
if (await p.evaluate(() => window.__ok())) lo = mid; else hi = mid;
}
console.log(`[nav-responsive] 字体容忍度(${COLLAPSE + 1}px · 英文):文字宽度可再增 ${((lo - 1) * 100).toFixed(1)}% 仍不折行、不溢出`);
if (lo < 1.05) {
console.log('[nav-responsive] 注意:余量已不足 5%,真实字体的宽度差异可能触发折行 —— 需要收紧顶栏或提高折叠阈值');
}
await p.close();
}
for (const lang of ['zh', 'en']) {
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
await page.addInitScript((l) => { try { localStorage.setItem('kole-lang', l); } catch (e) {} }, lang);
await page.goto(URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
for (const w of WIDTHS) {
await page.setViewportSize({ width: w, height: 900 });
await page.waitForTimeout(180);
const L = await page.evaluate(probeLayout, COLLAPSE);
const tag = `${lang}@${w}px`;
assert(L.overflow === 0, `${tag} 顶栏无横向溢出${L.overflow ? `(实测 ${L.overflow}px)` : ''}`);
assert(L.bleed.length === 0, `${tag} 无链接文字顶出顶栏${L.bleed.length ? `(${L.bleed.join(',')})` : ''}`);
if (w > COLLAPSE) {
assert(!L.hamburger, `${tag} 宽屏不显示汉堡`);
assert(L.maxLines === 1, `${tag} 链接单行(实测最多 ${L.maxLines} 行)`);
assert(L.inView === L.total, `${tag} 链接全部可见(${L.inView}/${L.total})`);
} else {
assert(L.hamburger, `${tag} 窄屏显示汉堡`);
await page.click('#nav-toggle');
await page.waitForTimeout(340);
const O = await page.evaluate(probeLayout, COLLAPSE);
assert(O.drawerOpen && O.navVisibility === 'visible', `${tag} 点汉堡后抽屉打开`);
assert(O.inView === O.total, `${tag} 抽屉内链接全部可达(${O.inView}/${O.total})`);
assert(O.backdrop, `${tag} 遮罩出现`);
assert(O.bodyOverflow === 'hidden', `${tag} 抽屉打开时锁定页面滚动`);
assert(O.drawerLeft >= 0, `${tag} 抽屉未被推出视口(left=${O.drawerLeft})`);
assert(O.expanded === 'true', `${tag} aria-expanded 同步为 true`);
await page.keyboard.press('Escape');
await page.waitForTimeout(340);
const C = await page.evaluate(probeLayout, COLLAPSE);
assert(!C.drawerOpen, `${tag} Esc 关闭抽屉`);
assert(C.expanded === 'false', `${tag} 关闭后 aria-expanded 同步为 false`);
}
}
await page.close();
}
/* ---- 交互序列(390px 中文)---- */
const p = await browser.newPage({ viewport: { width: 390, height: 800 } });
await p.goto(URL, { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(1500);
const open = async () => { await p.click('#nav-toggle'); await p.waitForTimeout(340); };
const isOpen = () => p.evaluate(() => document.body.classList.contains('nav-open'));
await open();
assert(await p.evaluate(() => document.activeElement === document.querySelector('.topnav a')), '交互 打开后焦点进入抽屉首链接');
const left = (await p.evaluate(() => Math.round(document.querySelector('.topnav').getBoundingClientRect().left)));
await p.mouse.click(Math.max(5, left - 20), 500);
await p.waitForTimeout(340);
assert(!(await isOpen()), '交互 点遮罩关闭');
assert(await p.evaluate(() => document.activeElement === document.querySelector('#nav-toggle')), '交互 关闭后焦点还给汉堡');
await open();
await p.click('.topnav a[data-nav="design"]');
await p.waitForTimeout(700);
assert(!(await isOpen()), '交互 点链接后抽屉关闭');
assert(p.url().includes('/site/design'), '交互 点链接后完成跳转');
assert(await p.evaluate(() => !!document.querySelector('.topnav a[data-nav="design"].active')), '交互 跳转后链接高亮');
/* 语言选择器的窄屏可见性由顶栏拥挤度决定(工作区里出现过又撤掉了「≤420px 让位」那条规则),
因此这里不绑定具体规则:叠层检查固定放在语言选择器可见的宽度(480px)上做,
390px 这一段只覆盖抽屉交互本身。 */
await p.setViewportSize({ width: 1600, height: 800 });
await p.waitForTimeout(450);
assert(!(await isOpen()), '交互 放大回宽屏自动复位 nav-open');
assert((await p.evaluate(() => getComputedStyle(document.body).overflow)) !== 'hidden', '交互 放大后页面滚动解锁');
assert(await p.evaluate(() => getComputedStyle(document.querySelector('#nav-toggle')).display === 'none'), '交互 放大后汉堡隐藏');
assert(await p.evaluate(() => {
const b = document.querySelector('.topnav').getBoundingClientRect();
return b.top < 60 && b.height <= 61 && b.right <= innerWidth;
}), '交互 放大后导航回到顶栏内单行');
await p.close();
/* ---- 语言菜单与抽屉的叠层关系(480px:语言选择器可见,抽屉仍为汉堡形态)---- */
const pLang = await browser.newPage({ viewport: { width: 480, height: 800 } });
await pLang.goto(URL, { waitUntil: 'domcontentloaded' });
await pLang.waitForTimeout(1200);
const langVisible = await pLang.evaluate(() => getComputedStyle(document.querySelector('.lang-select')).display !== 'none');
assert(langVisible, '交互 480px 语言选择器可见');
await pLang.click('#lang-trigger');
await pLang.waitForTimeout(300);
const menuBefore = await pLang.evaluate(() => !document.querySelector('#lang-menu').classList.contains('hidden'));
await pLang.click('#nav-toggle');
await pLang.waitForTimeout(340);
const menuAfter = await pLang.evaluate(() => !document.querySelector('#lang-menu').classList.contains('hidden'));
assert(menuBefore && !menuAfter, '交互 开抽屉会收起语言菜单(避免叠层)');
await pLang.close();
} finally {
await browser.close();
}
console.log(`\n[nav-responsive] ${failures.length === 0 ? 'OK — all checks passed' : `FAIL — ${failures.length} 项未通过`}`);
process.exit(failures.length === 0 ? 0 : 1);