Files
aurora-admin/tools/verify-site-routes.mjs
T
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

287 lines
18 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.
#!/usr/bin/env node
/**
* verify-site-routes.mjs — 无 # 路由(History API)的浏览器级验收。
*
* 前置:仓库根目录起 dev-server(node site/dev-server.js),或把 REG_BASE 指向已跑的实例。
*
* 为什么必须用真浏览器:`parseRoute()` 单独测不出问题 —— 深层 URL 是否可用取决于
* 「服务器回落 + SPA 壳的 <base> + 资源绝对地址 + pushState/历史」四件事同时成立,
* 静态检查只能证明文件里有那几行。这里逐条走真实导航:
* 深链直开 / 深链刷新 / 旧 # 链接改写 / 站内跳转不整页刷新 / 框架切换写进 URL /
* 前进后退 / 薄壳重定向 / 缺失资源仍回真 404 / file:// 降级(pushState 不可用)。
* 其中「控制台零报错 + 零 4xx」是资源路径写法的回归哨兵:SPA 壳里一旦写回静态
* 相对路径,预扫描器会在深层 URL 下按错误的基准预取(实测会多出 404 与报错)。
*/
import { pathToFileURL } from 'node:url';
import { join } from 'node:path';
const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311';
const failures = [];
const assert = (ok, message) => {
if (!ok) failures.push(message);
console.log(`[site-routes] ${ok ? 'OK' : 'FAIL'} ${message}`);
};
let chromium;
try {
({ chromium } = await import('playwright'));
} catch {
console.error('[site-routes] 未安装 playwright。CI 环境请先 npm i -D playwright && npx playwright install --with-deps chromium');
process.exit(2);
}
try {
const r = await fetch(`${BASE}/site/data.json`, { signal: AbortSignal.timeout(5000) });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
} catch (e) {
console.error(`[site-routes] 无法连接 ${BASE}/site/data.json — ${e.message}`);
console.error(' 请先在仓库根目录运行:node site/dev-server.js');
process.exit(2);
}
try {
const browser = await chromium.launch();
/* 视口取宽屏:≤1366px 时顶栏被 CSS 收成抽屉,导航链接点不到(见 verify-nav-responsive.mjs) */
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
page.setDefaultTimeout(15000);
const consoleErrors = [];
const badResponses = [];
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160)); });
page.on('pageerror', (e) => consoleErrors.push('pageerror: ' + String(e.message).slice(0, 160)));
page.on('response', (res) => { if (res.status() >= 400) badResponses.push(`${res.status()} ${res.url()}`); });
const probe = () => page.evaluate(() => ({
url: location.pathname + location.search + location.hash,
siteBase: window.KOLE_SITE_BASE,
baseUri: document.baseURI,
h1: (document.querySelector('#content h1') || {}).textContent || '',
sheets: document.styleSheets.length,
token: getComputedStyle(document.documentElement).getPropertyValue('--kole-color-brand').trim(),
}));
/* 1. 深链直开:渲染、URL 干净、站点根与令牌都对 */
await page.goto(`${BASE}/site/component/button/h5`, { waitUntil: 'networkidle' });
let s = await probe();
assert(/按钮|Button/.test(s.h1), `深链 /site/component/button/h5 直接打开能渲染(h1=${s.h1 || '空'})`);
assert(s.url === '/site/component/button/h5', `深链 URL 里没有 #(实际 ${s.url})`);
assert(s.siteBase === '/site/' && s.baseUri.endsWith('/site/'), `<base> 与 KOLE_SITE_BASE 指向站点根(${s.baseUri})`);
assert(s.sheets >= 2 && s.token !== '', `站点令牌样式表已生效(sheets=${s.sheets} --kole-color-brand=${s.token || '空'})`);
assert(consoleErrors.length === 0, `深链冷加载 0 条控制台报错(实际 ${consoleErrors.length}${consoleErrors[0] ? ':' + consoleErrors[0] : ''})`);
assert(badResponses.length === 0, `深链冷加载 0 个 4xx/5xx(实际 ${badResponses.length}${badResponses[0] ? ':' + badResponses[0] : ''})`);
/* 2. 旧 # 链接:渲染 + 就地改写成干净地址 */
await page.goto(`${BASE}/site/#/component/modal`, { waitUntil: 'networkidle' });
s = await probe();
assert(/对话框|Modal/.test(s.h1), `旧 #/component/modal 仍能渲染(h1=${s.h1 || '空'})`);
assert(s.url === '/site/component/modal', `旧 # 链接被 replaceState 成无 # 地址(实际 ${s.url})`);
/* 3. 站内跳转:pushState 局部渲染,不整页刷新 */
await page.goto(`${BASE}/site/component/button/h5`, { waitUntil: 'networkidle' });
await page.evaluate(() => { window.__routeMark = 1; });
await page.click('.topnav a[data-nav="design"]');
await page.waitForFunction(() => location.pathname === '/site/design');
s = await probe();
assert(s.url === '/site/design', `顶栏链接跳到 /site/design(实际 ${s.url})`);
assert(await page.evaluate(() => window.__routeMark === 1), '跳转是 pushState(页面未整页刷新)');
/* 4. 侧栏进入组件页 + 框架切换写进 URL */
await page.goto(`${BASE}/site/overview`, { waitUntil: 'networkidle' });
await page.click('.side-item[data-slug="button"]');
await page.waitForFunction(() => location.pathname.startsWith('/site/component/button'));
s = await probe();
assert(s.url === '/site/component/button', `侧栏进入组件页(实际 ${s.url})`);
await page.selectOption('#fw-select', 'react');
await page.waitForFunction(() => location.pathname === '/site/component/button/react');
s = await probe();
assert(s.url === '/site/component/button/react', `框架切换写进 URL(实际 ${s.url})`);
/* 5. 前进 / 后退 */
await page.goBack();
await page.waitForFunction(() => location.pathname === '/site/component/button');
assert((await probe()).url === '/site/component/button', '后退回到短形态组件页');
await page.goBack();
await page.waitForFunction(() => location.pathname === '/site/overview');
s = await probe();
assert(/组件总览|Components/.test(s.h1), `再后退回到组件总览(h1=${s.h1 || '空'})`);
await page.goForward();
await page.waitForFunction(() => location.pathname === '/site/component/button');
assert((await probe()).url === '/site/component/button', '前进回到组件页');
/* 6. 深链刷新:走的是服务器回落,不是前端兜底 */
await page.goto(`${BASE}/site/component/table/vue3`, { waitUntil: 'networkidle' });
await page.reload({ waitUntil: 'networkidle' });
s = await probe();
assert(/表格|Table/.test(s.h1), `深链刷新后仍渲染(h1=${s.h1 || '空'})`);
assert(s.url === '/site/component/table/vue3', `刷新后 URL 不变形(实际 ${s.url})`);
/* 7. 组件入口薄壳(build-site.ps1 生成物)重定向到干净目标 */
await page.goto(`${BASE}/site/components/button/h5.html`, { waitUntil: 'networkidle' });
s = await probe();
assert(s.url === '/site/component/button/h5', `薄壳重定向落到 /site/component/button/h5(实际 ${s.url})`);
/* 8. 服务器回落只接无扩展名的路径:缺失资源必须回真 404,不能拿 HTML 顶替 */
const missing = await fetch(`${BASE}/site/component/button/style.css`);
assert(missing.status === 404, `缺失的带扩展名资源回真 404(实际 ${missing.status})`);
const deep = await fetch(`${BASE}/site/component/button/h5`);
assert(deep.status === 200 && (deep.headers.get('content-type') || '').includes('text/html'), '深层路由由服务器回落到 SPA 壳(200 text/html)');
/* 9. file:// 直开:pushState 不可用时退化为 hash 形态,而不是白屏 */
await page.goto(pathToFileURL(join(process.cwd(), 'site', 'index.html')).href + '#/component/button', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
s = await probe();
assert(/按钮|Button/.test(s.h1), `file:// 下仍能渲染组件页(h1=${s.h1 || '空'})`);
assert(s.url.includes('#/'), `file:// 下退化为 hash 形态(实际 ${s.url})`);
/* 10. 搜索弹窗跳转:走的是同一条 go() 路径 */
await page.goto(`${BASE}/site/overview`, { waitUntil: 'networkidle' });
await page.evaluate(() => { window.__routeMark = 1; });
await page.keyboard.press('Control+k');
await page.waitForSelector('#search-modal:not(.hidden)');
await page.fill('#search-input', 'button');
await page.waitForSelector('.sr-item');
await page.click('.sr-item');
await page.waitForFunction(() => location.pathname.startsWith('/site/component/'));
s = await probe();
assert(s.url === '/site/component/button', `搜索结果跳转到 /site/component/button(实际 ${s.url})`);
assert(await page.evaluate(() => window.__routeMark === 1), '搜索跳转是 pushState(页面未整页刷新)');
/* 11. 相对链接仍受 <base> 正确解析(「在线测试」与测试页入口都是相对地址) */
const playHref = await page.evaluate(() => {
const a = document.querySelector('a[href*="playground"]');
return a ? a.href : '';
});
assert(/\/site\/playground\.html\?slug=button$/.test(playHref), `「在线测试」相对链接解析到站点根(实际 ${playHref || '未找到'})`);
if (playHref) {
await page.goto(playHref, { waitUntil: 'networkidle' });
s = await probe();
assert(s.url === '/site/playground.html?slug=button', `在线测试页带上 slug 打开(实际 ${s.url})`);
}
/* 12. 版本切换:清单加载、保留路由、快照站点自渲染。
站点根靠 pathname 里第一段 "/site/" 反推,快照必须是 /<x.y.z>/site/… 才成立;
清单由 tools/precompute.mjs 生成,只有磁盘上真存在快照的版本才会出现。 */
await page.goto(`${BASE}/site/component/button/h5`, { waitUntil: 'networkidle' });
const verManifest = await page.evaluate(async () => {
try { return await (await fetch(new URL('versions.json', document.baseURI).href, { cache: 'no-store' })).json(); }
catch (e) { return null; }
});
if (verManifest && Array.isArray(verManifest.versions) && verManifest.versions.length > 1) {
const chip = await page.evaluate(() => {
const el = document.querySelector('#version-trigger');
return el ? { text: el.textContent.trim(), label: el.getAttribute('aria-label'), single: el.getAttribute('data-single') } : null;
});
assert(!!chip && /^v\d+\.\d+\.\d+$/.test(chip.text), `版本角标显示当前版本(实际 ${chip ? chip.text : '未找到'})`);
assert(!!chip && chip.label === '选择版本' && chip.single === '0', `多版本时角标可交互(label=${chip && chip.label} single=${chip && chip.single})`);
await page.click('#version-trigger');
await page.waitForSelector('#version-menu:not(.hidden)');
const opts = await page.evaluate(() => Array.prototype.slice.call(document.querySelectorAll('#version-menu .ver-opt'))
.map((o) => ({ v: o.getAttribute('data-version'), cur: o.getAttribute('aria-selected') })));
assert(opts.length === verManifest.versions.length, `版本菜单列出全部在线版本(${opts.length}/${verManifest.versions.length})`);
assert(opts.filter((o) => o.cur === 'true').length === 1, '版本菜单里恰好一项标记为当前版本');
const target = verManifest.versions.find((v) => v.path !== '..');
const optIndex = opts.findIndex((o) => o.v === target.version);
await page.click(`#version-menu .ver-opt:nth-of-type(${optIndex + 1})`);
await page.waitForURL(`**/${target.version}/site/component/button/h5`, { timeout: 15000 });
await page.waitForLoadState('domcontentloaded');
s = await probe();
assert(s.url === `/${target.version}/site/component/button/h5`, `切换版本保留当前路由(实际 ${s.url})`);
assert(s.siteBase === `/${target.version}/site/`, `快照内站点根仍由 "/site/" 反推(实际 ${s.siteBase})`);
assert(s.token !== '', `快照版本自己的令牌样式表生效(--kole-color-brand=${s.token || '空'})`);
await page.waitForFunction(() => /按钮|Button/.test(((document.querySelector('#content h1') || {}).textContent) || ''), null, { timeout: 15000 });
const back = await page.evaluate(() => {
const el = document.querySelector('#version-trigger');
return el ? el.textContent.trim() : '';
});
/* 快照角标取的是「快照自己的构建版本」(它那份 data.meta.version)。
用 tools/snapshot-site.mjs --force-version 造出来的演练快照会与目录名不同号 —— 那是
有意的:内容就是当前构建,只是挂在了旧版本号下;正式归档(不带 --force-version)时两者一致。 */
/* 快照角标显示的是**清单里该快照对应的版本号**(也就是 URL 里那一段),不是快照内部
冻结的 data.meta.version。用 --force-version 造的演练快照两者不同号(内容=当前构建、
版本号=旧版),正好能把这条区分开;正式归档(不带 --force-version)时两者一致。 */
assert(back === 'v' + target.version, `快照内版本角标显示该快照对应的版本(期望 v${target.version},实际 ${back || '空'})`);
/* 快照页的菜单必须仍可交互、且「当前版本」标的是它自己 —— 实测踩过的两个坑:
① 快照里残留的旧清单把菜单压成单版本 → 角标不可点;② 按路径前缀判当前版本时
'/site/…' 与 '/1.4.1/site/…' 撞车,根站点条目抢走「当前」。两者都会导致切不回来。 */
const snapState = await page.evaluate(() => {
const t = document.querySelector('#version-trigger');
return {
single: t ? t.getAttribute('data-single') : null,
chip: document.querySelector('#ver').textContent,
opts: Array.prototype.slice.call(document.querySelectorAll('#version-menu .ver-opt')).map((o) => ({ v: o.getAttribute('data-version'), cur: o.getAttribute('aria-selected') }))
};
});
assert(snapState.single === '0', `快照页的版本菜单仍可交互(data-single=${snapState.single})`);
assert(snapState.opts.filter((o) => o.cur === 'true').length === 1 && snapState.opts.filter((o) => o.cur === 'true')[0].v === target.version,
`快照页把「当前版本」标在自己身上(chip=${snapState.chip})`);
/* 切回最新版:保留路由 + 角标回到最新版 */
const backIndex = snapState.opts.findIndex((o) => o.cur === 'false');
await page.click('#version-trigger');
await page.waitForSelector('#version-menu:not(.hidden)');
await page.click(`#version-menu .ver-opt:nth-of-type(${backIndex + 1})`);
await page.waitForURL('**/site/component/button/h5', { timeout: 15000 });
await page.waitForLoadState('domcontentloaded');
s = await probe();
assert(s.url === '/site/component/button/h5', `从快照切回最新版保留路由(实际 ${s.url})`);
await page.waitForFunction(() => /按钮|Button/.test(((document.querySelector('#content h1') || {}).textContent) || ''), null, { timeout: 15000 });
const backChip = await page.evaluate(() => (document.querySelector('#ver') || {}).textContent || '');
assert(backChip === 'v' + verManifest.versions.find((v) => v.path === '..').version, `切回后角标回到最新版(实际 ${backChip || '空'})`);
/* file:// 直开时 details/sources 的 fetch 必然失败(浏览器不允许 file 方案 fetch),
那是既有降级路径的噪声,不计入「版本切换全程零报错」 */
const httpErrors = consoleErrors.filter((m) => !/URL scheme "file"/.test(m) && !/Cannot load file:/.test(m));
assert(httpErrors.length === 0, `版本切换全程 0 条控制台报错(实际 ${httpErrors.length}${httpErrors[0] ? ':' + httpErrors[0] : ''})`);
assert(badResponses.length === 0, `版本切换全程 0 个 4xx/5xx(实际 ${badResponses.length}${badResponses[0] ? ':' + badResponses[0] : ''})`);
} else {
/* 只有一个在线版本(开发期常态):下拉仍要在,里面就一项且标为当前版本 ——
打开就能看清「当前是哪个版本」,点击当前项不跳转(避免无谓整页刷新)。
窄屏那一档同理:顶栏 select 出现,只有一项且选中。 */
const solo = await page.evaluate(() => {
const t = document.querySelector('#version-trigger');
const menu = document.querySelector('#version-menu');
const wrap = document.querySelector('#ver-select-wrap');
return {
chip: (document.querySelector('#ver') || {}).textContent || '',
single: t ? t.getAttribute('data-single') : null,
hasPopup: t ? t.hasAttribute('aria-haspopup') : null,
label: t ? t.getAttribute('aria-label') : null,
menuHidden: menu ? menu.classList.contains('hidden') : null,
opts: Array.prototype.slice.call(document.querySelectorAll('#version-menu .ver-opt')).map((o) => ({
v: o.getAttribute('data-version'), cur: o.getAttribute('aria-selected')
})),
selectHidden: wrap ? wrap.hasAttribute('hidden') : null,
selectOptions: Array.prototype.slice.call(document.querySelectorAll('#ver-select option')).map((o) => o.value + (o.selected ? '*' : ''))
};
});
assert(solo.chip === 'v' + verManifest.versions[0].version, `单版本时角标显示当前版本(实际 ${solo.chip || '空'})`);
assert(solo.single === '0' && solo.hasPopup === true, `单版本时下拉仍可用(data-single=${solo.single} popup=${solo.hasPopup})`);
assert(solo.menuHidden === true, '单版本时菜单初始为关闭');
assert(solo.opts.length === 1 && solo.opts[0].v === verManifest.versions[0].version && solo.opts[0].cur === 'true',
`单版本菜单里只有一项且标为当前(${JSON.stringify(solo.opts)})`);
assert(solo.selectHidden === false && solo.selectOptions.length === 1 && solo.selectOptions[0].endsWith('*'),
`单版本时窄屏 select 出现并选中当前版本(hidden=${solo.selectHidden} ${JSON.stringify(solo.selectOptions)})`);
/* 打开 → 点唯一一项:应关闭菜单且 URL 不变(当前版本不重复跳转) */
await page.click('#version-trigger');
await page.waitForSelector('#version-menu:not(.hidden)');
await page.click('#version-menu .ver-opt:nth-of-type(1)');
await page.waitForTimeout(400);
const afterClick = await page.evaluate(() => ({ url: location.pathname, hidden: document.querySelector('#version-menu').classList.contains('hidden') }));
assert(afterClick.url === '/site/component/button/h5', `点当前版本不跳转(实际 ${afterClick.url})`);
console.log(`[site-routes] SKIP 版本切换(清单里只有 v${verManifest.versions[0].version} 一个在线版本,下拉内只有它一项)`);
}
await browser.close();
} catch (e) {
failures.push('探针中断: ' + String(e.message).split('\n')[0].slice(0, 200));
console.error('[site-routes] FAIL 中断: ' + String(e.message).split('\n')[0].slice(0, 200));
}
if (failures.length) {
console.error(`\n[site-routes] FAIL (${failures.length})`);
failures.forEach((f) => console.error(`- ${f}`));
process.exit(1);
}
console.log('\n[site-routes] OK — all checks passed');