Files
aurora-admin/tools/verify-stage-overlay.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

257 lines
12 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-stage-overlay.mjs — 演示帧内「覆盖型浮层」必须铺满真实视口
*
* 前置:仓库根目录起 dev-server(node site/dev-server.js)
* 依赖:npm i -D playwright(CI 专用;运行时零依赖不变)
* 运行:node tools/verify-stage-overlay.mjs
* 退出码:0 全通过 / 1 有断言失败 / 2 环境问题
*
* 为什么需要它(2026-09-20 实测的缺陷):
* 文档站把演示页放进按内容高度撑开的 iframe(.ex-stage / .demo-stage + fitFrame),
* 帧视口 == 演示框尺寸。组件里 position:fixed; inset:0 的覆盖层以**帧视口**为包含块,
* 于是「点击图片全屏」「打开对话框」只在演示框内铺开 —— imagepreview 实测遮罩
* 724×134,宿主视口 1241×1233。这是承载容器的形态问题(演示页单独打开时覆盖层是铺满的),
* 修法在站点侧:帧内出现覆盖层时把演示舞台升到视图层(site/app.js 的 watchStageLift)。
*
* 断言分两组:
* A(正向)逐组件:点开触发 → 帧内覆盖层尺寸必须≈宿主视口(容差 2px),且演示舞台带
* .is-lifted、宿主滚动被锁;关闭 → 提升撤销、滚动锁解除、帧回到内容高度。
* B(反向)不该提升的必须不提升:水印全屏层(装饰,不是浮层)、BackToTop(贴右下角)、
* AnchorNav(贴右侧)、TabBar(贴底部)、局部 LoadingOverlay(absolute 局部块)、
* 以及没有任何浮层的普通演示页(如 button)—— 它们都不能被提升,否则「全屏」会把
* 一个贴边浮层错放大成整屏。
*
* 判据只认浏览器实测的几何量,不做静态推断:静态检查看不出 position:fixed 的包含块是谁。
*/
import { chromium } from 'playwright';
const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311';
const TOL = 2; // 与视口的像素容差
const failures = [];
function assert(ok, msg) {
if (!ok) failures.push(msg);
console.log(`[stage-overlay] ${ok ? 'OK ' : 'FAIL'} ${msg}`);
}
/* 正向用例:slug / 进入浮层的帧内操作 / 关闭浮层的帧内操作 / 期望的浮层选择器 */
const POSITIVE = [
{
slug: 'imagepreview', layer: '.kole-imgprev-mask',
open: (d) => d.querySelector('.kole-thumb').click(),
close: (d) => d.getElementById('close').click()
},
{
slug: 'modal', layer: '.kole-mask',
/* 同上:这个场景的预览计划把挂载点 #mount 隐藏了(预览只展示三个按钮),
遮罩虽被创建却整块不可见。还原成演示页原样后再点。 */
prep: (d) => { d.querySelectorAll('[style*="display: none"]').forEach((n) => { n.style.display = ''; }); },
open: (d) => d.querySelector('.btn-primary').click(),
close: (d) => { const m = d.querySelector('.kole-mask'); if (m) m.click(); }
},
{
slug: 'alertmodal', layer: '.kole-modal-mask',
open: (d) => d.getElementById('open').click(),
close: (d) => d.getElementById('ok').click()
},
{
slug: 'confirmmodal', layer: '.kole-modal-mask',
open: (d) => d.getElementById('open').click(),
close: (d) => d.getElementById('cancel').click()
},
{
slug: 'formmodal', layer: '.kole-formmodal-mask',
open: (d) => d.getElementById('open').click(),
close: (d) => d.getElementById('cancel').click()
},
{
slug: 'fullscreenmodal', layer: '.kole-fs-mask',
open: (d) => d.getElementById('open').click(),
close: (d) => d.getElementById('cancel').click()
},
{
slug: 'bottomsheet', layer: '.kole-sheet-mask',
/* 文档站给这个组件配的逐示例预览计划会隐藏遮罩与触发按钮
(它展示的是面板底部两个按钮,不是「打开面板」这个动作)。
要测「遮罩出现时必须提升」,先把预览计划隐藏掉的节点还原成演示页原样。 */
prep: (d) => { d.querySelectorAll('[style*="display: none"]').forEach((n) => { n.style.display = ''; }); },
open: (d) => d.getElementById('open').click(),
close: (d) => d.getElementById('cancel').click()
},
{
slug: 'loadingoverlay', layer: '.kole-loading-overlay.is-dark',
open: (d) => { const btns = d.querySelectorAll('button'); for (const b of btns) { if (/全屏/.test(b.textContent)) { b.click(); return; } } },
close: (d) => { const btns = d.querySelectorAll('button'); for (const b of btns) { if (/全屏/.test(b.textContent)) { b.click(); return; } } }
}
];
/* 反向用例:进入一个"看着像但没有覆盖视口"的状态,或干脆不触发 */
const NEGATIVE = [
{ slug: 'watermark', why: '水印全屏层是装饰,不是浮层' },
{ slug: 'backtotop', why: '贴右下角的浮动按钮不提升' },
{ slug: 'anchornav', why: '贴右侧的目录不提升' },
{ slug: 'button', why: '无任何覆盖层的普通页不提升' },
{ slug: 'messagepro', why: '顶部消息条不提升(页面加载即无浮层)' },
/* 移动端站是**静态站**,不加载 site/app.js —— 这条断的是「PC 的提升逻辑不得泄漏到移动端」,
不是「移动端浮层不该提升」(那边压根没有提升机制)。移动端演示框用 transform 建立包含块
把面板限定在框内,是有意的设计(见 AGENTS.md 已知陷阱表),本方案不动它。 */
{ slug: 'tabbar', why: 'PC 提升逻辑不泄漏到移动端静态站', mobile: true }
];
let chromium_;
try {
({ chromium: chromium_ } = await import('playwright'));
} catch {
console.error('[FATAL] 未安装 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(`[FATAL] 无法连接 ${BASE}/site/data.json — ${e.message}`);
console.error(' 请先在仓库根目录运行:node site/dev-server.js');
process.exit(2);
}
const inPage = () => {
const f = document.querySelector('.ex-stage iframe, .demo-stage iframe');
if (!f) return { error: 'no-frame' };
const stage = f.parentElement;
return {
lifted: !!stage && stage.classList.contains('is-lifted'),
locked: document.documentElement.classList.contains('kole-stage-locked'),
stagePos: stage ? getComputedStyle(stage).position : '',
frame: { w: Math.round(f.getBoundingClientRect().width), h: Math.round(f.getBoundingClientRect().height) },
viewport: { w: document.documentElement.clientWidth, h: document.documentElement.clientHeight }
};
};
const layerRectIn = (sel) => {
const f = document.querySelector('.ex-stage iframe, .demo-stage iframe');
if (!f || !f.contentDocument) return null;
const n = f.contentDocument.querySelector(sel);
if (!n) return null;
const r = n.getBoundingClientRect();
const cs = f.contentDocument.defaultView.getComputedStyle(n);
return {
w: Math.round(r.width), h: Math.round(r.height),
shown: cs.display !== 'none' && cs.visibility !== 'hidden' && parseFloat(cs.opacity) > 0
};
};
const browser = await chromium_.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
try {
/* ---------- A 组:正向 ---------- */
for (const t of POSITIVE) {
const url = `${BASE}/site/component/${t.slug}`;
try {
await page.goto(url, { waitUntil: 'load', timeout: 20000 });
await page.waitForSelector('.ex-stage iframe, .demo-stage iframe', { timeout: 10000 });
await page.waitForTimeout(600);
/* 需要时先还原预览计划隐藏的节点(见用例里的 prep 注释) */
if (t.prep) {
await page.evaluate(({ slug }) => {
const f = document.querySelector('.ex-stage iframe, .demo-stage iframe');
const d = f && f.contentDocument;
if (d) d.querySelectorAll('[style*="display: none"]').forEach((n) => { n.style.display = ''; });
}, { slug: t.slug });
await page.waitForTimeout(200);
}
/* 帧内点击触发 */
const opened = await page.evaluate(({ slug }) => {
const f = document.querySelector('.ex-stage iframe, .demo-stage iframe');
const d = f && f.contentDocument;
if (!d) return 'no-doc';
const ops = {
imagepreview: () => d.querySelector('.kole-thumb').click(),
modal: () => d.querySelector('.btn-primary').click(),
alertmodal: () => d.getElementById('open').click(),
confirmmodal: () => d.getElementById('open').click(),
formmodal: () => d.getElementById('open').click(),
fullscreenmodal: () => d.getElementById('open').click(),
bottomsheet: () => d.getElementById('open').click(),
loadingoverlay: () => { for (const b of d.querySelectorAll('button')) if (/全屏/.test(b.textContent)) { b.click(); return; } }
};
try { ops[slug](); return 'ok'; } catch (e) { return 'err:' + e.message; }
}, { slug: t.slug });
if (opened !== 'ok') { assert(false, `${t.slug}:帧内触发浮层(${opened})`); continue; }
await page.waitForTimeout(500); /* 等 MutationObserver + 过渡 */
const st = await page.evaluate(inPage);
const rect = await page.evaluate(layerRectIn, t.layer);
if (!rect) { assert(false, `${t.slug}:帧内未找到浮层 ${t.layer}`); continue; }
assert(rect.shown, `${t.slug}:浮层 ${t.layer} 处于显示态`);
assert(Math.abs(rect.w - st.viewport.w) <= TOL && Math.abs(rect.h - st.viewport.h) <= TOL,
`${t.slug}:浮层铺满真实视口(浮层 ${rect.w}×${rect.h} vs 视口 ${st.viewport.w}×${st.viewport.h})`);
assert(st.lifted, `${t.slug}:演示舞台被提升(.is-lifted)`);
assert(st.locked, `${t.slug}:宿主滚动已锁(html.kole-stage-locked)`);
/* 关闭 → 复位 */
const closed = await page.evaluate(({ slug }) => {
const f = document.querySelector('.ex-stage iframe, .demo-stage iframe');
const d = f.contentDocument;
const ops = {
imagepreview: () => d.getElementById('close').click(),
modal: () => { const m = d.querySelector('.kole-mask'); if (m) m.click(); },
alertmodal: () => d.getElementById('ok').click(),
confirmmodal: () => d.getElementById('cancel').click(),
formmodal: () => d.getElementById('cancel').click(),
fullscreenmodal: () => d.getElementById('cancel').click(),
bottomsheet: () => d.getElementById('cancel').click(),
loadingoverlay: () => { for (const b of d.querySelectorAll('button')) if (/全屏/.test(b.textContent)) { b.click(); return; } }
};
try { ops[slug](); return 'ok'; } catch (e) { return 'err:' + e.message; }
}, { slug: t.slug });
await page.waitForTimeout(500);
const st2 = await page.evaluate(inPage);
if (closed === 'ok') {
assert(!st2.lifted, `${t.slug}:关闭浮层后撤销提升`);
assert(!st2.locked, `${t.slug}:关闭浮层后解除滚动锁`);
assert(st2.stagePos !== 'fixed', `${t.slug}:演示舞台回到常规文档流(position=${st2.stagePos})`);
assert(st2.frame.h < st2.viewport.h, `${t.slug}:帧回到内容高度(${st2.frame.h} < 视口 ${st2.viewport.h})`);
}
} catch (e) {
assert(false, `${t.slug}:用例执行异常 — ${e.message}`);
}
}
/* ---------- B 组:反向(不该提升的必须不提升) ---------- */
for (const t of NEGATIVE) {
const prefix = t.mobile ? '/site/m/component/' : '/site/component/';
try {
await page.goto(`${BASE}${prefix}${t.slug}`, { waitUntil: 'load', timeout: 20000 });
await page.waitForTimeout(900);
const probe = await page.evaluate(() => {
const frames = Array.from(document.querySelectorAll('iframe'));
return {
lifted: Array.from(document.querySelectorAll('.is-lifted')).map((n) => n.className),
locked: document.documentElement.classList.contains('kole-stage-locked'),
frames: frames.length
};
});
assert(probe.lifted.length === 0, `${t.slug}:不被提升(${t.why})— 实际 ${JSON.stringify(probe.lifted)}`);
assert(!probe.locked, `${t.slug}:不触发滚动锁`);
} catch (e) {
assert(false, `${t.slug}(反向):用例执行异常 — ${e.message}`);
}
}
} finally {
await browser.close();
}
if (failures.length) {
console.error(`\n[stage-overlay] ${failures.length} check(s) failed`);
process.exit(1);
}
console.log(`\n[stage-overlay] OK — 全部通过`);