Files
MailUI4Agents/web/test/manual/narrow-verify.mjs
JianFeeeee 342282b92c fix: 窄屏实测修复 —— 删抽屉、44px 命中区、触摸设备可见的次要动作
用 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
- 生产已部署
2026-09-02 23:53:59 +08:00

150 lines
6.0 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.

/**
* 窄屏实测验收390px 与 320px 下量真实盒子、真实命中。
*
* 每一条都对应一个曾经真实存在的问题(见 docs/PLAN.md §7.10.1
* 抽屉盖住底部导航、工具按钮只有 16px 高、看不见却按得动的「归档」、
* 对话树缩进把卡片压成竖条、对话树没有返回出口。
*
* 用法ADMIN_PW=<密码> node web/test/manual/narrow-verify.mjs
*/
import { openApp, overflowX, tapTargets, hitTest, SMALL } from './narrow-probe-helper.mjs';
const { browser, page, issues } = await openApp();
const failed = [];
async function check(name, fn) {
try {
const r = await fn();
console.log(` ${r.ok ? '通过' : '失败'} ${name}${r.note ? ' — ' + r.note : ''}`);
if (!r.ok) failed.push(name);
} catch (e) {
console.log(` 错误 ${name}${e.message.slice(0, 90)}`);
failed.push(name);
}
}
/**
* 打开收件箱里的第一封邮件。
*
* 邮件行是 `<button class="w-full text-left ...">`,不是带 cursor-pointer 的 div
* —— 按后者找会一直等到超时。
*/
async function openFirstMail(page) {
await page.click('nav button:has-text("收件")');
await page.waitForTimeout(1000);
const rows = page.locator('button.w-full.text-left');
const n = await rows.count();
if (n === 0) throw new Error('收件箱是空的,没有邮件可点');
await rows.first().click();
await page.waitForTimeout(1200);
}
console.log('窄屏实测390px');
// 抽屉已删。它是 fixed z-50 铺满视口高度,把底部导航最左那项盖住点不到。
await check('抽屉入口已移除', async () => {
const n = await page.locator('button[aria-label="打开导航"]').count();
return { ok: n === 0, note: n ? `仍有 ${n}` : '' };
});
// 删抽屉时退出登录是它唯一的独有入口,必须有新去处
await check('「我的」页有退出登录', async () => {
await page.click('nav button:has-text("我的")');
await page.waitForTimeout(1200);
const btn = page.locator('button:has-text("退出登录")');
const n = await btn.count();
const b = n ? await btn.first().boundingBox() : null;
return {
ok: n === 1 && b.height >= 36,
note: b ? `${Math.round(b.width)}x${Math.round(b.height)}` : '找不到'
};
});
// 核心回归:底部导航每一项都要命中自己
await check('底部导航每项都命中自己', async () => {
const r = await hitTest(page, 'nav button');
const miss = r.filter(x => !x.hit);
return {
ok: r.length > 0 && miss.length === 0,
note: miss.length ? '未命中: ' + miss.map(m => m.text).join(',') : `${r.length} 项全部命中`
};
});
// 详情页工具按钮:视觉 15-16px命中区必须补到 44
await check('详情页工具按钮命中区 >= 44px', async () => {
await openFirstMail(page);
const r = await tapTargets(page, ['标记已读', '对话树', '转发', '抄送', '发送', '清空']);
for (const x of r) console.log(' ', JSON.stringify(x));
const small = r.filter(x => x.hitH < 44 || x.hitW < 44);
return {
ok: r.length > 0 && small.length === 0,
note: small.length ? '仍偏小: ' + small.map(s => s.t).join(',') : `${r.length} 个都达标`
};
});
// 次要动作在触摸设备上必须可见(没有 hover 时曾经永远透明却接收点击)
await check('联系人页次要动作默认可见', async () => {
await page.click('nav button:has-text("联系人")');
await page.waitForTimeout(1200);
const r = await page.evaluate(() =>
[...document.querySelectorAll('.reveal')].slice(0, 3).map(d => getComputedStyle(d).opacity)
);
return { ok: r.length > 0 && r.every(o => o === '1'), note: `opacity=${r.join(',')}` };
});
// 对话树:窄屏要有返回出口
await check('对话树有返回出口', async () => {
await openFirstMail(page);
const t = page.locator('button:has-text("对话树")');
if ((await t.count()) === 0) return { ok: false, note: '找不到对话树入口' };
await t.first().click();
await page.waitForTimeout(1600);
const n = await page.locator('button[aria-label="返回"]').count();
return { ok: n >= 1, note: `${n}` };
});
// 各页无横向溢出
for (const label of ['收件', '联系人', '管理', '我的']) {
await check(`${label}页无横向溢出`, async () => {
await page.click(`nav button:has-text("${label}")`);
await page.waitForTimeout(1100);
const of = await overflowX(page);
for (const o of of.offenders) console.log(' 超出:', JSON.stringify(o));
return { ok: of.scrollWidth <= of.docWidth, note: `doc=${of.docWidth} scroll=${of.scrollWidth}` };
});
}
console.log('\n最窄320px');
await page.setViewportSize(SMALL);
await page.waitForTimeout(800);
await check('320px 收件箱无横向溢出', async () => {
await page.click('nav button:has-text("收件")');
await page.waitForTimeout(1000);
const of = await overflowX(page);
return { ok: of.scrollWidth <= of.docWidth, note: `doc=${of.docWidth} scroll=${of.scrollWidth}` };
});
await check('320px 模型范围排序按钮命中区达标', async () => {
await page.click('nav button:has-text("管理")');
await page.waitForTimeout(900);
await page.click('button:has-text("模型范围")');
await page.waitForTimeout(1000);
const first = page.locator('button:has-text("dsh")').first();
if ((await first.count()) === 0) return { ok: false, note: '没有 Agent 可展开' };
await first.click();
await page.waitForTimeout(1600);
const r = await tapTargets(page, ['↑', '↓', '×']);
if (r.length === 0) return { ok: true, note: '当前没有已选模型,跳过' };
const small = r.filter(x => x.hitH < 44 || x.hitW < 44);
return { ok: small.length === 0, note: `${r.length} 个,最小 ${Math.min(...r.map(x => x.hitH))}px 高` };
});
console.log('\nissues:', issues.length ? issues : '无');
console.log(failed.length === 0 ? '\n窄屏实测全部通过' : `\n窄屏实测:${failed.length} 项失败 — ${failed.join(', ')}`);
await page.close();
await browser.close();
process.exit(failed.length === 0 ? 0 : 1);