跨端: 2in1 键盘可达(Ctrl+N 写信 / ↑↓ 换补全 / Enter 选中)+ 三处判据被实测改判

用户 2026-09-21:「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」
「上下键切换发信目标」「回车展开输入框等」

## ① 打开发信页:Ctrl+N,用官方 `keyboardShortcut`

先查了官方文档再动手,避开两条静默失败的坑:

  · `keyboardShortcut`(组件快捷键事件,API 10+)「**无论组件是否获焦** ——
    只要窗口获焦,快捷键就会响应」。而 `onKeyEvent` 要求**组件先获焦**,
    邮件列表里焦点落在哪是不确定的(点一下就换)⇒ 用它做全局快捷键会时灵时不灵。
  · 「多个不同组件设置相同组合键 ⇒ 只响应节点树**深度最浅**的那个」。
    ⇒ 再给别处的"写信"按钮补一个 Ctrl+N 不是"多一个入口",而是**让后来那处永久失效**。
    判据因此钉"全仓只许绑一处"。

绑定位置在 `MainPage` 的**根 Stack**(窗口组件树的根)—— 绑在 FAB 上不行,
它在 `if` 分支里、窄屏/宽屏位置也不同,会随分支挂卸。

键位 `Ctrl+N` 避开了官方列出的五个**禁止绑定**组合(Alt+F4/Alt+Tab/Ctrl+Shift+Esc…)。
判据直接解析调用参数校验这三点。

## ② 根够不着 `openCompose()` ⇒ 照 `PushService` 的"两半"形状

`openCompose()` 住在**条件挂载**的 `CommPage` 上(`if (currentIndex === 0)`),
根组件拿不到它。新建 `common/ComposeIntent.ets`:

  · **格子**(`pending`)—— 用户此刻在别的页、`CommPage` 还没实例化;
  · **回调**(`listener`)—— 用户此刻就在通信页、页面早挂载完了。

缺任何一半都是**按键静默失效**:只有格子 ⇒ `aboutToAppear` 不重跑;
只有回调 ⇒ 没有监听者。这形状与 `api/PushService.ets` 处理"点通知跳转"时
踩的是同一个坑,那边注释里已写过解法 —— 这次是照着抄,不是重新踩。

## ③ 收件人三段式补全(↑↓ 切换、Enter/Tab 选中、Esc 收起)

WebUI 的逻辑原先**散在组件闭包里**(`parseParts` 没 export、`apply`/`onKeyDown`
直接改 React state)⇒ 判据根本 import 不到。先把它抽成一对纯逻辑:
`client/electron/src/lib/addressSuggest.ts` ↔ `model/AddressSuggest.ts`,
**抽的时候行为一字不改**(抽出来顺手"改进"会让判据比新行为、线上跑旧行为,两边都错)。
`AddressInput.tsx` 改接这份 lib。

`cross-client-logic` 新增 `AddressSuggest` 一对,22 个用例两边逐例比。
其中 `nextActiveIndex(0,3,-1)` 是**负下标陷阱**:JS 的 `%` 对负数返回负数
(`-1 % 5 === -1`),而负下标在数组访问里**不报错**(返回 `undefined`),
只表现为"按上键后没有任何一项高亮"。直接写 `(i-1) % n` 就会这样静默坏掉。

候选走**内联渲染**而不是 `bindPopup`/`bindMenu`:那两者各有焦点体系,
会先吃掉按键 ⇒ "↑↓ 换候选"落不到 `onToKey` 上。

## ④ 另修一个真 bug:`AddressSuggestion` 字段名整套写错

模型声明 `value`/`kind`,而服务端(`contacts.go:206`)给的是 `alias`/`title`/`source`
—— **从来没返回过** `value`/`kind`。按本仓纪律「声明了服务端从不返回的字段 ⇒ 删掉声明」改正。

顺带守住 `title` 的 `omitempty`:缺键时裸 cast 给 `undefined`(**不是**类里那个 `= ''`),
直接读会 `Cannot read property of undefined` —— 本仓在 `MailDetail.normalize()` 上
踩过同一形状(整页白屏)。判据钉住那句 `typeof … === 'string'` 的守。

## ⑤ 三处判据被**实测**改判(不是我挑一边,是拿数字定的)

### a. 卡片不该有模糊 —— 反转原断言
原判据断言「玻璃卡要走参数化 `backgroundEffect`」。那是**记录旧实现的副作用**
(重言式)。逐字读 WebUI 的 CSS:全仓 `backdrop-filter` **只有两处**
(`.glass-control` 8px/1.1、`.narrow-nav` 18px/1.5),而 `.glass-card`(`index.css:1629`)
**完全没有** —— 它的玻璃感是 `rgb(255 255 255 / 0.92)` 这个 alpha。
留着旧断言更坏:下次谁把卡片改成正确的"白 + alpha"会被判红,然后去**把模糊加回来**。

