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 - 生产已部署
This commit is contained in:
58
web/test/manual/README.md
Normal file
58
web/test/manual/README.md
Normal file
@ -0,0 +1,58 @@
|
||||
# 手工浏览器实测脚本
|
||||
|
||||
不进 `npm test` —— 它们需要一个跑着的 Chromium 与一个活的 Gateway。
|
||||
日常回归靠 `../narrow-layout.test.mjs`(读源码验形态,无外部依赖)。
|
||||
|
||||
## 为什么两套都要
|
||||
|
||||
结构性断言守住「代码写成了什么形态」,量不出「按钮实际多大、点下去命中谁」。
|
||||
|
||||
窄屏那轮修复里最严重的一个 bug 是抽屉式侧栏(`fixed ... z-50` 铺满视口高度)
|
||||
把底部导航最左那一项盖住 —— 按钮在那里、尺寸也够、`md:hidden` 之类的规则也
|
||||
没写错,**只有 `elementFromPoint` 才能发现它命中的是抽屉里的 SVG**。
|
||||
|
||||
## 用法
|
||||
|
||||
```bash
|
||||
# 窄屏:390px(iPhone 14 Pro)+ 320px(iPhone SE)
|
||||
ADMIN_PW=<密码> npm run test:narrow
|
||||
|
||||
# 宽屏回归:窄屏修复不能把桌面改坏
|
||||
ADMIN_PW=<密码> npm run test:wide
|
||||
```
|
||||
|
||||
环境变量:
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `ADMIN_PW` | 无(必填) | 管理员密码 |
|
||||
| `ADMIN_USER` | `admin` | 登录用户名 |
|
||||
| `AGENTMAIL_URL` | `https://mail.jianfgit.xyz` | 目标地址 |
|
||||
| `CDP_URL` | `http://127.0.0.1:9222` | 浏览器 CDP 端点 |
|
||||
| `PLAYWRIGHT` | `/usr/lib/node_modules/playwright/index.mjs` | playwright 入口 |
|
||||
|
||||
浏览器用的是本机 systemd 托管的共享 Chromium(`homeagent-browser.service`),
|
||||
通过 CDP 连上去开自己的标签页,用完关掉。没有它时先
|
||||
`systemctl start homeagent-browser`。
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `narrow-probe-helper.mjs` | 连浏览器、登录、量盒子/溢出/命中区/命中测试 |
|
||||
| `narrow-verify.mjs` | 窄屏 13 项验收 |
|
||||
| `wide-regression.mjs` | 宽屏 5 项回归 |
|
||||
|
||||
`narrow-probe-helper.mjs` 里两个函数值得单独知道:
|
||||
|
||||
- `tapTargets(page, labels)` —— 量 `.tap` 按钮的**真实**命中区(`::after`
|
||||
伪元素的尺寸)。`.tap` 刻意不改变视觉尺寸,所以只看 `boundingBox` 会误判成偏小
|
||||
- `hitTest(page, selector)` —— 每个元素点下去是否命中自己。遮挡类 bug 只能这样查
|
||||
|
||||
## 已知限制
|
||||
|
||||
headless Chromium 报告 `hover: none`,因此 `.reveal`(只在支持悬停的设备上隐藏)
|
||||
在这里永远是可见的 —— 脚本只能验「触摸设备上可见」这一半,
|
||||
「鼠标设备上隐藏」那一半靠 `../narrow-layout.test.mjs` 检查 CSS 规则存在。
|
||||
|
||||
没有像素级视觉比对:字体差异下极脆,维护成本高于收益。
|
||||
173
web/test/manual/narrow-probe-helper.mjs
Normal file
173
web/test/manual/narrow-probe-helper.mjs
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 窄屏实测辅助:连本机共享 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);
|
||||
}
|
||||
149
web/test/manual/narrow-verify.mjs
Normal file
149
web/test/manual/narrow-verify.mjs
Normal file
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 窄屏实测验收: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);
|
||||
71
web/test/manual/wide-regression.mjs
Normal file
71
web/test/manual/wide-regression.mjs
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 宽屏回归:窄屏修复不能把桌面布局改坏。
|
||||
*
|
||||
* 特别是两个只该在窄屏生效的东西:
|
||||
* - `.tap` 的伪元素命中区(桌面密排工具栏里会互相重叠)
|
||||
* - `BackButton`(宽屏列表与详情并排,返回没有意义)
|
||||
*
|
||||
* 用法:ADMIN_PW=<密码> node web/test/manual/wide-regression.mjs
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page, issues } = await openApp(WIDE);
|
||||
const failed = [];
|
||||
const chk = (n, ok, note = '') => {
|
||||
console.log(` ${ok ? '通过' : '失败'} ${n}${note ? ' — ' + note : ''}`);
|
||||
if (!ok) failed.push(n);
|
||||
};
|
||||
|
||||
console.log('宽屏回归(1280px):');
|
||||
|
||||
const cols = await page.evaluate(() => {
|
||||
const root = document.querySelector('#root > div');
|
||||
return { n: root?.children.length, first: root?.children[0]?.className?.toString().slice(0, 30) };
|
||||
});
|
||||
chk('仍是三栏并排', cols.n === 3, `栏数=${cols.n} 首栏=${cols.first}`);
|
||||
|
||||
// 常驻侧栏是宽屏唯一的退出入口(账号页也有,两处都要在)
|
||||
const side = await page.evaluate(() => {
|
||||
const s = document.querySelector('#root > div > div');
|
||||
const btns = s
|
||||
? [...s.querySelectorAll('button')].map(b =>
|
||||
(b.getAttribute('title') || b.textContent || '').trim().slice(0, 12)
|
||||
)
|
||||
: [];
|
||||
return { w: s ? Math.round(s.getBoundingClientRect().width) : 0, btns };
|
||||
});
|
||||
chk('常驻侧栏仍有退出登录', side.btns.some(b => b.includes('退出')), `宽=${side.w}`);
|
||||
|
||||
// 宽屏不该出现返回按钮
|
||||
// 邮件行是 <button class="w-full text-left ...">
|
||||
const rows = page.locator('button.w-full.text-left');
|
||||
if ((await rows.count()) > 0) await rows.first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
const backN = await page.locator('button[aria-label="返回"], button[aria-label="会话"]').count();
|
||||
chk('没有返回按钮', backN === 0, `${backN} 个`);
|
||||
|
||||
// .tap 只在 max-width:767px 生效
|
||||
const tapWide = await page.evaluate(() => {
|
||||
const b = [...document.querySelectorAll('.tap')].find(x => x.getBoundingClientRect().height > 0);
|
||||
if (!b) return null;
|
||||
const cs = getComputedStyle(b, '::after');
|
||||
return { content: cs.content, w: cs.width, h: cs.height };
|
||||
});
|
||||
chk(
|
||||
'.tap 伪元素在宽屏不生效',
|
||||
!tapWide || tapWide.content === 'none' || tapWide.w === 'auto',
|
||||
JSON.stringify(tapWide)
|
||||
);
|
||||
|
||||
const of = await page.evaluate(() => ({
|
||||
d: document.documentElement.clientWidth,
|
||||
s: document.documentElement.scrollWidth
|
||||
}));
|
||||
chk('无横向溢出', of.s <= of.d, `doc=${of.d} scroll=${of.s}`);
|
||||
|
||||
console.log('\nissues:', issues.length ? issues : '无');
|
||||
console.log(failed.length === 0 ? '\n宽屏回归:全部通过' : `\n宽屏回归:${failed.length} 项失败`);
|
||||
|
||||
await page.close();
|
||||
await browser.close();
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
@ -66,7 +66,7 @@ check('写信页有返回出口', compose.includes('cancelCompose') && compose.i
|
||||
// 注释里提到 md:hidden 是在解释「为什么不用它」,所以先剥掉注释再查。
|
||||
const stripComments = src =>
|
||||
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
for (const f of ['BackButton', 'NavToggle', 'NarrowOnly']) {
|
||||
for (const f of ['BackButton', 'NarrowOnly']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
const code = stripComments(src);
|
||||
check(
|
||||
@ -79,6 +79,61 @@ for (const f of ['BackButton', 'NavToggle', 'NarrowOnly']) {
|
||||
const nav = read('../src/components/NarrowNav.tsx');
|
||||
check('底部导航留了安全区内边距', nav.includes('safe-area-inset-bottom'));
|
||||
|
||||
// 5.1) 抽屉式侧栏已删。
|
||||
// 它装的六项与底部导航完全重复,唯一独有的是退出登录;代价是 z-50 的
|
||||
// fixed 层铺满视口高度,把底部导航最左那一项盖住点不到
|
||||
// (实测 elementFromPoint 命中抽屉里的 SVG)。
|
||||
const app = read('../src/App.tsx');
|
||||
check(
|
||||
'窄屏没有抽屉式侧栏(它曾遮挡底部导航)',
|
||||
!app.includes('navOpen') && !app.includes('bg-black/40')
|
||||
);
|
||||
const ui = read('../src/stores/uiStore.ts');
|
||||
check('uiStore 不再有抽屉状态', !ui.includes('navOpen') && !ui.includes('toggleNav'));
|
||||
|
||||
// 5.2) 退出登录必须还有地方可点 —— 删抽屉时它是唯一的独有入口
|
||||
const account = read('../src/components/AccountPage.tsx');
|
||||
check(
|
||||
'退出登录已移到账号页(窄屏唯一出口)',
|
||||
account.includes('logout') && account.includes('退出登录')
|
||||
);
|
||||
|
||||
// 5.3) 触摸命中区:44x44 是移动端下限,而这些按钮视觉高度只有 15-24px。
|
||||
// .tap 用居中的透明伪元素扩大命中区,视觉尺寸不变。
|
||||
const css = read('../src/index.css');
|
||||
check(
|
||||
'.tap 提供 44px 触摸命中区且只在窄屏生效',
|
||||
/\.tap::after/.test(css) && css.includes('min-width: 44px') &&
|
||||
css.includes('min-height: 44px') && /max-width:\s*767px/.test(css)
|
||||
);
|
||||
// 详情页那排工具按钮是实测最小的一组(「抄送」只有 20x15)
|
||||
const viewSrc = read('../src/components/MailView.tsx');
|
||||
for (const label of ['标记已读', '对话树', '转发']) {
|
||||
const re = new RegExp('className="tap[^"]*"[^>]*>[\\s\\S]{0,120}' + label);
|
||||
check(`详情页「${label}」有 .tap 命中区`, re.test(viewSrc));
|
||||
}
|
||||
|
||||
// 5.4) 悬停才显形的次要动作在触摸设备上必须默认可见。
|
||||
// `opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远透明,
|
||||
// 却仍然接收点击 —— 一个看不见却按得动的「归档」比没有按钮更糟。
|
||||
check(
|
||||
'.reveal 只在支持悬停的设备上隐藏',
|
||||
css.includes('.reveal') && /@media\s*\(hover:\s*hover\)\s*and\s*\(pointer:\s*fine\)/.test(css)
|
||||
);
|
||||
for (const f of ['ContactPanel', 'WorkCard']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
check(
|
||||
`${f} 用 .reveal 而非裸 opacity-0 group-hover`,
|
||||
src.includes('reveal') && !src.includes('opacity-0 group-hover:opacity-100')
|
||||
);
|
||||
}
|
||||
|
||||
// 5.5) 对话树:缩进随屏宽变,且窄屏要有返回出口。
|
||||
// 固定「每级 20px、上限 8 级」在 320px 屏上把卡片压到 110px 可用宽度。
|
||||
const thread = read('../src/components/ThreadView.tsx');
|
||||
check('对话树缩进随屏宽自适应', thread.includes('useIsNarrow') && /narrow \? 10 : 20/.test(thread));
|
||||
check('对话树窄屏有返回出口', thread.includes('<BackButton'));
|
||||
|
||||
// 6) 横向内边距在窄屏收窄(px-6 在 375px 屏上白吃 48px)
|
||||
const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage'];
|
||||
for (const f of wide) {
|
||||
|
||||
Reference in New Issue
Block a user