Files
MailUI4Agents/web/test/manual/narrow-verify.mjs
JianFeeeee 514f443e54 fix: 「我的」页无法滚动 —— 缺滚动容器;顺带修矮屏登录页
用户反馈「我的页面点击进入后无法滑动」。

## 根因:AccountPage 根本没有滚动容器

窄屏外壳是 `h-full flex flex-col overflow-hidden`,页面本身是
`flex-1 min-w-0 flex flex-col`,中间缺一层 `overflow-y-auto` ——
内容超出的部分**直接被裁**,滚不到也点不到。

实测 390px 下内容(资料 + 权限 + 改密码 + 密钥 + 退出)需 860px、容器只有
795px,「退出登录」按钮连同下面 65px 一起消失;1280x800 的桌面上同样看不到。

其余六个页面级组件(AdminUsersPage / MailView / ComposePage / ThreadView /
ContactPanel / MailList)都有这一层,只有它漏了 —— 上一轮把退出登录搬进这页
之后内容变高,问题才显形。

## 顺带:登录页与初始化页在矮屏滚不到底

卡片高约 371px,而 `h-full flex items-center` 在内容超高时让它上下**同时**溢出,
溢出到顶部那段是滚不到的(`scrollTop` 最小是 0)—— 实测 568x280(横屏手机,
或软键盘弹出后的可视高度)下「登录」按钮完全在视口外,光加 `overflow-y-auto`
也够不着。

改用卡片自己的 `my-auto` 而不是容器的 `items-center`:auto margin 在空间不足时
自动退化为 0,矮屏变成正常的顶对齐可滚布局,高屏仍然垂直居中
(390x844 与 1280x800 实测 centered=true,568x280 下滚到底能看到按钮)。

## 两侧都加了检查

- 结构断言:七个页面级组件都必须含 `overflow-y-auto`;
  登录/初始化页必须有 `my-auto` 且不用 `h-full ... items-center`
- 实测脚本新增 `scrollHealth()`:每页都有滚动容器,且没有内容被
  `overflow-hidden` 的父级裁掉

判据刻意是「**有**滚动容器」而不是「当前正在滚动」—— 内容暂时不够高时后者
为假,但页面是健康的;真正的 bug 是根本没有那一层。

## 验证

窄屏实测 18 项 + 宽屏回归 5 项全通过;结构断言从 28 条扩到 37 条。
生产已部署。
2026-09-03 07:39:52 +08:00

175 lines
6.8 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,
scrollHealth,
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}` };
});
}
// 每个页面都必须有纵向滚动容器 —— 否则内容一超过视口就被裁掉看不到。
// 「我的」页曾经缺这个390px 下内容需 860px、容器 795px
// 「退出登录」按钮连同下面 65px 一起消失,滚也滚不到。
for (const label of ['收件', '发件', '联系人', '管理', '我的']) {
await check(`${label}页有纵向滚动容器`, async () => {
await page.click(`nav button:has-text("${label}")`);
await page.waitForTimeout(1200);
const h = await scrollHealth(page);
for (const c of h.clipped) console.log(' 被裁:', JSON.stringify(c));
return {
ok: h.hasScroller && h.clipped.length === 0,
note: h.hasScroller
? `${h.scrollers.length} 个容器${h.clipped.length ? ',但有内容被裁' : ''}`
: '没有滚动容器'
};
});
}
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);