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:
@ -169,12 +169,29 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(
|
||||
for (let i = 1; i < layers; i++) opaque = opaque + nested * (1 - opaque);
|
||||
return opaque;
|
||||
};
|
||||
const ctrl = Number((css.match(/--bg-glass-control:\s*([\d.]+)/) || [])[1]);
|
||||
const stacked = composite(glass, inner, 2);
|
||||
check('玻璃有"嵌套层"变量', Number.isFinite(glass) && Number.isFinite(inner), `glass=${glass} inner=${inner}`);
|
||||
check('有"嵌套面板不再各叠一次"的规则', /\.bg-white \.bg-white \{/.test(css));
|
||||
check('两层嵌套后的有效不透明度 ≤ 0.85', stacked <= 0.85, `实际 ${stacked.toFixed(3)}`);
|
||||
check('外层玻璃 ≤ 0.70', glass <= 0.7, `实际 ${glass}`);
|
||||
check('判据自检:旧值 0.82 层层叠必须算得出 >0.95', composite(0.82, 0.82, 2) > 0.95);
|
||||
/*
|
||||
* ★ 这三条 2026-09-14 重写(原先断言的是"越透越好",方向是错的)。
|
||||
*
|
||||
* 我第一版按"内容更通透"把正文面压到 0.45,用户当场指出:「你把大量需要打底的
|
||||
* 场景(弹窗正文等)改为了透明。真正该透明的地方(空白区域)加了很浓的模糊」。
|
||||
* 于是判据改成**两类面分开**:
|
||||
* - 承载文字的面:必须够实(0.8–0.95),否则字压在壁纸上读不动;
|
||||
* - 空白/页面底:透明且不模糊(另有用例)。
|
||||
* 旧的"≤0.70 / ≤0.85"留着只会把我再拽回那个错误方向,所以连同理由一起改掉。
|
||||
*/
|
||||
check('正文面够实(0.80–0.95,读得清优先)', glass >= 0.8 && glass <= 0.95, `实际 ${glass}`);
|
||||
check('嵌套面比外层透、但仍打底(0.70–外层)', inner >= 0.7 && inner <= glass, `glass=${glass} inner=${inner}`);
|
||||
check('两层嵌套后仍接近实心(≥0.9)', composite(glass, inner, 2) >= 0.9, `实际 ${stacked.toFixed(3)}`);
|
||||
check(
|
||||
'控件档最透,且比嵌套面更透(复选框/地址建议)',
|
||||
ctrl <= 0.6 && ctrl < inner,
|
||||
`control=${ctrl} inner=${inner}`
|
||||
);
|
||||
check('判据自检:两档一样透是分不出层次的(必须算得出 ≥0.9)', composite(0.82, 0.82, 2) >= 0.9);
|
||||
|
||||
// 17) ★ 浅色表面类的接管清单必须跟着源码走。
|
||||
//
|
||||
@ -210,11 +227,21 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(
|
||||
const navNoBlur = /html\[data-bg='on'\] \.app-shell > \.bg-chrome-900 \{[\s\S]{0,160}backdrop-filter: none/.test(css);
|
||||
check('导航栏在背景模式下完全不透明', opaqueNav);
|
||||
check('导航栏不再参与模糊', navNoBlur);
|
||||
check('内容面板更通透(--bg-glass ≤ 0.55)', glass <= 0.55, `实际 ${glass}`);
|
||||
check('外壳留缝(浮动面板布局)',
|
||||
/html\[data-bg='on'\] \.app-shell \{[\s\S]{0,80}padding: var\(--pane-gap\)/.test(css));
|
||||
check('顶层面板圆角化',
|
||||
/html\[data-bg='on'\] \.app-shell > \* \{[\s\S]{0,140}border-radius: var\(--radius-card\)/.test(css));
|
||||
/*
|
||||
* ★ 浮动面板几何必须**无条件**生效(2026-09-14 用户:「通信页面大面积缺失圆角与
|
||||
* 玻璃效果,所有有内容与无内容区域都是硬截断」)。
|
||||
*
|
||||
* 根因:这些规则原先全写在 `html[data-bg='on']` 里 ⇒ 没开壁纸的账号看到硬边面板。
|
||||
* 所以判据不能再带 data-bg 前缀 —— 带前缀等于把缺陷写进判据。
|
||||
*/
|
||||
check('外壳留缝与圆角是无条件的(不只壁纸模式)',
|
||||
/(?<!data-bg='on'\] )\.app-shell \{[\s\S]{0,80}padding: var\(--pane-gap\)/.test(css) &&
|
||||
/(?<!data-bg='on'\] )\.app-shell > \* \{[\s\S]{0,160}border-radius: var\(--radius-card\)/.test(css));
|
||||
check('窄屏内容面板也浮起来(圆角)',
|
||||
/\.narrow-shell > \*:not\(\.narrow-nav\) \{[\s\S]{0,80}border-radius: var\(--radius-card\)/.test(css));
|
||||
check('窄屏底部导航是悬浮玻璃(留缝 + 模糊)',
|
||||
/\.narrow-nav \{[\s\S]{0,220}backdrop-filter: blur\(/.test(css) &&
|
||||
/\.narrow-nav \{[\s\S]{0,120}margin: 0 10px/.test(css));
|
||||
|
||||
console.log(`\n背景:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
|
||||
120
client/electron/test/manual/narrow-glass-verify.mjs
Normal file
120
client/electron/test/manual/narrow-glass-verify.mjs
Normal file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 窄屏悬浮玻璃导航 + 视图切换动画的真实验收。
|
||||
*
|
||||
* 用户两条原话:
|
||||
* 「窄屏布局的导航栏还是固定底部延伸,没有玻璃效果,也没有悬浮」
|
||||
* 「页面极度缺少动画,所有页面都是直接出现」
|
||||
*
|
||||
* 判据只量**可观测的东西**:几何(留缝/圆角)、玻璃(半透明 + 模糊)、动画(真的在跑)。
|
||||
* 每项都配反向对照,否则"没有动画"和"有动画但没触发"分不开。
|
||||
*
|
||||
* 用法:AGENTMAIL_DIST=<dist> ADMIN_USER=.. ADMIN_PW=.. node test/manual/narrow-glass-verify.mjs
|
||||
*/
|
||||
import { openApp } from './narrow-probe-helper.mjs';
|
||||
|
||||
const PHONE = { width: 390, height: 844 };
|
||||
const results = [];
|
||||
const record = (name, ok, note) => {
|
||||
results.push({ name, ok });
|
||||
console.log(` ${ok ? '通过' : '失败'} ${name}${note ? ' — ' + note : ''}`);
|
||||
};
|
||||
|
||||
const { page, ctx } = await openApp(PHONE);
|
||||
try {
|
||||
await page.waitForSelector('.narrow-nav', { timeout: 25000 });
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// ── ① 悬浮:留缝 + 圆角 ──
|
||||
const geo = await page.evaluate(() => {
|
||||
const nav = document.querySelector('.narrow-nav');
|
||||
const r = nav.getBoundingClientRect();
|
||||
const cs = getComputedStyle(nav);
|
||||
const pr = getComputedStyle(document.querySelector('.narrow-shell') || nav.parentElement);
|
||||
return {
|
||||
left: r.left,
|
||||
right: r.right,
|
||||
bottom: r.bottom,
|
||||
vw: window.innerWidth,
|
||||
vh: window.innerHeight,
|
||||
radius: cs.borderRadius,
|
||||
marginBottom: cs.marginBottom,
|
||||
parentPadBottom: pr.paddingBottom,
|
||||
pointerAtGap: document.elementFromPoint(r.left - 4, r.top + 4)?.className?.toString().slice(0, 40) || ''
|
||||
};
|
||||
});
|
||||
record(
|
||||
'① 导航不再贴底通栏:左右留缝、离底留缝、有圆角',
|
||||
geo.left >= 6 && geo.vw - geo.right >= 6 && geo.vh - geo.bottom >= 6 && parseFloat(geo.radius) >= 8,
|
||||
`left=${Math.round(geo.left)} right缝=${Math.round(geo.vw - geo.right)} bottom缝=${Math.round(geo.vh - geo.bottom)} radius=${geo.radius}`
|
||||
);
|
||||
record(
|
||||
'① 左边缝露出来的是页面底(说明它真的浮在上面)',
|
||||
!/narrow-nav/.test(geo.pointerAtGap),
|
||||
`缝隙处命中=${geo.pointerAtGap || '(页面底)'}`
|
||||
);
|
||||
|
||||
// ── ② 玻璃:半透明 + 模糊 ──
|
||||
const glass = await page.evaluate(() => {
|
||||
const cs = getComputedStyle(document.querySelector('.narrow-nav'));
|
||||
const bg = cs.backgroundColor.match(/rgba?\(([^)]+)\)/);
|
||||
const a = bg ? (bg[1].split(',').length === 4 ? parseFloat(bg[1].split(',')[3]) : 1) : null;
|
||||
const bf = cs.backdropFilter || cs.webkitBackdropFilter || 'none';
|
||||
return { alpha: a, blur: bf };
|
||||
});
|
||||
record(
|
||||
'② 导航是玻璃:半透明 + 背景模糊',
|
||||
glass.alpha !== null && glass.alpha > 0.4 && glass.alpha < 0.98 && /blur\((\d+)/.test(glass.blur),
|
||||
`α=${glass.alpha} backdrop=${glass.blur}`
|
||||
);
|
||||
|
||||
// ── ③ 触摸目标仍然达标(悬浮不能把可点面积改小)──
|
||||
const taps = await page.$$eval('.narrow-nav button', els =>
|
||||
els.map(e => {
|
||||
const r = e.getBoundingClientRect();
|
||||
return { w: Math.round(r.width), h: Math.round(r.height), label: e.textContent.trim().slice(0, 4) };
|
||||
})
|
||||
);
|
||||
const small = taps.filter(t => t.h < 44);
|
||||
record('③ 每项触摸高度 ≥44px', taps.length >= 4 && small.length === 0,
|
||||
`${taps.length} 项,最小 ${Math.min(...taps.map(t => t.h))}px`);
|
||||
|
||||
// ── ④ 动画:切换页签时面板真的在跑 pane-in ──
|
||||
const animBefore = await page.evaluate(
|
||||
() => getComputedStyle(document.querySelector('.narrow-shell > *')).animationName
|
||||
);
|
||||
await page.click('.narrow-nav button:has-text("日历")');
|
||||
await page.waitForTimeout(60);
|
||||
const during = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const panes = [...document.querySelectorAll('.narrow-shell > *')];
|
||||
return {
|
||||
hasClass: root.classList.contains('view-switch'),
|
||||
names: panes.map(p => getComputedStyle(p).animationName).filter(n => n !== 'none')
|
||||
};
|
||||
});
|
||||
record(
|
||||
'④ 切视图时面板在跑入场动画(反向对照:静止时不跑)',
|
||||
animBefore === 'none' && during.names.length > 0 && during.names.every(n => n === 'pane-in'),
|
||||
`静止 animationName=${animBefore};切换后 class=${during.hasClass} 动画=[${during.names.join(',')}]`
|
||||
);
|
||||
|
||||
// ── ⑤ 动画必须尊重 reduced-motion ──
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.click('.narrow-nav button:has-text("联系人")');
|
||||
await page.waitForTimeout(60);
|
||||
const reduced = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('.narrow-shell > *')]
|
||||
.map(p => getComputedStyle(p).animationName)
|
||||
.filter(n => n !== 'none')
|
||||
);
|
||||
record('⑤ 系统关了动画时一点都不动', reduced.length === 0, `动画=[${reduced.join(',')}]`);
|
||||
await page.emulateMedia({ reducedMotion: null });
|
||||
} 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;
|
||||
@ -43,9 +43,22 @@ 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());
|
||||
|
||||
// ★ 只把视口调窄**不等于**手机(2026-09-14 用户:"窄屏ui你还没改完")。
|
||||
//
|
||||
// 桌面 Chromium 里 `(hover: hover) and (pointer: fine)` 仍然为真,于是
|
||||
// 「悬停才显形的次要动作」在探针里被测成透明 —— 而真机上它们是可见的。
|
||||
// 实测这类假失败把窄屏验收带偏了方向:我差点去"修"一个本来就对的规则。
|
||||
// 因此这里开一个**触屏上下文**(hasTouch + isMobile + DPR),让媒体查询
|
||||
// 与真机一致;只有这样才能测出"看不见却按得动"这类真问题。
|
||||
const ctx = await browser.newContext({
|
||||
viewport,
|
||||
hasTouch: true,
|
||||
isMobile: true,
|
||||
deviceScaleFactor: 3,
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Mobile Safari/537.36'
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
await page.setViewportSize(viewport);
|
||||
|
||||
const issues = [];
|
||||
page.on('pageerror', e => issues.push('pageerror: ' + String(e).slice(0, 220)));
|
||||
@ -86,6 +99,7 @@ export async function openApp(viewport = PHONE) {
|
||||
});
|
||||
}
|
||||
|
||||
// 用完只关自己的上下文(CDP 连的是**共享**浏览器,close() 会把别人的标签页一起干掉)
|
||||
// 不能用 networkidle:SSE 是一条永不结束的长连接,networkidle 永远不触发
|
||||
await page.goto(APP + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(2500);
|
||||
@ -98,7 +112,7 @@ export async function openApp(viewport = PHONE) {
|
||||
await page.click('button:has-text("登录")');
|
||||
await page.waitForTimeout(3500);
|
||||
}
|
||||
return { browser, page, issues };
|
||||
return { browser, ctx, page, issues };
|
||||
}
|
||||
|
||||
/** 量一个元素的盒子;不存在返回 null。 */
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
SMALL
|
||||
} from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page, issues } = await openApp();
|
||||
const { browser, ctx, page, issues } = await openApp();
|
||||
const failed = [];
|
||||
|
||||
async function check(name, fn) {
|
||||
@ -169,6 +169,9 @@ await check('320px 模型范围排序按钮命中区达标', async () => {
|
||||
console.log('\nissues:', issues.length ? issues : '无');
|
||||
console.log(failed.length === 0 ? '\n窄屏实测:全部通过' : `\n窄屏实测:${failed.length} 项失败 — ${failed.join(', ')}`);
|
||||
|
||||
// ★ 只关自己开的上下文再断开:CDP 连的是共享 Chromium,
|
||||
// browser.close() 会把**别人的**标签页也一起关掉(本机多个 agent 共用 9222)。
|
||||
await page.close();
|
||||
await browser.close();
|
||||
await ctx.close();
|
||||
// 共享浏览器(CDP 9222)不能 close:那是所有 agent 共用的实例。
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
|
||||
204
client/electron/test/manual/nav-restructure-verify.mjs
Normal file
204
client/electron/test/manual/nav-restructure-verify.mjs
Normal 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;
|
||||
108
client/electron/test/nav-merge.test.mjs
Normal file
108
client/electron/test/nav-merge.test.mjs
Normal file
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 导航重构的判据(2026-09-14 用户三条要求)。
|
||||
*
|
||||
* 用户原话:
|
||||
* ①「导航栏的内容有点多了,收件发件授权改为一个导航项,通过内部导航区分,
|
||||
* 然后新建作为他们内部的一个悬浮的圆形加号,这样导航项就只剩通信,日历,联系人」
|
||||
* ②「将管理和我的合并,管理员视角我的页面拉到最下面有一个管理」
|
||||
* ③「复选框和地址猜测项的透明度问题还没改,他们才是真正需要拉低透明度的地方」
|
||||
*
|
||||
* 为什么写文件级判据:改完跑老套件 254 条**全绿**,因为它一条都没测导航结构 ——
|
||||
* 我差点把"没红的测试"当成"改对了"。结构类改动必须自己带判据。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const read = (...p) => readFileSync(join(HERE, '..', 'src', ...p), 'utf8');
|
||||
|
||||
const sidebar = read('components', 'Sidebar.tsx');
|
||||
const narrow = read('components', 'NarrowNav.tsx');
|
||||
const commTabs = read('components', 'CommTabs.tsx');
|
||||
const app = read('App.tsx');
|
||||
const account = read('components', 'AccountPage.tsx');
|
||||
const uiStore = read('stores', 'uiStore.ts');
|
||||
const css = read('index.css');
|
||||
const mailView = read('components', 'MailView.tsx');
|
||||
const addr = read('components', 'AddressInput.tsx');
|
||||
|
||||
test('① 导航只剩 通信/日历/联系人 三项(桌面与窄屏都要)', () => {
|
||||
for (const [name, src] of [['Sidebar', sidebar], ['NarrowNav', narrow]]) {
|
||||
// 三项都在
|
||||
assert.match(src, /短:\s*'通信'|short:\s*'通信'/, `${name} 缺「通信」`);
|
||||
assert.match(src, /'日历'/, `${name} 缺「日历」`);
|
||||
assert.match(src, /'联系人'|'联系'/, `${name} 缺「联系人」`);
|
||||
// 旧的独立项必须消失
|
||||
for (const gone of ['收件', '发件', '授权请求', '用户管理']) {
|
||||
assert.ok(!src.includes(`'${gone}'`), `${name} 里还留着旧的独立导航项「${gone}」`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('① 「通信」的选中态覆盖三个子页签,点击回到上次那个', () => {
|
||||
assert.match(sidebar, /modes:\s*\['inbox',\s*'sent',\s*'permissions'\]/);
|
||||
assert.match(narrow, /modes:\s*\['inbox',\s*'sent',\s*'permissions'\]/);
|
||||
assert.match(sidebar, /setViewMode\(target \|\| commTab/, '桌面点通信要回 commTab');
|
||||
assert.match(narrow, /setViewMode\(isComm \? commTab : mode\)/, '窄屏点通信要回 commTab');
|
||||
});
|
||||
|
||||
test('① 内部导航(CommTabs)存在、挂在通信页、且带徽标', () => {
|
||||
assert.match(commTabs, /data-testid="comm-tabs"/);
|
||||
assert.match(commTabs, /'收件箱'[\s\S]*'发件箱'[\s\S]*'授权'/, '三个内部页签');
|
||||
assert.match(commTabs, /pendingPerms/, '授权徽标不能因为合并而消失');
|
||||
assert.match(app, /<CommTabs \/>/, 'App 里要渲染内部导航');
|
||||
assert.match(app, /const isComm = viewMode === 'inbox'/, '只挂在通信三个页签下');
|
||||
});
|
||||
|
||||
test('① 新建是悬浮圆形加号,且不再是导航项', () => {
|
||||
assert.match(commTabs, /data-testid="compose-fab"/);
|
||||
assert.match(commTabs, /rounded-full/, '必须是圆的(用户点名"圆形加号")');
|
||||
assert.match(commTabs, /absolute bottom-4 right-4/, '悬浮在列表栏右下角');
|
||||
assert.match(app, /<ComposeFab \/>/, 'App 里要渲染它');
|
||||
assert.ok(!/新建<\/span>/.test(sidebar), '桌面侧栏不该再有为"新建"的导航文字');
|
||||
assert.ok(!/新建<\/span>/.test(narrow), '窄屏导航不该再有"新建"');
|
||||
});
|
||||
|
||||
test('② 管理与我的合并:入口在「我的」最下面,且仅管理员可见', () => {
|
||||
assert.match(account, /user\?\.role === 'admin' &&/, '必须是条件渲染而不是禁用');
|
||||
const logout = account.indexOf('退出登录');
|
||||
const adminEntry = account.indexOf("setViewMode('admin')");
|
||||
assert.ok(logout > 0 && adminEntry > logout, '管理入口要在「登录状态」之后(=页面最下面)');
|
||||
assert.ok(!/mode: 'admin'/.test(sidebar), '侧栏不该再有独立的 admin 导航项');
|
||||
assert.ok(!/mode: 'admin'/.test(narrow), '窄屏不该再有独立的 admin 导航项');
|
||||
});
|
||||
|
||||
test('② uiStore 记住通信子页签,且 reset 能清掉', () => {
|
||||
assert.match(uiStore, /commTab: CommTab/);
|
||||
assert.match(uiStore, /setCommTab/);
|
||||
assert.match(uiStore, /commTab: mode as CommTab/, 'setViewMode 要顺手记住');
|
||||
assert.match(uiStore, /reset:[\s\S]{0,120}commTab: 'inbox'/, '登出后要复位');
|
||||
});
|
||||
|
||||
test('③ 复选框与地址建议用"控件档"透度(比正文面更透)', () => {
|
||||
assert.match(mailView, /glass-control/, '授权多选胶囊');
|
||||
assert.match(addr, /glass-control/, '地址建议菜单');
|
||||
// 两处都不能再是 bg-white:我的 .bg-white 接管会把它们拉回正文面那一档
|
||||
const chip = mailView.match(/glass-control[^']*/);
|
||||
assert.ok(chip, 'chip 类名');
|
||||
assert.ok(!/glass-control[^']*bg-white/.test(mailView), '不能再同时写 bg-white');
|
||||
assert.ok(!/glass-control[^"]*bg-white/.test(addr), '建议菜单不能再同时写 bg-white');
|
||||
// 三档透度必须真的递减(正文 > 嵌套 > 控件)
|
||||
const num = re => Number(css.match(re)[1]);
|
||||
const big = num(/--bg-glass:\s*(0\.\d+)/);
|
||||
const inner = num(/--bg-glass-inner:\s*(0\.\d+)/);
|
||||
const ctrl = num(/--bg-glass-control:\s*(0\.\d+)/);
|
||||
assert.ok(big > inner && inner > ctrl, `三档应递减:${big} > ${inner} > ${ctrl}`);
|
||||
assert.ok(ctrl <= 0.6, `控件档要明显更透(当前 ${ctrl})`);
|
||||
});
|
||||
|
||||
test('★ 判据自检:拿重构前的写法喂进来必须判红', () => {
|
||||
const oldSidebar = `{ short: '收件', mode: 'inbox', Icon: InboxIcon },
|
||||
{ short: '授权', title: '授权请求', mode: 'permissions', Icon: ShieldIcon }`;
|
||||
assert.ok(oldSidebar.includes("'授权请求'"), '旧写法能被本判据的"必须消失"条款命中');
|
||||
const oldChip = "'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'";
|
||||
assert.ok(!/glass-control/.test(oldChip), '旧胶囊写法不含 glass-control ⇒ 会判红');
|
||||
});
|
||||
Reference in New Issue
Block a user