### b. 我试了"把模糊移到导航条",被实测否掉
推断「真归属是底栏」,于是把底栏改成 `backgroundEffect({radius:18, saturation:1.5})`。
实测(模拟器窄屏 1008×2232,底栏中心列 x=504):

    y      backgroundEffect        backgroundBlurStyle
    1960   rgb(191,199,209)        rgb(234,235,239)      ← 差 -36 亮度
    2060   rgb(206,211,219)        rgb(234,235,239)      ← 差 -24

⇒ `backgroundEffect` **只给模糊、不给底色**,壁纸原样透上来,底栏暗了 24~36 级。
WebUI 的 `.narrow-nav` 是**两条声明**组合的(`background-color` + `backdrop-filter`),
我只搬了后者。系统材质**同时含色调 + 模糊 + 深浅两套** ⇒ 在这里它才是正解
(§7.12 把它判成"有意差异"是对的,我的"改进"是退步)。
判据改成**反向钉住** `backgroundEffect`,并把这段实测数字留在 Theme.ets 里。

### c. 页签条判据记录的是被否掉的"胶囊"
原判据钉 `TAB_BAR_RADIUS`/`TAB_BAR_SIDE`/`TAB_BAR_TOP` —— 那正是用户否掉的形状
(「你又在内部套了一个胶囊」)。CDP 读 WebUI 的实测几何:

    .comm-pane   x=80 w=320 radius=14px overflow=hidden   ← 窗格,裁圆的是它
    tabstrip     x=80 w=320 radius=0px                    ← 条自己无圆角

设备实测(`uitest dumpLayout`):页签条 `[28,140][980,267]`、窗格 `[28,140][980,1957]`
⇒ 左右边缘逐像素相同。判据改为钉"与窗格齐平 + 只有左上角圆角"这两条**不变式**,
`TAB_BAR_RADIUS`/`TAB_BAR_SIDE` 一并**删除**(留着就是孤儿,会邀请人把胶囊拼回来)。

## ⑥ 顺带修两条判据自己的正则
`{6}` 看不见 8 位色 ⇒ 把 `glassCard`/`glassCardWall` 报成"清册过期",
**病因报错了**。改成 `{6}(?:[0-9A-Fa-f]{2})?`(不能写 `{6,8}`,那会连 7 位也放进来)。
半透明禁令改为**枚举白名单**(不是放宽:遮罩那个真实约束原样保留,
`overlay` 写成 8 位单色照样红)。

新增判据 `harmony-2in1.test.mjs` 12 条;`files=34 checks=543 pass=540 fail=2`(收尾前)。
This commit is contained in:
2026-09-21 13:29:24 +08:00
parent f83c23362a
commit 125aec1191
22 changed files with 1608 additions and 109 deletions

View File

