用 playwright 连本机共享 Chromium,在 390px(iPhone 14 Pro)与 320px (iPhone SE)量真实盒子。之前的窄屏适配是「照着规则写对」,实测发现 一个功能性 bug 加五处可用性问题。 ## 抽屉式侧栏遮挡底部导航(真 bug) 抽屉是 `fixed left-0 top-0 bottom-0 z-50`,铺满整个视口高度;底部导航没有 z-index。抽屉打开时点最左那一项「收件」,elementFromPoint 命中的是抽屉里的 SVG,不是导航按钮 —— 按钮在那里、尺寸也够、CSS 规则也没写错,只有命中测试 才能发现。 **删掉抽屉而不是给导航加 z-index**:抽屉装的六项(收件/发件/联系/用户/新建/ 管理)与底部导航完全重复,唯一独有的是退出登录。为一个按钮维护一套 fixed 层级加遮罩不划算,而它还附带「Esc 关不掉」「底层未锁滚」两个毛病。 退出登录移到「我的」页 —— 它与密码、密钥同属「账号自身」,而那页此前根本 没有退出入口。`NavToggle` 与 uiStore 的 navOpen/toggleNav/closeNav 一并删除。 ## 触摸命中区:新增 .tap 详情页那排工具按钮视觉高度只有 15-16px(实测「标记已读」48x16、「对话树」 54x16、「转发」42x16、「抄送」20x15),移动端下限是 44x44。 直接加 padding 会把本来就挤的头部撑散、320px 下换行,因此用居中的透明伪元素 扩大命中区:**视觉一像素不动**。只在 max-width:767px 生效 —— 桌面用鼠标精度 足够,而扩大后的命中区在密排工具栏里会互相重叠,点一个可能命中隔壁那个。 覆盖 MailView / ContactPanel / WorkCard / ModelScopePanel / AdminUsersPage / ComposePage / ThreadView / KeyPanel / QuotaPanel / Attachments / BackButton。 ## 看不见却按得动的按钮:新增 .reveal `opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远是 opacity:0, **但仍然接收点击** —— 实测联系人列表里 elementFromPoint 命中的就是那个看不见的 「归档」。一个看不见却按得动的破坏性按钮比没有按钮更糟:人以为点的是卡片, 实际归档了一条会话。 改为默认可见,只在 `(hover: hover) and (pointer: fine)` 时隐藏。 单看 hover 会把带触摸板的平板算进去。 ## 对话树 - 缩进随屏宽自适应:固定「每级 20px、上限 8 级」= 最多 160px,320px 屏还要 去掉 px-4 的 32px 与连接线 18px,卡片只剩 110px,发件人一行直接被 truncate 吃掉。窄屏改为每级 10px、上限 5 级 - 补返回出口:原先只有「关闭」。两者语义不同 —— 返回退出整个详情栏回到列表, 关闭只收起树、留在这封邮件上 ## 把实测脚本留进仓库 `web/test/manual/`(`npm run test:narrow` / `test:wide`),不进 npm test —— 要一个跑着的浏览器加一个活的 Gateway。 留着而不是用完即删,是因为结构性断言守不住「按钮实际多大、点下去命中谁」, 而这次最严重的 bug 恰好只有 elementFromPoint 能发现。helper 里两个函数专门 为此:tapTargets() 量 .tap 的真实命中区(伪元素尺寸,不是 boundingBox), hitTest() 验每个元素点下去是否命中自己。 ## 验证 - 窄屏 13 项 + 宽屏 5 项全通过。宽屏回归特意验了两件只该在窄屏生效的事: .tap 伪元素 content 为 none、没有返回按钮 - narrow-layout.test.mjs 从 20 条扩到 28 条,逐条钉住上面每个修复 - 无横向溢出:390px 与 320px 下 scrollWidth === clientWidth - 生产已部署
174 lines
6.2 KiB
JavaScript
174 lines
6.2 KiB
JavaScript
/**
|
||
* 窄屏实测辅助:连本机共享 Chromium(CDP 127.0.0.1:9222)量真实盒子。
|
||
*
|
||
* 这是**手工脚本**,不进 `npm test` —— 它需要一个跑着的浏览器与一个活的
|
||
* Gateway。日常回归靠 `../narrow-layout.test.mjs` 的结构性断言。
|
||
*
|
||
* 两者分工:结构性断言守住「代码写成了什么形态」,量不出「按钮实际多大、
|
||
* 点下去命中谁」。抽屉遮挡底部导航那个 bug(`elementFromPoint` 命中抽屉里的
|
||
* SVG 而不是导航按钮)只有这样才能发现。
|
||
*
|
||
* 用法:
|
||
* ADMIN_PW=<密码> node web/test/manual/narrow-verify.mjs
|
||
* ADMIN_PW=<密码> node web/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 CDP = process.env.CDP_URL || 'http://127.0.0.1:9222';
|
||
const APP = (process.env.AGENTMAIL_URL || 'https://mail.jianfgit.xyz').replace(/\/$/, '');
|
||
|
||
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);
|
||
const ctx = browser.contexts()[0] ?? (await browser.newContext());
|
||
const page = await ctx.newPage();
|
||
await page.setViewportSize(viewport);
|
||
|
||
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));
|
||
}
|
||
});
|
||
|
||
// 不能用 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, 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);
|
||
}
|