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()` 会把别人的标签页一起关掉)。
This commit is contained in:
2026-09-14 10:35:05 +08:00
parent 552fbc731e
commit 76ce201c3a
15 changed files with 911 additions and 107 deletions

View File

@ -0,0 +1,204 @@
/**
* 导航重构的真实渲染验收(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;