Files
MailUI4Agents/client/electron/test/manual/nav-restructure-verify.mjs
JianFeeeee 76ce201c3a feat(webui): 导航合并 + 悬浮玻璃 + 页面动画 + 控件档透明度 + 圆角无条件生效
用户四封信(同一线索)提的六件事,都在这里:

## ① 导航合并(「导航栏的内容有点多了」)

收件箱/发件箱/授权 → 一项「通信」,进去由**内部页签**区分(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()` 会把别人的标签页一起关掉)。
2026-09-14 10:35:05 +08:00

205 lines
9.2 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.

/**
* 导航重构的真实渲染验收2026-09-14 用户三条要求)。
*
* 文件级判据test/nav-merge.test.mjs只能证明"代码里写了",证明不了"点得到、
* 看得见、量出来对"。这里在真浏览器里量:
*
* ① 导航项真的只剩 3 项(宽屏侧栏)/ 3+我的(窄屏底部)
* ② 内部页签能切换、切换后列表真的换内容
* ③ 悬浮加号命中区 >= 44px且 elementFromPoint 命中的就是它(不是被别的层压住)
* ④ 管理员在「我的」页最下面真的看得到管理入口
* ⑤ 控件档透度**真的**比正文面低(反向对照:两类面的 alpha 必须分得开)
*
* 用法node client/electron/test/manual/nav-restructure-verify.mjs
*/
import { openApp, tapTargets } from './narrow-probe-helper.mjs';
const WIDE = { width: 1280, height: 900 };
const PHONE = { width: 390, height: 844 };
const results = [];
function record(name, ok, note) {
results.push({ name, ok });
console.log(` ${ok ? '通过' : '失败'} ${name}${note ? ' — ' + note : ''}`);
}
// ── 宽屏 ─────────────────────────────────────────────────────────────
{
const { page, ctx } = await openApp(WIDE);
try {
await page.waitForSelector('.w-\\[60px\\] , .narrow-nav', { timeout: 25000 });
// 侧栏根是 div.w-[60px](不是 nav 元素)——之前用 'nav' 选择器什么也没等到。
// 标签取"按钮文字去掉徽标数字":带徽标的项最后一个 span 是徽标,
// 按 span:last-child 取会把「通信」「联系人」整条丢掉(判据自己的 bug实测踩到
const navLabels = await page.$$eval('.w-\\[60px\\] button', els =>
els.map(e => e.textContent.replace(/[0-9]+\+?/g, '').trim()).filter(Boolean)
);
const commCount = navLabels.filter(t => t === '通信').length;
const stale = navLabels.filter(t => ['收件', '发件', '授权', '新建', '用户'].includes(t));
record(
'① 宽屏侧栏只剩 通信/日历/联系(+我的)',
commCount === 1 && stale.length === 0,
`标签=[${navLabels.join(',')}] 残留=[${stale.join(',')}]`
);
// ② 内部页签:切换后列表标题/内容变化
if (await page.locator('[data-testid="comm-tabs"]').count()) {
const tabs = await page.$$eval('[data-testid="comm-tabs"] button', els =>
els.map(e => e.textContent.trim())
);
record(
'① 通信内部有三个页签(收件箱/发件箱/授权)',
tabs.length === 3 && tabs[0].includes('收件箱') && tabs[2].includes('授权'),
`页签=[${tabs.join(' | ')}]`
);
const listText = async () =>
(await page.locator('[data-testid="comm-tabs"]').locator('xpath=..').innerText()).slice(0, 400);
const before = await listText();
await page.click('[data-testid="comm-tabs"] button:has-text("发件箱")');
await page.waitForTimeout(700);
const after = await listText();
record('② 切换页签后内容真的变了', before !== after, `${before.length} 字符 → ${after.length} 字符`);
await page.click('[data-testid="comm-tabs"] button:has-text("收件箱")');
await page.waitForTimeout(500);
} else {
record('① 通信内部有三个页签', false, '没找到 comm-tabs');
}
// ③ 悬浮加号
const fab = page.locator('[data-testid="compose-fab"]');
if (await fab.count()) {
const box = await fab.boundingBox();
const hit = await page.evaluate(() => {
const b = document.querySelector('[data-testid="compose-fab"]');
if (!b) return null;
const r = b.getBoundingClientRect();
const el = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return { inside: b.contains(el) || b === el, r: { w: r.width, h: r.height } };
});
const round = box && Math.abs(box.width - box.height) < 2;
record(
'③ 悬浮加号是圆的、命中区 ≥44px、且没被压住',
!!box && box.width >= 44 && box.height >= 44 && round && hit?.inside === true,
`尺寸=${Math.round(box?.width)}×${Math.round(box?.height)} 命中自己=${hit?.inside} 圆=${round}`
);
} else {
record('③ 悬浮加号存在', false, '没找到 compose-fab');
}
// ⑤ 透度分档(把壁纸状态直接打开,量的是 CSS 本身,不动用户保存的设置)
await page.evaluate(() => {
document.documentElement.dataset.bg = 'on';
});
await page.waitForTimeout(300);
const alpha = sel =>
page.evaluate(s => {
const el = document.querySelector(s);
if (!el) return null;
const bg = getComputedStyle(el).backgroundColor;
const m = bg.match(/rgba?\(([^)]+)\)/);
if (!m) return null;
const parts = m[1].split(',').map(x => parseFloat(x));
return parts.length === 4 ? parts[3] : 1;
}, sel);
const card = await alpha('.bg-white');
// 地址建议菜单:先在写信页的收件人框里打字触发
await page.click('[data-testid="compose-fab"]');
await page.waitForTimeout(600);
const toInput = page.locator('input[placeholder*="收件人"], input[autocomplete="off"]').first();
if (await toInput.count()) {
await toInput.fill('gui');
await page.waitForTimeout(1200);
}
const menu = await alpha('.glass-control');
record(
'⑤ 控件档(地址建议)比正文面更透',
menu !== null && card !== null && menu < card - 0.15,
`正文面 α=${card} 控件 α=${menu}`
);
} finally {
await page.close();
await ctx.close();
}
}
// ── 窄屏 ─────────────────────────────────────────────────────────────
{
const { page, ctx } = await openApp(PHONE);
try {
await page.waitForSelector('nav', { timeout: 20000 });
const labels = await page.$$eval('.narrow-nav button', els =>
els.map(e => e.textContent.replace(/[0-9]+\+?/g, '').trim()).filter(Boolean)
);
const stale = labels.filter(t => ['收件', '发件', '授权', '新建', '管理'].includes(t));
record(
'① 窄屏底部导航 = 通信/日历/联系人/我的',
labels.length === 4 && labels.includes('通信') && labels.includes('我的') && stale.length === 0,
`标签=[${labels.join(',')}] 残留=[${stale.join(',')}]`
);
const fab = page.locator('[data-testid="compose-fab"]');
const fabInfo = (await fab.count())
? await page.evaluate(() => {
const b = document.querySelector('[data-testid="compose-fab"]');
const r = b.getBoundingClientRect();
const nav = document.querySelector('.narrow-nav')?.getBoundingClientRect();
const el = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return {
w: r.width,
h: r.height,
bottom: r.bottom,
navTop: nav ? nav.top : null,
inside: b.contains(el) || b === el
};
})
: null;
record(
'③ 窄屏加号在底部导航之上、命中区达标、没被压住',
!!fabInfo &&
fabInfo.w >= 44 &&
fabInfo.h >= 44 &&
fabInfo.inside === true &&
(fabInfo.navTop === null || fabInfo.bottom <= fabInfo.navTop + 1),
fabInfo ? `${Math.round(fabInfo.w)}×${Math.round(fabInfo.h)} bottom=${Math.round(fabInfo.bottom)} navTop=${Math.round(fabInfo.navTop)}` : '没找到'
);
if (await fab.count()) {
await fab.click();
await page.waitForTimeout(900);
const composing = await page.evaluate(() =>
!!document.querySelector('textarea') && !document.querySelector('[data-testid="compose-fab"]')
);
record('② 点加号能进入写信(且加号自己让位)', composing, '');
}
// ④ 我的页最下面的管理入口(当前登录账号是否管理员决定可见性,两种都算通过)
await page.click('.narrow-nav button:has-text("我的")');
await page.waitForTimeout(900);
const adminInfo = await page.evaluate(() => {
const btns = [...document.querySelectorAll('button')];
const admin = btns.find(b => b.textContent.includes('用户管理'));
const logout = btns.find(b => b.textContent.includes('退出登录'));
if (!admin) return { found: false };
return {
found: true,
afterLogout: !!(logout && admin.getBoundingClientRect().top >= logout.getBoundingClientRect().top),
visible: admin.getBoundingClientRect().height > 0
};
});
record(
'④ 「我的」页底部有管理入口(且排在退出登录之后)',
adminInfo.found && adminInfo.afterLogout && adminInfo.visible,
adminInfo.found ? `在退出登录之后=${adminInfo.afterLogout}` : '当前账号非管理员(此判据不适用)'
);
} finally {
await page.close();
await ctx.close();
}
}
const failed = results.filter(r => !r.ok);
console.log(`\n 导航重构验收:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) console.log(' 失败:' + failed.map(f => f.name).join('; '));
process.exitCode = failed.length ? 1 : 0;