@ -284,8 +284,36 @@ test('⑤ 宽屏 app-shell 几何:padding/gap/radius 与 WebUI 同值(一比
assert.match(theme, /static readonly paneGap: number = 10/, 'paneGap = 10(与 WebUI --pane-gap 同值)');
// Row 用 space=paneGap(gap)
assert.match(code_, /Row\(\{ space: Theme\.paneGap \}\)/, '宽屏 Row 用 space=paneGap(与 WebUI gap 同值)');
// 内容面板圆角只在宽屏启用(窄屏贴合全屏)
assert.match(code_, /borderRadius\(this\.isWide \? Theme\.glassRadius : 0\)/, '内容面板圆角宽屏 14 / 窄屏 0');
/*
* ★★ 2026-09-21 反转:原断言是
* `borderRadius(this.isWide ? Theme.glassRadius : 0)` —— 「宽屏 14 / 窄屏 0」。
*
* 那是**误读 WebUI**:它的两条 shell 规则**都给圆角**,差别只在留白 ——
*
* .app-shell > * { border-radius: var(--radius-card); } ← 宽屏
* .narrow-shell > * { border-radius: var(--radius-card); } ← 窄屏
* .app-shell { padding: var(--pane-gap); }
* .narrow-shell { padding: var(--pane-gap) var(--pane-gap) 0; } ← 只差底边
*
* 我当初把"窄屏底边留白为 0"顺手推广成了"窄屏什么都不留(含圆角)",
* 于是窄屏四个窗格全是直角 —— 用户看出来的原话:「你的邮件的圆角呢?日历的圆角呢?」
*
* 新不变式(这才是 WebUI 的真正契约):
* · 面板**必须有**圆角(两端都有);
* · 且**不得**用 `isWide ? … : 0` 这种把圆角绑到宽窄上的写法。
*/
assert.match(code_, /borderRadius\(Theme\.glassRadius\)/,
'内容面板两端都要圆角(WebUI 的 `.app-shell > *` 与 `.narrow-shell > *` 都给 `--radius-card`)');
assert.ok(!/borderRadius\(this\.isWide \? Theme\.glassRadius : 0\)/.test(code_),
'★ 圆角不得绑在宽窄上(`isWide ? glassRadius : 0`)—— ' +
'WebUI 的两条 shell 规则**都给圆角**,差的只是留白;' +
'写成窄屏 0 会让四个窗格变直角,正是用户 2026-09-21 报的那个问题。');
/*
* 留白才是两端有别的那一处:宽屏四边都是 paneGap,窄屏底边为 0
* (底栏自己带 margin,内容要从它下面穿过 —— 见 `navReserve` 那段)。
*/
assert.match(code_, /left: Theme\.paneGap,[\s\S]{0,80}?right: Theme\.paneGap,/,
'左右留白两端都要(WebUI 两条 shell 的 padding 左右都是 --pane-gap)');
/*
* ★★ 2026-09-20 改:原来这条断言的是
* assert.match(code_, /clip\(this\.isWide\)/, '面板内容要被圆角裁剪(clip 宽屏才开)');
@ -307,15 +335,41 @@ test('⑤ 宽屏 app-shell 几何:padding/gap/radius 与 WebUI 同值(一比
* 原来那条测的是"某句实现写着没写着"(且那实现是错的),
* 现在测的是"与 WebUI 同一条几何纪律":圆角在、`overflow:hidden` 不在。
*/
assert.match(code_, /borderRadius\(this\.isWide \? Theme\.glassRadius : 0\)/,
'宽屏圆角开(14)/ 窄屏 0 —— 与 WebUI `.app-shell > *` 同值');
/*
* 圆角:两端都开。
*
* ★★ 2026-09-21 反转(原句是 `borderRadius(this.isWide ? Theme.glassRadius : 0)`)。
* 见上面那条断言的说明:WebUI 的两条 shell 规则**都给圆角**,
* 差的只是留白。写成"窄屏 0"是我把"底边留白为 0"错推广成了"什么都不留"。
* 用户原话:「你的邮件的圆角呢?日历的圆角呢?」。
*/
assert.match(code_, /borderRadius\(Theme\.glassRadius\)/,
'内容面板两端都要圆角 —— 与 WebUI `.app-shell > *` / `.narrow-shell > *` 同值(都是 `--radius-card`)');
assert.ok(!/borderRadius\(this\.isWide \? Theme\.glassRadius : 0\)/.test(code_),
'★ 圆角不得绑宽窄(`isWide ? glassRadius : 0`)—— 窄屏会变直角');
assert.match(code_, /attributeModifier\(GlassCardModifier\.of\(this\.bgActive\)\)/,
'宽屏内容列的面来自设计令牌(不是就地写死一个实心 surface)');
assert.ok(!/\.clip\(this\.isWide\)/.test(code_),
'宽屏内容列**不能**写 `.clip(this.isWide)` —— 会把 `Navigation` 里 List 的滚动整条裁掉'
+ '(WebUI `index.css:968` 在同一个规则块里写明了这条,2026-09-14 用户实测"无法上下滑动")');
// 容器 padding 宽屏 paneGap / 窄屏 0(壁纸从缝隙露出)
assert.match(code_, /left: this\.isWide \? Theme\.paneGap : 0/, '宽屏容器左右 padding paneGap(窄屏 0 贴合全屏)');
/*
* 容器左右留白 —— 两端都要 `paneGap`。
*
* ★★ 2026-09-21 反转:原句是 `left: this.isWide ? Theme.paneGap : 0`
* (「窄屏 0 贴合全屏」)。那是我把 WebUI 的
* .narrow-shell { padding: var(--pane-gap) var(--pane-gap) 0; }
* 读成了"窄屏不留白" —— 它**左右上三边都留 gap**,只有**底边**为 0
* (底栏自带 margin,内容要从它下面穿过)。
* 后果:窄屏面板贴死屏幕两侧,既没圆角的余地也没投影的余地,
* 看起来是一块被屏幕裁掉的白板 —— 用户:「邮箱页面丑死了,跟 WebUI 没法比」。
*/
assert.match(code_, /left: Theme\.paneGap,/,
'容器左右留白两端都要(WebUI `.narrow-shell` 的 padding 左右也是 `--pane-gap`)');
assert.ok(!/left: this\.isWide \? Theme\.paneGap : 0/.test(code_),
'★ 左右留白不得绑宽窄(窄屏会贴死屏幕两侧)');
/* 真正两端有别的只有**底边**:宽屏补 paneGap,窄屏 0(底栏自己带 margin) */
assert.match(code_, /bottom: this\.isWide \? Theme\.paneGap : 0/,
'底边留白才是两端有别的那一处:宽屏 paneGap / 窄屏 0(底栏自带 margin)');
});
test('⑦ 导航项徽标:取值/色调与 WebUI Sidebar 同口径(纯逻辑,不需要设备)', async () => {
/*