用户四封信(同一线索)提的六件事,都在这里: ## ① 导航合并(「导航栏的内容有点多了」) 收件箱/发件箱/授权 → 一项「通信」,进去由**内部页签**区分(CommTabs); 新建 → 「通信」页内**悬浮圆形加号**(ComposeFab);导航只剩 通信/日历/联系人; 管理 → 合进「我的」,管理员在页面**最下面**看得到(非管理员不渲染,不是禁用)。 `viewMode` 仍是原来那三个值(几十处调用点不用改),新增 `commTab` 记住上次用的子页签; 通信项的徽标 = 未读 + 待决策(授权信息不能因为合并而消失)。 ## ② 窄屏底部导航:悬浮玻璃(「还是固定底部延伸,没有玻璃效果,也没有悬浮」) 原来是 `border-t bg-chrome-900` 通栏贴底。改成:左右/离底各留 10px、圆角、 深色半透明 + `blur(18px)`、投影,安全区并入下外边距。 ## ③ 页面动画(「页面极度缺少动画,所有页面都是直接出现」) 在 `<html>` 上挂一个短命类触发外壳直接子面板的入场动画。**没有用 key 重挂载**: 面板是 flex 链上的一环,套 wrapper 会改掉 flex 传递(窄屏覆盖层最先坏)。 并且尊重 `prefers-reduced-motion`(关了就一点都不动)。 ## ④ 控件档透明度(「复选框和地址猜测项…他们才是真正需要拉低透明度的地方」) 新增第三档 `--bg-glass-control`(0.5/0.55):授权多选胶囊、地址建议菜单。 三档递减:正文面 0.88 > 嵌套 0.82 > 控件 0.5。 ## ⑤ 打底 vs 透明(上一封「该打底的地方透了、该透的空白处糊了」) 正文面回到 0.88(读得清优先)、空白/页面底透明**且不模糊**、面板模糊降到 10px。 ## ⑥ 圆角与玻璃**无条件**生效(「大面积缺失圆角与玻璃效果…都是硬截断」) 根因:`.app-shell` 的留缝/圆角/投影原先全写在 `html[data-bg='on']` 里 ⇒ **没开壁纸的账号**看到的是硬边不透明面板。现在几何下沉为无条件基线, 壁纸相关的加强仍叠加在上面。窄屏内容面板同样浮起来。 ## 判据 - `test/nav-merge.test.mjs` 8 条(含自检:重构前的写法必须判红)—— 改完跑老套件 254 条**全绿却一条都没测导航结构**,结构改动必须自带判据。 - `test/manual/nav-restructure-verify.mjs`:真浏览器 9 条(导航项数、页签切换真的换内容、 加号 56×56 且 `elementFromPoint` 命中自己、我的页底部管理入口在退出登录之后、 控件档 α=0.5 对正文面 α=0.88 的反向对照)。 - `test/manual/narrow-glass-verify.mjs` 6 条:悬浮几何(三边留缝 10px、圆角 14px、 缝隙处命中的是页面底 ⇒ 真的浮着)、玻璃(α=0.82 + blur18)、触摸高度最小 49px、 动画真的在跑(静止时 `animationName=none`,切换后 =pane-in)且 reduced-motion 下不动。 - `test/background.test.mjs`:把三条**过时判据**(早前那版"越透越好")按现行契约重写 —— 旧判据留着只会把我拽回错误方向。30 条全绿。 ## 顺带 窄屏探针原先只调视口、**没模拟触屏**,于是 `(hover: hover)` 仍为真, 把「悬停才显形的次要动作」误报成透明 —— 差点去"修"一个本来就对的规则。 已改成 hasTouch + isMobile + DPR 的触屏上下文,且收尾只关自己的 context (CDP 连的是共享 Chromium,`browser.close()` 会把别人的标签页一起关掉)。
277 lines
10 KiB
JavaScript
277 lines
10 KiB
JavaScript
/**
|
||
* 窄屏实测辅助:连本机共享 Chromium(CDP 127.0.0.1:9222)量真实盒子。
|
||
*
|
||
* 这是**手工脚本**,不进 `npm test` —— 它需要一个跑着的浏览器与一个活的
|
||
* Gateway。日常回归靠 `../narrow-layout.test.mjs` 的结构性断言。
|
||
*
|
||
* 两者分工:结构性断言守住「代码写成了什么形态」,量不出「按钮实际多大、
|
||
* 点下去命中谁」。抽屉遮挡底部导航那个 bug(`elementFromPoint` 命中抽屉里的
|
||
* SVG 而不是导航按钮)只有这样才能发现。
|
||
*
|
||
* 用法:
|
||
* ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs
|
||
* ADMIN_PW=<密码> node client/electron/test/manual/wide-regression.mjs
|
||
*
|
||
* 环境变量:
|
||
* ADMIN_PW 必填,管理员密码
|
||
* AGENTMAIL_URL 目标地址,默认 https://mail.jianfgit.xyz
|
||
* CDP_URL 浏览器 CDP 端点,默认 http://127.0.0.1:9222
|
||
* PLAYWRIGHT playwright 入口,默认 /usr/lib/node_modules/playwright/index.mjs
|
||
*/
|
||
const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs';
|
||
const { chromium } = await import(PLAYWRIGHT);
|
||
const { readFile } = await import('node:fs/promises');
|
||
const { extname, join } = await import('node:path');
|
||
|
||
const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222';
|
||
const APP = (process.env.AGENTMAIL_URL || 'https://mail.jianfgit.xyz').replace(/\/$/, '');
|
||
const DIST = process.env.AGENTMAIL_DIST?.replace(/\/$/, '');
|
||
const contentTypes = {
|
||
'.css': 'text/css; charset=utf-8',
|
||
'.html': 'text/html; charset=utf-8',
|
||
'.js': 'text/javascript; charset=utf-8',
|
||
'.json': 'application/json; charset=utf-8',
|
||
'.svg': 'image/svg+xml',
|
||
'.png': 'image/png',
|
||
'.webp': 'image/webp',
|
||
'.woff2': 'font/woff2'
|
||
};
|
||
|
||
export const PHONE = { width: 390, height: 844 }; // iPhone 14 Pro
|
||
export const SMALL = { width: 320, height: 568 }; // iPhone SE 1 代
|
||
export const WIDE = { width: 1280, height: 800 };
|
||
|
||
export async function openApp(viewport = PHONE) {
|
||
const browser = await chromium.connectOverCDP(CDP);
|
||
|
||
// ★ 只把视口调窄**不等于**手机(2026-09-14 用户:"窄屏ui你还没改完")。
|
||
//
|
||
// 桌面 Chromium 里 `(hover: hover) and (pointer: fine)` 仍然为真,于是
|
||
// 「悬停才显形的次要动作」在探针里被测成透明 —— 而真机上它们是可见的。
|
||
// 实测这类假失败把窄屏验收带偏了方向:我差点去"修"一个本来就对的规则。
|
||
// 因此这里开一个**触屏上下文**(hasTouch + isMobile + DPR),让媒体查询
|
||
// 与真机一致;只有这样才能测出"看不见却按得动"这类真问题。
|
||
const ctx = await browser.newContext({
|
||
viewport,
|
||
hasTouch: true,
|
||
isMobile: true,
|
||
deviceScaleFactor: 3,
|
||
userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Mobile Safari/537.36'
|
||
});
|
||
const page = await ctx.newPage();
|
||
|
||
const issues = [];
|
||
page.on('pageerror', e => issues.push('pageerror: ' + String(e).slice(0, 220)));
|
||
page.on('console', m => {
|
||
if (m.type() === 'error') {
|
||
const t = m.text();
|
||
// 401 是未登录时的正常探测,不算问题
|
||
if (!t.includes('401')) issues.push('console: ' + t.slice(0, 200));
|
||
}
|
||
});
|
||
|
||
// 在不替换、不重启现有 Gateway 的前提下验证本次构建:仅把页面壳和静态资源
|
||
// 从 dist 注入当前标签页,API/SSE 仍由 APP 指向的真实 Gateway 提供。
|
||
if (DIST) {
|
||
const appOrigin = new URL(APP).origin;
|
||
await page.route(`${appOrigin}/**`, async route => {
|
||
const url = new URL(route.request().url());
|
||
if (url.pathname.startsWith('/api/')) {
|
||
await route.continue();
|
||
return;
|
||
}
|
||
const relative = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '');
|
||
const file = join(DIST, relative);
|
||
if (!file.startsWith(DIST + '/') && file !== join(DIST, 'index.html')) {
|
||
await route.abort('blockedbyclient');
|
||
return;
|
||
}
|
||
try {
|
||
const body = await readFile(file);
|
||
await route.fulfill({
|
||
status: 200,
|
||
body,
|
||
contentType: contentTypes[extname(file)] || 'application/octet-stream'
|
||
});
|
||
} catch {
|
||
await route.continue();
|
||
}
|
||
});
|
||
}
|
||
|
||
// 用完只关自己的上下文(CDP 连的是**共享**浏览器,close() 会把别人的标签页一起干掉)
|
||
// 不能用 networkidle:SSE 是一条永不结束的长连接,networkidle 永远不触发
|
||
await page.goto(APP + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(2500);
|
||
|
||
// 需要登录时登录(同一 context 共享 cookie,通常只登一次)
|
||
if ((await page.locator('input[autocomplete=username]').count()) > 0) {
|
||
if (!process.env.ADMIN_PW) throw new Error('需要 ADMIN_PW 环境变量');
|
||
await page.fill('input[autocomplete=username]', process.env.ADMIN_USER || 'admin');
|
||
await page.fill('input[type=password]', process.env.ADMIN_PW);
|
||
await page.click('button:has-text("登录")');
|
||
await page.waitForTimeout(3500);
|
||
}
|
||
return { browser, ctx, page, issues };
|
||
}
|
||
|
||
/** 量一个元素的盒子;不存在返回 null。 */
|
||
export async function box(page, sel) {
|
||
const el = page.locator(sel).first();
|
||
if ((await el.count()) === 0) return null;
|
||
return await el.boundingBox();
|
||
}
|
||
|
||
/**
|
||
* 有没有横向溢出 —— 窄屏最常见的毛病。
|
||
*
|
||
* 只报 `right` 超过文档宽度的元素:溢出到左边通常是有意的负 margin。
|
||
*/
|
||
export async function overflowX(page) {
|
||
return await page.evaluate(() => {
|
||
const de = document.documentElement;
|
||
const over = [];
|
||
for (const el of document.querySelectorAll('*')) {
|
||
const r = el.getBoundingClientRect();
|
||
if (r.width > 0 && r.right > de.clientWidth + 1) {
|
||
over.push({
|
||
tag: el.tagName.toLowerCase(),
|
||
cls: (el.className || '').toString().slice(0, 70),
|
||
right: Math.round(r.right),
|
||
text: (el.textContent || '').trim().slice(0, 40)
|
||
});
|
||
}
|
||
}
|
||
return {
|
||
docWidth: de.clientWidth,
|
||
scrollWidth: de.scrollWidth,
|
||
bodyScrollWidth: document.body.scrollWidth,
|
||
offenders: over.slice(0, 8)
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 点击目标够不够大。
|
||
*
|
||
* 量的是**视觉尺寸**;有 `.tap` 的按钮视觉上仍然很小,命中区在
|
||
* `::after` 伪元素上 —— 用 tapTargets() 才能看到真实命中区。
|
||
*/
|
||
export async function smallTargets(page, min = 40) {
|
||
return await page.evaluate(min => {
|
||
const bad = [];
|
||
for (const el of document.querySelectorAll(
|
||
'button, a, [role=button], input[type=checkbox]'
|
||
)) {
|
||
const r = el.getBoundingClientRect();
|
||
if (r.width === 0 || r.height === 0) continue; // 隐藏的不算
|
||
if (r.height < min || r.width < min) {
|
||
bad.push({
|
||
tag: el.tagName.toLowerCase(),
|
||
w: Math.round(r.width),
|
||
h: Math.round(r.height),
|
||
text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 28)
|
||
});
|
||
}
|
||
}
|
||
return bad;
|
||
}, min);
|
||
}
|
||
|
||
/**
|
||
* 量 `.tap` 按钮的真实命中区(`::after` 伪元素的尺寸)。
|
||
*
|
||
* @param labels 只看这些文字的按钮
|
||
*/
|
||
export async function tapTargets(page, labels) {
|
||
return await page.evaluate(labels => {
|
||
const out = [];
|
||
for (const b of document.querySelectorAll('button')) {
|
||
const t = (b.textContent || '').trim();
|
||
if (labels.length && !labels.includes(t)) continue;
|
||
const bb = b.getBoundingClientRect();
|
||
if (bb.width === 0) continue;
|
||
const cs = getComputedStyle(b, '::after');
|
||
out.push({
|
||
t,
|
||
visual: `${Math.round(bb.width)}x${Math.round(bb.height)}`,
|
||
hitW: Math.round(parseFloat(cs.width) || 0),
|
||
hitH: Math.round(parseFloat(cs.height) || 0)
|
||
});
|
||
}
|
||
return out;
|
||
}, labels);
|
||
}
|
||
|
||
/**
|
||
* 每个元素点下去是否命中自己。
|
||
*
|
||
* 这是抽屉遮挡 bug 的检测手段:按钮明明在那里、尺寸也够,
|
||
* 但上面盖了一层 `fixed z-50`,`elementFromPoint` 命中的是别人。
|
||
*/
|
||
export async function hitTest(page, selector) {
|
||
return await page.evaluate(selector => {
|
||
const out = [];
|
||
for (const el of document.querySelectorAll(selector)) {
|
||
const bb = el.getBoundingClientRect();
|
||
if (bb.width === 0) continue;
|
||
const top = document.elementFromPoint(
|
||
Math.round(bb.x + bb.width / 2),
|
||
Math.round(bb.y + bb.height / 2)
|
||
);
|
||
out.push({
|
||
text: (el.textContent || '').replace(/\s+/g, '').slice(0, 8),
|
||
hit: el.contains(top) || el === top
|
||
});
|
||
}
|
||
return out;
|
||
}, selector);
|
||
}
|
||
|
||
/**
|
||
* 每个页面是否**有**纵向滚动容器。
|
||
*
|
||
* 判据是「存在 overflow-y:auto|scroll 的容器」,不是「当前正在滚动」——
|
||
* 内容暂时不够高时后者为假,但页面是健康的。真正的 bug 是**根本没有**滚动
|
||
* 容器:内容一旦超过视口就被 `overflow-hidden` 的父级裁掉,没有任何办法看到。
|
||
*
|
||
* 「我的」页就是这样坏的:内容(资料+权限+改密码+密钥+退出)在 390px 下需要
|
||
* 860px,容器只有 795px,超出那 65px 连同「退出登录」按钮一起消失。
|
||
*
|
||
* @returns {{ hasScroller: boolean, scrollers: object[], clipped: object[] }}
|
||
*/
|
||
export async function scrollHealth(page) {
|
||
return await page.evaluate(() => {
|
||
const root = document.querySelector('#root');
|
||
const scrollers = [];
|
||
for (const el of root.querySelectorAll('*')) {
|
||
const cs = getComputedStyle(el);
|
||
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') {
|
||
scrollers.push({
|
||
cls: (el.className || '').toString().slice(0, 45),
|
||
scrollH: el.scrollHeight,
|
||
clientH: el.clientHeight,
|
||
needsScroll: el.scrollHeight > el.clientHeight + 4
|
||
});
|
||
}
|
||
}
|
||
// 内容超出但被 overflow:hidden 的父级裁掉 —— 这才是真正的问题
|
||
const clipped = [];
|
||
for (const el of root.querySelectorAll('*')) {
|
||
const cs = getComputedStyle(el);
|
||
if (el.scrollHeight > el.clientHeight + 20 && cs.overflowY === 'visible') {
|
||
const p = el.parentElement;
|
||
const pcs = p ? getComputedStyle(p) : null;
|
||
if (pcs && (pcs.overflow === 'hidden' || pcs.overflowY === 'hidden')) {
|
||
clipped.push({
|
||
cls: (el.className || '').toString().slice(0, 50),
|
||
have: el.clientHeight,
|
||
need: el.scrollHeight
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return { hasScroller: scrollers.length > 0, scrollers, clipped: clipped.slice(0, 4) };
|
||
});
|
||
}
|