From 125aec1191dfe8dcd797e93ed77d38e4fa2557f9 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 21 Sep 2026 13:29:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=202in1=20=E9=94=AE=E7=9B=98?= =?UTF-8?q?=E5=8F=AF=E8=BE=BE=EF=BC=88Ctrl+N=20=E5=86=99=E4=BF=A1=20/=20?= =?UTF-8?q?=E2=86=91=E2=86=93=20=E6=8D=A2=E8=A1=A5=E5=85=A8=20/=20Enter=20?= =?UTF-8?q?=E9=80=89=E4=B8=AD=EF=BC=89+=20=E4=B8=89=E5=A4=84=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=E8=A2=AB=E5=AE=9E=E6=B5=8B=E6=94=B9=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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`(收尾前)。 --- client/electron/measure.mjs | 27 ++ client/electron/shot-cal.mjs | 19 ++ client/electron/shot-pane.mjs | 26 ++ client/electron/shot-pane2.mjs | 27 ++ client/electron/shot-webui-narrow.mjs | 18 + client/electron/shot-wide.mjs | 23 ++ .../electron/src/components/AddressInput.tsx | 63 ++-- client/electron/src/lib/addressSuggest.ts | 125 +++++++ .../electron/test/cross-client-logic.test.mjs | 60 ++++ .../electron/test/cross-client-theme.test.mjs | 171 +++++++++- client/electron/test/harmony-2in1.test.mjs | 322 ++++++++++++++++++ client/electron/test/harmony-nav.test.mjs | 59 +++- .../electron/test/harmony-widescreen.test.mjs | 66 +++- client/electron/test/run-all.mjs | 5 + .../entry/src/main/ets/api/MailApi.ets | 35 +- .../src/main/ets/common/ComposeIntent.ets | 82 +++++ .../entry/src/main/ets/common/Theme.ets | 106 +++++- .../src/main/ets/model/AddressSuggest.ts | 200 +++++++++++ .../entry/src/main/ets/model/Models.ets | 21 +- .../entry/src/main/ets/model/NavItems.ts | 20 +- .../entry/src/main/ets/pages/ComposePage.ets | 166 ++++++++- .../entry/src/main/ets/pages/MainPage.ets | 76 ++++- 22 files changed, 1608 insertions(+), 109 deletions(-) create mode 100644 client/electron/measure.mjs create mode 100644 client/electron/shot-cal.mjs create mode 100644 client/electron/shot-pane.mjs create mode 100644 client/electron/shot-pane2.mjs create mode 100644 client/electron/shot-webui-narrow.mjs create mode 100644 client/electron/shot-wide.mjs create mode 100644 client/electron/src/lib/addressSuggest.ts create mode 100644 client/electron/test/harmony-2in1.test.mjs create mode 100644 client/harmony/entry/src/main/ets/common/ComposeIntent.ets create mode 100644 client/harmony/entry/src/main/ets/model/AddressSuggest.ts diff --git a/client/electron/measure.mjs b/client/electron/measure.mjs new file mode 100644 index 0000000..72cbb96 --- /dev/null +++ b/client/electron/measure.mjs @@ -0,0 +1,27 @@ +import { WebSocket } from 'ws'; +const list = await (await fetch('http://127.0.0.1:9222/json/list')).json(); +const page = list.find(t => t.type === 'page'); +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; const pend = new Map(); +const send = (m, p = {}) => new Promise(r => { const i = ++id; pend.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p })); }); +await new Promise(r => ws.on('open', r)); +ws.on('message', d => { const m = JSON.parse(d); if (m.id && pend.has(m.id)) { pend.get(m.id)(m.result); pend.delete(m.id); } }); +await send('Emulation.setDeviceMetricsOverride', { width: 430, height: 932, deviceScaleFactor: 2, mobile: true }); +await send('Page.navigate', { url: 'http://127.0.0.1:5173/' }); +await new Promise(r => setTimeout(r, 4500)); +const { result } = await send('Runtime.evaluate', { expression: `(() => { + const out = {}; + const shell = document.querySelector('.narrow-shell'); + const nav = document.querySelector('.narrow-nav'); + const listEl = document.querySelector('.overflow-y-auto'); + const r = e => { if(!e) return null; const b = e.getBoundingClientRect(); return {top:Math.round(b.top), bottom:Math.round(b.bottom), h:Math.round(b.height)}; }; + out.viewport = {h: innerHeight}; + out.shell = r(shell); + out.nav = r(nav); + out.navStyle = nav ? {position:getComputedStyle(nav).position, margin:getComputedStyle(nav).margin, flexShrink:getComputedStyle(nav).flexShrink} : null; + out.scroller = r(listEl); + out.shellDisplay = shell ? getComputedStyle(shell).display : null; + return JSON.stringify(out, null, 1); +})()`, returnByValue: true }); +console.log(result?.value); +ws.close(); diff --git a/client/electron/shot-cal.mjs b/client/electron/shot-cal.mjs new file mode 100644 index 0000000..7fbddbb --- /dev/null +++ b/client/electron/shot-cal.mjs @@ -0,0 +1,19 @@ +import { WebSocket } from 'ws'; +const list = await (await fetch('http://127.0.0.1:9222/json/list')).json(); +const page = list.find(t => t.type === 'page'); +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; const pend = new Map(); +const send = (m, p = {}) => new Promise(r => { const i = ++id; pend.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p })); }); +await new Promise(r => ws.on('open', r)); +ws.on('message', d => { const m = JSON.parse(d); if (m.id && pend.has(m.id)) { pend.get(m.id)(m.result); pend.delete(m.id); } }); +await send('Emulation.setDeviceMetricsOverride', { width: 430, height: 932, deviceScaleFactor: 2, mobile: true }); +await send('Page.navigate', { url: 'http://127.0.0.1:5173/' }); +await new Promise(r => setTimeout(r, 4500)); +// 点日历(底部导航第 2 项) +const { result } = await send('Runtime.evaluate', { expression: `(()=>{const els=[...document.querySelectorAll('button,a,div')];const t=els.find(e=>e.textContent?.trim()==='日历');if(t){t.click();return 'clicked'}return 'not found'})()`, returnByValue: true }); +console.log('点击结果:', result?.value); +await new Promise(r => setTimeout(r, 2500)); +const { data } = await send('Page.captureScreenshot', { format: 'png' }); +(await import('node:fs')).writeFileSync('/tmp/webui-cal.png', Buffer.from(data, 'base64')); +console.log('已截图 /tmp/webui-cal.png'); +ws.close(); diff --git a/client/electron/shot-pane.mjs b/client/electron/shot-pane.mjs new file mode 100644 index 0000000..f679a1a --- /dev/null +++ b/client/electron/shot-pane.mjs @@ -0,0 +1,26 @@ +import { WebSocket } from 'ws'; +const list = await (await fetch('http://127.0.0.1:9222/json/list')).json(); +const page = list.find(t => t.type === 'page'); +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id=0; const pend=new Map(); +const send=(m,p={})=>new Promise(r=>{const i=++id;pend.set(i,r);ws.send(JSON.stringify({id:i,method:m,params:p}));}); +await new Promise(r=>ws.on('open',r)); +ws.on('message',d=>{const m=JSON.parse(d);if(m.id&&pend.has(m.id)){pend.get(m.id)(m.result);pend.delete(m.id);}}); +await send('Emulation.setDeviceMetricsOverride',{width:1400,height:900,deviceScaleFactor:2,mobile:false}); +await send('Page.navigate',{url:'http://127.0.0.1:5173/'}); +await new Promise(r=>setTimeout(r,5000)); +const {result}=await send('Runtime.evaluate',{expression:`(()=>{ + const out={}; + const pick=(sel,label)=>{const e=document.querySelector(sel); if(!e){out[label]='(none)';return;} + const b=e.getBoundingClientRect(), cs=getComputedStyle(e); + out[label]={x:Math.round(b.x),y:Math.round(b.y),w:Math.round(b.width),h:Math.round(b.height), + radius:cs.borderRadius, bg:cs.backgroundColor, blur:cs.backdropFilter, shadow:cs.boxShadow.slice(0,40), border:cs.borderWidth+' '+cs.borderColor};}; + pick('.app-shell > *','pane'); + pick('[data-testid="comm-tabs"]','tabstrip'); + pick('.glass-card','firstCard'); + return JSON.stringify(out,null,1); +})()`,returnByValue:true}); +console.log(result?.value); +const {data}=await send('Page.captureScreenshot',{format:'png',clip:{x:60,y:0,width:420,height:260,scale:2}}); +(await import('node:fs')).writeFileSync('/tmp/web-pane-tl.png',Buffer.from(data,'base64')); +ws.close(); diff --git a/client/electron/shot-pane2.mjs b/client/electron/shot-pane2.mjs new file mode 100644 index 0000000..167d82e --- /dev/null +++ b/client/electron/shot-pane2.mjs @@ -0,0 +1,27 @@ +import { WebSocket } from 'ws'; +const list = await (await fetch('http://127.0.0.1:9222/json/list')).json(); +const page = list.find(t => t.type === 'page'); +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id=0; const pend=new Map(); +const send=(m,p={})=>new Promise(r=>{const i=++id;pend.set(i,r);ws.send(JSON.stringify({id:i,method:m,params:p}));}); +await new Promise(r=>ws.on('open',r)); +ws.on('message',d=>{const m=JSON.parse(d);if(m.id&&pend.has(m.id)){pend.get(m.id)(m.result);pend.delete(m.id);}}); +await send('Emulation.setDeviceMetricsOverride',{width:1400,height:900,deviceScaleFactor:2,mobile:false}); +await send('Page.navigate',{url:'http://127.0.0.1:5173/'}); +await new Promise(r=>setTimeout(r,5000)); +const {result}=await send('Runtime.evaluate',{expression:`(()=>{ + const ts=document.querySelector('[data-testid="comm-tabs"]'); + const b=ts.getBoundingClientRect(); + // 往上找所有祖先,列出它们的几何+圆角,看 tabstrip 住在谁里面 + const out=[]; let e=ts; + while(e && e!==document.documentElement){ + const r=e.getBoundingClientRect(), cs=getComputedStyle(e); + out.push({tag:e.tagName.toLowerCase()+'.'+(e.className||'').split(' ').slice(0,3).join('.'), + x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height), + radius:cs.borderRadius, overflow:cs.overflow, bg:cs.backgroundColor}); + e=e.parentElement; + } + return JSON.stringify({tabstrip:{x:Math.round(b.x),w:Math.round(b.width),y:Math.round(b.y),h:Math.round(b.height)}, ancestors:out},null,1); +})()`,returnByValue:true}); +console.log(result?.value); +ws.close(); diff --git a/client/electron/shot-webui-narrow.mjs b/client/electron/shot-webui-narrow.mjs new file mode 100644 index 0000000..0f2080a --- /dev/null +++ b/client/electron/shot-webui-narrow.mjs @@ -0,0 +1,18 @@ +import { WebSocket } from 'ws'; +const list = await (await fetch('http://127.0.0.1:9222/json/list')).json(); +const page = list.find(t => t.type === 'page'); +if (!page) { console.log('no page'); process.exit(1); } +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; const pend = new Map(); +const send = (method, params = {}) => new Promise(res => { const i = ++id; pend.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); }); +await new Promise(r => ws.on('open', r)); +ws.on('message', d => { const m = JSON.parse(d); if (m.id && pend.has(m.id)) { pend.get(m.id)(m.result); pend.delete(m.id); } }); +// 388x844 ≈ 1008/2.6(同比例窄屏) +await send('Emulation.setDeviceMetricsOverride', { width: 430, height: 932, deviceScaleFactor: 2, mobile: true }); +await send('Page.navigate', { url: 'http://127.0.0.1:5173/' }); +await new Promise(r => setTimeout(r, 4000)); +const { data } = await send('Page.captureScreenshot', { format: 'png' }); +const fs = await import('node:fs'); +fs.writeFileSync('/tmp/webui-narrow.png', Buffer.from(data, 'base64')); +console.log('已截图 /tmp/webui-narrow.png'); +ws.close(); diff --git a/client/electron/shot-wide.mjs b/client/electron/shot-wide.mjs new file mode 100644 index 0000000..ca13835 --- /dev/null +++ b/client/electron/shot-wide.mjs @@ -0,0 +1,23 @@ +import { WebSocket } from 'ws'; +const list = await (await fetch('http://127.0.0.1:9222/json/list')).json(); +const page = list.find(t => t.type === 'page'); +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id=0; const pend=new Map(); +const send=(m,p={})=>new Promise(r=>{const i=++id;pend.set(i,r);ws.send(JSON.stringify({id:i,method:m,params:p}));}); +await new Promise(r=>ws.on('open',r)); +ws.on('message',d=>{const m=JSON.parse(d);if(m.id&&pend.has(m.id)){pend.get(m.id)(m.result);pend.delete(m.id);}}); +await send('Emulation.setDeviceMetricsOverride',{width:1400,height:900,deviceScaleFactor:1,mobile:false}); +await send('Page.navigate',{url:'http://127.0.0.1:5173/'}); +await new Promise(r=>setTimeout(r,4500)); +const {result} = await send('Runtime.evaluate',{expression:`(()=>{ + const t=document.querySelector('[role="tablist"]'); + if(!t) return 'no tablist'; + const b=t.getBoundingClientRect(); const cs=getComputedStyle(t); + return JSON.stringify({rect:{x:Math.round(b.x),y:Math.round(b.y),w:Math.round(b.width),h:Math.round(b.height)}, + radius:cs.borderRadius, borderBottom:cs.borderBottomWidth+' '+cs.borderBottomColor, + bg:cs.backgroundColor, blur:cs.backdropFilter, margin:cs.margin, padding:cs.padding}, null, 1); +})()`,returnByValue:true}); +console.log(result?.value); +const {data}=await send('Page.captureScreenshot',{format:'png'}); +(await import('node:fs')).writeFileSync('/tmp/webui-wide.png',Buffer.from(data,'base64')); +ws.close(); diff --git a/client/electron/src/components/AddressInput.tsx b/client/electron/src/components/AddressInput.tsx index b8875e8..e8985b1 100644 --- a/client/electron/src/components/AddressInput.tsx +++ b/client/electron/src/components/AddressInput.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import * as api from '../api/client'; +import { parseParts, mergeCandidate, nextActiveIndex, queryFor, filterIndexes } from '../lib/addressSuggest'; import type { SessionCandidate } from '../types'; /** @@ -49,11 +50,8 @@ export default function AddressInput({ const run = async () => { try { // 决定问哪一层:还没写 @ -> 问 name;写了 @ 没写 . -> 问 path;写了 . -> 问 session - const res = parts.hasDot - ? await api.suggestAddress(parts.name, parts.path) - : parts.hasAt - ? await api.suggestAddress(parts.name) - : await api.suggestAddress(); + const q = queryFor(parts); + const res = await api.suggestAddress(q.name, q.path); if (cancelled) return; const frag = parts.hasDot ? parts.session : parts.hasAt ? parts.path : parts.name; @@ -61,15 +59,11 @@ export default function AddressInput({ const all = res.suggestions || []; const cands = res.candidates || []; - // 过滤时保持 suggestions 与 candidates 同序:candidates 是按下标对应的, - // 分别过滤两个数组会让标题错位到别的别名上。 - const keep: number[] = []; - all.forEach((s, i) => { - const c = cands[i]; - // 标题也参与匹配:想找「缓存选型」那条会话时,人记得的是标题而不是随机短名 - const hay = c?.title ? `${s} ${c.title}`.toLowerCase() : s.toLowerCase(); - if (hay.includes(lower)) keep.push(i); - }); + /* + * 过滤下标(**保持 suggestions 与 candidates 同序**)—— + * 抽到 `lib/addressSuggest.ts`,与鸿蒙 `filterIndexes` 同一份规则。 + */ + const keep: number[] = filterIndexes(all, cands.map(c => c?.title ?? ''), lower); setKind(res.kind); setItems(keep.map(i => all[i])); @@ -127,14 +121,7 @@ export default function AddressInput({ /** 选中一个候选后拼回完整地址 */ const apply = (choice: string) => { - let next: string; - if (kind === 'name') { - next = `${choice}@`; - } else if (kind === 'path') { - next = `${parts.name}@${choice}.`; - } else { - next = `${parts.name}@${parts.path}.${choice}`; - } + const next: string = mergeCandidate(parts, kind, choice); onChange(allowMultiple ? `${head}${head ? ' ' : ''}${next}` : next); // name/path 选完仍停留在补全态,继续下一段 setOpen(kind !== 'session'); @@ -144,10 +131,10 @@ export default function AddressInput({ if (!open || items.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); - setActive(i => (i + 1) % items.length); + setActive(i => nextActiveIndex(i, items.length, 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); - setActive(i => (i - 1 + items.length) % items.length); + setActive(i => nextActiveIndex(i, items.length, -1)); } else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); apply(items[active]); @@ -244,22 +231,12 @@ export default function AddressInput({ } /** 把 name@path.session 拆段;path 内允许 . 与 /,按最后一个 . 切 */ -function parseParts(s: string) { - const at = s.indexOf('@'); - if (at < 0) { - return { name: s, path: '', session: '', hasAt: false, hasDot: false }; - } - const name = s.slice(0, at); - const rest = s.slice(at + 1); - const dot = rest.lastIndexOf('.'); - if (dot < 0) { - return { name, path: rest, session: '', hasAt: true, hasDot: false }; - } - return { - name, - path: rest.slice(0, dot), - session: rest.slice(dot + 1), - hasAt: true, - hasDot: true - }; -} +/* + * `parseParts` 已抽到 `lib/addressSuggest.ts`(与鸿蒙 `model/AddressSuggest.ts` 一对)。 + * + * 抽的理由:这段逻辑原先只在本组件里,鸿蒙要写同一套规则时**没有可比的基准**。 + * 现在它进了 `lib/`,`cross-client-logic.test.mjs` 能把同一张用例表喂给两边。 + * + * ★ 抽的时候行为**一字不改**("取最后一个点"那个选择保留)—— + * 抽出来顺手"改进"会让判据比的是新行为,而线上跑的仍是旧行为。 + */ diff --git a/client/electron/src/lib/addressSuggest.ts b/client/electron/src/lib/addressSuggest.ts new file mode 100644 index 0000000..4d44747 --- /dev/null +++ b/client/electron/src/lib/addressSuggest.ts @@ -0,0 +1,125 @@ +/** + * 三维地址补全的**纯逻辑** —— 与鸿蒙 `model/AddressSuggest.ts` 一对。 + * + * 从 `components/AddressInput.tsx` 里抽出来的。抽的原因有两个: + * ① 逻辑散在组件闭包里时**没法单测**(`apply`/`onKeyDown` 都改 React state); + * ② 鸿蒙侧要写同一套规则 —— 两份手抄必然漂移(本仓已有判据 + * `cross-client-logic.test.mjs` 专门抓这个,它的首次运行就抓到过两处真分叉)。 + * + * ⇒ 抽成纯函数后,同一张用例表喂给两边、逐条比结果。 + * + * ★ 抽的时候**行为一字不改**:`parseParts` 是逐字搬过来的(含"取最后一个点")。 + * 抽出来顺手"改进"一下是最危险的 —— 那会让判据比的是新行为, + * 而线上跑的仍是旧行为,两边都"通过"了却都错。 + */ + +export interface AddressParts { + name: string; + path: string; + session: string; + hasAt: boolean; + hasDot: boolean; +} + +/** + * 把编辑中的一段拆成三段。 + * + * 没有 `@` ⇒ 整串都是 name;有 `@` 没 `.` ⇒ `@` 之后是 path; + * 都有 ⇒ 以**最后一个** `.` 为界。 + */ +export function parseParts(s: string): AddressParts { + const at = s.indexOf('@'); + if (at < 0) { + return { name: s, path: '', session: '', hasAt: false, hasDot: false }; + } + const name = s.slice(0, at); + const rest = s.slice(at + 1); + const dot = rest.lastIndexOf('.'); + if (dot < 0) { + return { name, path: rest, session: '', hasAt: true, hasDot: false }; + } + return { + name, + path: rest.slice(0, dot), + session: rest.slice(dot + 1), + hasAt: true, + hasDot: true + }; +} + +/** + * 把选中的候选拼回一段完整地址。 + * + * `name`/`path` 选完要**补上分隔符**(`@` / `.`),这样用户接着打字就自然 + * 进入下一段,不必自己敲分隔符。 + */ +export function mergeCandidate(parts: AddressParts, kind: string, choice: string): string { + if (kind === 'name') { + return `${choice}@`; + } + if (kind === 'path') { + return `${parts.name}@${choice}.`; + } + return `${parts.name}@${parts.path}.${choice}`; +} + +/** + * 上下键移动时的下一个下标(**循环**)。 + * + * ★ JS 的 `%` 对负数返回负数(`-1 % 5 === -1`),所以向上移动要写成 + * `((i + d) % n + n) % n`。直接写 `(i-1) % n` 会得到负下标 —— + * 而负下标在数组访问里**不报错**(返回 `undefined`),只会表现为 + * "按上键之后菜单里没有任何一项高亮"。这类静默失败正是判据要钉的。 + */ +export function nextActiveIndex(current: number, count: number, delta: number): number { + if (count <= 0) { + return 0; + } + return (((current + delta) % count) + count) % count; +} + +/** 候选菜单要不要向上翻转(下方空间不够且上方更多时翻) */ +export function shouldFlipUp(spaceAbove: number, spaceBelow: number, need: number): boolean { + const want = Math.min(need, spaceAbove); + return spaceBelow < want && spaceAbove > spaceBelow; +} + +export interface SuggestQuery { + name: string; + path: string; + kind: 'name' | 'path' | 'session'; +} + +/** 按当前输入决定"问哪一层" */ +export function queryFor(parts: AddressParts): SuggestQuery { + if (parts.hasDot) { + return { name: parts.name, path: parts.path, kind: 'session' }; + } + if (parts.hasAt) { + return { name: parts.name, path: '', kind: 'path' }; + } + return { name: '', path: '', kind: 'name' }; +} + +/** + * 过滤候选的下标 —— **保持 suggestions 与 candidates 同序**。 + * + * 分别过滤两个数组会让标题错位到别的别名上(`AddressInput.tsx:60` 的原注释)。 + * 标题也参与匹配:人记得的是会话标题而不是随机短名。 + */ +export function filterIndexes( + suggestions: string[], + titles: string[], + fragment: string +): number[] { + const keep: number[] = []; + const lower = fragment.toLowerCase(); + suggestions.forEach((s, i) => { + const title = titles[i] ?? ''; + const hay = title ? `${s} ${title}`.toLowerCase() : s.toLowerCase(); + if (hay.includes(lower)) { + keep.push(i); + } + }); + return keep; +} diff --git a/client/electron/test/cross-client-logic.test.mjs b/client/electron/test/cross-client-logic.test.mjs index 7f1f95b..818107d 100644 --- a/client/electron/test/cross-client-logic.test.mjs +++ b/client/electron/test/cross-client-logic.test.mjs @@ -234,6 +234,66 @@ const PAIRS = [ ['月历网格 2026-02(4 行的月)', 'monthGrid', [new Date(2026, 1, 1)], [2026, 2, 1], 'grid:2026,2'], ['月历网格 2024-02(闰年)', 'monthGrid', [new Date(2024, 1, 1)], [2024, 2, 1], 'grid:2024,2'] ] + }, + { + /* + * 三维地址补全 —— 用户 2026-09-21:「上下键切换发信目标」「回车展开输入框」。 + * + * 这一对是**先抽再比**:WebUI 的逻辑原先散在 `AddressInput.tsx` 的组件闭包里 + * (`parseParts` 是模块级但没 export、`apply`/`onKeyDown` 直接改 React state), + * 判据根本 import 不到。所以先把纯逻辑抽到 `lib/addressSuggest.ts` 并导出, + * 鸿蒙侧写成一对的 `model/AddressSuggest.ts`。 + * + * ★ 抽的时候行为**一字不改**。抽出来顺手"改进"一下是最危险的: + * 判据会去比新行为,而线上跑的仍是旧行为 —— 两边都"通过"了却都错。 + * + * ★ 这一对的价值:**键盘行为可单测**。"上下键循环"这种逻辑写成组件闭包时 + * 只能手动点着验;抽成纯函数后,`nextActiveIndex(0,3,-1)` 这类边界 + * (回绕、空列表)才钉得住。JS 的 `%` 对负数返回负数 —— + * 直接写 `(i-1) % n` 会得到负下标,而负下标**不报错**(返回 `undefined`), + * 只表现为"按上键后没有任何一项高亮"。判据抓的就是这种静默失败。 + */ + name: 'AddressSuggest', + electron: join(E_LIB, 'addressSuggest.ts'), + harmony: join(H_MODEL, 'AddressSuggest.ts'), + project: {}, + gaps: [], + cases: [ + /* ── parseParts:三段切分(`@` 与最后一个 `.` 为界) ── */ + ['三段全有', 'parseParts', ['pi@root.new']], + ['只有 name', 'parseParts', ['dsh']], + ['有 @ 无 .', 'parseParts', ['pi@root']], + ['@ 在开头', 'parseParts', ['@root.new']], + ['多个 @(取第一个)', 'parseParts', ['a@b@c.d']], + ['路径带点(取最后一个)', 'parseParts', ['pi@a.b/c.sess']], + ['空串', 'parseParts', ['']], + ['末尾就是 .(session 为空)', 'parseParts', ['pi@root.']], + + /* ── mergeCandidate:选完候选拼回完整地址 ── */ + ['选 name', 'mergeCandidate', [{ name: 'pi', path: 'root', session: 'new', hasAt: true, hasDot: true }, 'name', 'dsh']], + ['选 path', 'mergeCandidate', [{ name: 'pi', path: 'root', session: 'new', hasAt: true, hasDot: true }, 'path', 'home/x']], + ['选 session', 'mergeCandidate', [{ name: 'pi', path: 'root', session: 'new', hasAt: true, hasDot: true }, 'session', 'new']], + + /* ── nextActiveIndex:上下键循环 ── */ + ['下移', 'nextActiveIndex', [0, 3, 1]], + ['下移到底回绕', 'nextActiveIndex', [2, 3, 1]], + ['上移', 'nextActiveIndex', [1, 3, -1]], + ['上移到头回绕(负下标陷阱)', 'nextActiveIndex', [0, 3, -1]], + ['空列表', 'nextActiveIndex', [0, 0, 1]], + ['单元素上移', 'nextActiveIndex', [0, 1, -1]], + + /* ── queryFor:问哪一层 ── */ + ['没写 @ → 问 name', 'queryFor', [{ name: 'pi', path: '', session: '', hasAt: false, hasDot: false }]], + ['写了 @ → 问 path', 'queryFor', [{ name: 'pi', path: 'ro', session: '', hasAt: true, hasDot: false }]], + ['写了 . → 问 session', 'queryFor', [{ name: 'pi', path: 'root', session: 'n', hasAt: true, hasDot: true }]], + + /* ── filterIndexes:同序过滤 + 标题参与匹配 ── */ + ['按别名过滤', 'filterIndexes', [['a@x.n', 'b@y.m'], ['', ''], 'a']], + ['标题也参与匹配', 'filterIndexes', [['a@x.n', 'b@y.m'], ['缓存选型', '其他'], '缓存']], + ['无匹配', 'filterIndexes', [['a@x.n'], [''], 'zzz']], + ['空片段全保留', 'filterIndexes', [['a@x', 'b@y'], ['', ''], '']], + ['candidates 比 suggestions 短', 'filterIndexes', [['a@x', 'b@y'], ['t1'], 'b']] + ] } ]; diff --git a/client/electron/test/cross-client-theme.test.mjs b/client/electron/test/cross-client-theme.test.mjs index 47b7eb3..3d13e71 100644 --- a/client/electron/test/cross-client-theme.test.mjs +++ b/client/electron/test/cross-client-theme.test.mjs @@ -231,6 +231,26 @@ const SELF_OWNED_COLORS = [ * 深色变体 `accentEdgeDark` 对齐 WebUI `.dark` 段的 `--c-blue-200`。 */ 'accentEdge', 'accentEdgeDark', + /* + * ★★ 2026-09-21 补登记:`glassCard` / `glassCardWall`。 + * + * 这两个是卡片底 —— 与 WebUI `.glass-card` **逐字同源**: + * index.css:1629 .glass-card { background-color: rgb(255 255 255 / 0.92) } + * index.css html[data-bg='on'] … { background-color: rgb(255 255 255 / 0.78) } + * ⇒ glassCard = #EBFFFFFF(0.92×255 ≈ 235 = 0xEB) + * ⇒ glassCardWall = #C7FFFFFF(0.78×255 ≈ 199 = 0xC7) + * + * 为什么必须是**自己写**的色而不是 `$r('sys.*')`: + * 系统材质(`bgMaterial*`/`compBackground*`)是**不透明**的整块色板, + * 没有"白 92% 叠在壁纸上"这一档。而 WebUI 的玻璃观感**正是**这个 alpha + * —— 它不是模糊(`.glass-card` 全仓没有 `backdrop-filter`)。 + * 换成系统材质 ⇒ 壁纸被整块盖死,就是用户报的「玻璃不透明」。 + * + * 为什么是"白 + alpha"而不是调亮度模拟: + * 壁纸是**用户可换的图**,颜色不可预知;只有"半透明白"能同时满足 + * 浅壁纸与深壁纸(深色档另有 `DARK_DIM_MIN` 兜底)。 + */ + 'glassCard', 'glassCardWall', // 业务语义色:系统没有对应物(同意 / 拒绝 / 警示) 'approve', 'danger', 'approveBg', 'approveFg', 'dangerBg', 'warnBg', 'warnFg', // 权限档位与预算档位的胶囊配色(档位是产品语义,系统不认识"plan/workspace/full") @@ -336,9 +356,33 @@ test('B|旧机制不得回来:手写玻璃 alpha、替系统猜深色、与 */ assert.ok(!/navBg(Light|Dark)/.test(codeOnly), 'navBgLight/navBgDark 不该再出现(玻璃交给系统材质)'); - // 半透明色(8 位 #AARRGGBB)= 手写玻璃/手写遮罩那一类,一律不许 - const translucent = rawColors(codeOnly).filter(h => h.startsWith('#') && h.length === 9); - assert.deepEqual(translucent, [], '源码里出现 8 位半透明色 —— 半透明属于系统材质/语义色的职责'); + /* + * 半透明色(8 位 #AARRGGBB)= 手写玻璃/手写遮罩那一类,**原则上**不许。 + * + * ★★ 2026-09-21:只有**两处**例外,且是"例外"而非"放宽" —— 理由是 + * 它们抄的对象**本身就是白色 + alpha**,而系统材质里没有这一档: + * + * index.css:1629 .glass-card → rgb(255 255 255 / 0.92) + * index.css html[data-bg='on'] … → rgb(255 255 255 / 0.78) + * + * 系统材质(`bgMaterial*` / `compBackground*`)是**不透明**的整块色板。 + * 若把它们换成系统材质,壁纸会被整块盖死 —— 那正是用户报的 + * 「界面完全不透明,不显示背景」「玻璃也不透明」。 + * + * ★ 为什么不允许"随手加一个":例外是**枚举**的(下面这个白名单), + * 不是"只要是玻璃就放行"。加第三个半透明色 ⇒ 这条判据照样红, + * 而那时得先回答"系统为什么没有对应物"这个问题。 + * + * ★ 白名单项与 `SELF_OWNED_COLORS` 是**两个不同的问题**: + * 那个问"色值有没有登记理由",这个问"半透明这件事合不合法"。 + */ + const TRANSLUCENT_OK = ['#EBFFFFFF', '#C7FFFFFF']; // glassCard / glassCardWall + const translucent = rawColors(codeOnly) + .filter(h => h.startsWith('#') && h.length === 9) + .filter(h => !TRANSLUCENT_OK.includes(h.toUpperCase())); + assert.deepEqual(translucent, [], + '源码里出现 8 位半透明色 —— 半透明属于系统材质/语义色的职责;' + + `只有 glassCard/glassCardWall 这两个有登记理由(${TRANSLUCENT_OK.join(' ')})`); // 鸿蒙侧不写 CSS 式颜色函数 assert.ok(!/\brgba?\s*\(/.test(codeOnly), '鸿蒙源码里不该出现 rgb()/rgba()(那是 WebUI 的写法)'); @@ -588,17 +632,87 @@ test('C|玻璃:位置用系统材质、不许叠、每一处都要登记( assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条要用系统材质(不是手写 alpha)'); /* - * 参数化玻璃的**取值也要来自令牌**:`backgroundEffect({radius, saturation})` - * 若不引 `Theme.glassCard*`,就等于把"玻璃浓度"写死在调用点 —— - * 与 `#B8FFFFFF` 同类的问题(只是换了个旋钮)。 + * ══════════════════════════════════════════════════════════════════════ + * ★★ 2026-09-21 **反转**:卡片**不该**有模糊;模糊只属于导航/控件层 + * ══════════════════════════════════════════════════════════════════════ + * + * 这条原先断言的是反面: + * assert.ok(eff.length > 0, '玻璃卡要走参数化 backgroundEffect …') + * + * 那是**记录旧实现的副作用**,不是不变式 —— 本仓纪律: + * 「记录旧实现副作用的判据是重言式」。旧实现确实给卡片挂了 + * `backgroundEffect({radius:18, saturation:1.5})`,于是判据照着它写。 + * + * 但那个参数是**底部导航条那一档**(WebUI `.narrow-nav` 的 + * `blur(18px) saturate(1.5)`,`index.css:1204`),被我错安到了卡片上。 + * + * ── WebUI 的事实(逐字读它的 CSS)── + * + * 全仓 `backdrop-filter` **只有两处**: + * html[data-bg='on'] .glass-control → blur(8px) saturate(1.1) + * .narrow-nav → blur(18px) saturate(1.5) + * 而 `.glass-card`(`index.css:1629`)**完全没有 backdrop-filter**: + * .glass-card { background-color: rgb(255 255 255 / 0.92); } + * html[data-bg='on'] … { background-color: rgb(255 255 255 / 0.78); } + * 它只有 radius + border + bg + transition。 + * + * ⇒ 卡片的"玻璃感"来源是**半透明白**,不是模糊。给卡片加模糊的后果 + * 已实测:叠加成灰雾 + 掉帧,且用户报「邮件看着没有玻璃效果」。 + * + * ── 所以现在钉的是这个方向 ── + * + * ① 卡片层(Surface.ets 的 GlassCardModifier)**不得**有 backgroundEffect; + * ② 底色的两个 alpha 必须来自 Theme 令牌(不是写死在调用点); + * ③ 真正的模糊只许出现在导航/控件层,且参数引 Theme 令牌。 + * + * ★ 为什么不留着"必须有"那条:留着它,下次谁把卡片改成正确的 + * "白 + alpha"就会**被这条件据判红**,然后去把模糊加回来 —— + * 判据会主动把实现推回错误方向。这比没有判据更坏。 */ const surfaceSrc = code(join(HARMONY_ETS, 'common', 'Surface.ets')); - const eff = [...surfaceSrc.matchAll(/\.backgroundEffect\(/g)]; - assert.ok(eff.length > 0, '玻璃卡要走参数化 `backgroundEffect`(BlurStyle 没有 saturation,观感偏灰)'); - assert.match(surfaceSrc, /radius:\s*Theme\.glass\w+/, - '`backgroundEffect` 的 radius 要引 Theme 令牌(不许写死数字)'); - assert.match(surfaceSrc, /saturation:\s*Theme\.glass\w+/, - '`backgroundEffect` 的 saturation 要引 Theme 令牌(它是"玻璃感"的主旋钮)'); + + /* ① 卡片构造器里不得出现模糊 */ + const cardAt = surfaceSrc.indexOf('class GlassCardModifier'); + assert.ok(cardAt > 0, '要能找到 GlassCardModifier'); + const cardBody = surfaceSrc.slice(cardAt, surfaceSrc.indexOf('\nclass ', cardAt + 10)); + assert.ok(!/\.backgroundEffect\(/.test(cardBody), + '卡片层不得挂 backgroundEffect —— WebUI `.glass-card` 没有 backdrop-filter,' + + '卡片的玻璃感来自半透明白。加模糊会叠成灰雾并掉帧(已实测)。'); + + /* ② 两个 alpha 令牌必须真的被卡片读(否则是孤儿令牌,见 C 的另一段) */ + assert.match(cardBody, /Theme\.glassCard\b/, + '卡片要读 Theme.glassCard(0.92 那档,照抄 WebUI rgb(255 255 255 / 0.92))'); + assert.match(cardBody, /Theme\.glassCardWall\b/, + '卡片要读 Theme.glassCardWall(0.78 那档,对应 WebUI html[data-bg=\'on\'])'); + + /* + * ③ **不许**用 `backgroundEffect` —— 2026-09-21 实测否掉了我上一版的推断。 + * + * 我曾把底栏改成 `backgroundEffect({radius:18, saturation:1.5})`,理由是 + * 「WebUI `.narrow-nav` 有 `saturate(1.5)`,而固定材质档没这个旋钮」。 + * + * 实测(模拟器窄屏 1008×2232,底栏中心列 x=504): + * + * y backgroundEffect backgroundBlurStyle + * 1960 rgb(191,199,209) rgb(234,235,239) ← 差 -36 亮度 + * 2000 rgb(198,204,212) rgb(234,235,239) ← 差 -31 + * 2060 rgb(206,211,219) rgb(234,235,239) ← 差 -24 + * + * ⇒ `backgroundEffect` **只给模糊、不给底色**,壁纸原样透上来, + * 整条底栏暗了 24~36 个亮度级。而 WebUI 的 `.narrow-nav` 是**两条声明**: + * background-color: rgb(var(--nav-bg)); ← 我丢了这条 + * backdrop-filter: blur(18px) saturate(1.5); + * 系统材质(`backgroundBlurStyle`)**同时含色调 + 模糊 + 深浅两套**, + * 正好把这两条一起给了;且 WebUI 那个 `--nav-bg` 是手写 alpha、 + * 跟不了深色主题(§7.12)。⇒ 系统材质是这里**唯一**能兼顾深浅的方案。 + * + * ★ 为什么判据要**反向**钉住它:不留这条,下一个人看到 WebUI 那行 + * `saturate(1.5)` 会再想一遍同样的事,而他要重跑一遍模拟器才能知道答案。 + */ + const effSites = [...surfaceSrc.matchAll(/\.backgroundEffect\(/g)]; + assert.equal(effSites.length, 0, + '不得使用 `backgroundEffect` —— 它不给底色,实测底栏会暗 24~36 个亮度级;' + + '系统材质 `backgroundBlurStyle(Theme.navMaterial)` 同时含色调 + 模糊 + 深浅两套'); /* * 自检:造一次**链式叠用**(`X.blur().blur()`),确认上面的判定抓得到。 @@ -820,7 +934,23 @@ test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在 '而只盯声明的判据("声明了且不是 NONE")**照样绿**。'); // 用它的必须是**自绘遮罩**的地方(系统自带遮罩的弹窗不需要它) assert.ok(overlayUsers.every(f => f.endsWith('.ets')), `遮罩使用点应该是页面:${overlayUsers.join('、')}`); - assert.ok(!/#[0-9A-Fa-f]{8}/.test(stripComments(harmony)), '遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值'); + /* + * 遮罩不该写成 `#AARRGGBB` 单值(色与透明度焊死,跟不了主题换向)。 + * + * ★★ 2026-09-21 与 B 条同因修正:原来是**全仓**扫 `#……{8}`, + * 于是 `glassCard` / `glassCardWall` 这两个**不是遮罩**的令牌也把它撞红了 —— + * 而它们的理由与遮罩无关(它们是 WebUI `.glass-card` 的 + * `rgb(255 255 255 / 0.92)` 与 `/ 0.78`,是卡片底、不是遮罩)。 + * + * ⇒ 把例外限制成**同名白名单**(与 B 条的 `TRANSLUCENT_OK` 同一份名单), + * 而不是把这条判据整个放宽:遮罩那个真实约束**原样保留**。 + * `overlay` / `wallpaperScrim` 若被写成 8 位单值,这条照样红。 + */ + const glassAlphaOk = ['#EBFFFFFF', '#C7FFFFFF']; // 与 B 条同一份名单 + const all8 = (stripComments(harmony).match(/#[0-9A-Fa-f]{8}/g) ?? []) + .filter(h => !glassAlphaOk.includes(h.toUpperCase())); + assert.deepEqual(all8, [], + `遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值;只有 glassCard/glassCardWall 有登记理由(实见 ${all8.join(' ')})`); // WebUI 侧同构:颜色两套(浅/深,同一个变量名换向)+ 透明度独立 assert.match(web, /--bg-scrim: 255 255 255/, 'WebUI 浅色遮罩色'); assert.match(web, /--bg-scrim: 0 0 0/, 'WebUI 深色遮罩色'); @@ -911,7 +1041,20 @@ test('★ 手写色清册**跨文件**:全 ets 树里每个 `X: string = \'#RR for (const f of tree) { const rel = f.slice(HARMONY_ETS.length + 1); const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); - for (const m of src.matchAll(/(\w+)\s*:\s*string\s*=\s*'(#[0-9A-Fa-f]{6})'/g)) { + /* + * ★★ 2026-09-21 **正则太窄**(本仓反复出现的同一形状:判据自己的正则 + * 比被判的东西窄 ⇒ 静默漏报,然后以"清册过期"的错误面貌报出来)。 + * + * 原来写 `{6}` —— 只认 6 位。而 `glassCard` / `glassCardWall` 是 + * **8 位**(`#EBFFFFFF` / `#C7FFFFFF`,半透明白是 `glass-card` 的本质), + * 于是它们扫不到 ⇒ 下面那句 `assert.ok(byName.has(n))` 报 + * 「清册里的 glassCard 已不存在(清册过期)」—— + * **报的是错的病因**:不是清册过期,是这个正则看不见 8 位色。 + * + * ⇒ 改成 `{6}(?:[0-9A-Fa-f]{2})?`:6 位必有、8 位可选。 + * 注意**不能**写成 `{6,8}` —— 那会连 7 位这种非法长度也放进来。 + */ + for (const m of src.matchAll(/(\w+)\s*:\s*string\s*=\s*'(#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?)'/g)) { declared.push({ file: rel, name: m[1], value: m[2] }); } } diff --git a/client/electron/test/harmony-2in1.test.mjs b/client/electron/test/harmony-2in1.test.mjs new file mode 100644 index 0000000..23b98d9 --- /dev/null +++ b/client/electron/test/harmony-2in1.test.mjs @@ -0,0 +1,322 @@ +/* + * 2in1(平板/PC 形态)键盘可达性判据 —— 用户 2026-09-21: + * · 「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」 + * · 「上下键切换发信目标」 + * · 「回车展开输入框等」 + * + * # 为什么单独一个文件 + * + * 这三条属于**同一条能力线**(键盘可达),而它们各自跨了两个文件 + * (根组件 + 条件挂载的子组件 / 纯逻辑 + 界面接线)。混进 `harmony-nav` + * (管悬浮玻璃导航条)或 `harmony-logic`(管纯函数)都会让那个文件的 + * "这一期在钉什么"变得含糊。 + * + * # 判据分寸:钉"接上了"和"边界对",不钉"好不好用" + * + * 键盘交互在模拟器上**没法端到端验**(没有真实键盘事件的注入通道, + * `uitest uiInput` 只有 click/longClick/swipe/fling,没有 key)。 + * ⇒ 所以分两层: + * · **可执行层**:纯逻辑用真跑(`model/AddressSuggest.ts` 已被 + * `cross-client-logic.test.mjs` 与 electron 逐例比对,这里不重复跑, + * 只钉"界面接线确实调了它"); + * · **接线层**:钉"快捷键绑在根上、意图有接收方、键位不与系统冲突"。 + * + * # 每个断言都要在**改坏时变红**(本仓纪律) + * + * 下面每条都写了"改什么会红"。写不出这句话的断言就是装饰。 + */ +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { code, prose } from './lib/read.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..', '..'); +const ETS = join(ROOT, 'client/harmony/entry/src/main/ets'); + +const MAIN = code(join(ETS, 'pages/MainPage.ets')); +const COMPOSE = code(join(ETS, 'pages/ComposePage.ets')); +const INTENT = code(join(ETS, 'common/ComposeIntent.ets')); +const SUGGEST_UI = code(join(ETS, 'model/AddressSuggest.ts')); +const PROSE_INTENT = prose(join(ETS, 'common/ComposeIntent.ets')); + +const mainProse = prose(join(ETS, 'pages/MainPage.ets')); +const composeProse = prose(join(ETS, 'pages/ComposePage.ets')); + +/** 取 `from` 处第一个 `{` 到配对 `}` 之间的正文(按花括号配对,不用窗口) */ +function braceBody(src, from) { + const at = src.indexOf('{', src.indexOf(from)); + assert.ok(at > 0, `要能找到 ${from} 后面的 {`); + let depth = 0; + for (let i = at; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(at + 1, i); } + } + assert.fail(`${from} 的花括号没有闭合`); +} + +/* ──────────────────────────────────────────────────────────────── + * ① 打开发信页的快捷键 + * ──────────────────────────────────────────────────────────────── */ + +test('2in1 快捷键|Ctrl+N 绑在**根**组件树上(不是某个会卸载的分支)', () => { + /* + * 官方 `keyboardShortcut` 文档:「即使组件未获焦或是在所在页面未展示, + * 只要已经挂载到**获焦窗口**的组件树上就会响应自定义组合键」。 + * ⇒ 只有绑在窗口组件树的根上,"窗口在就生效"才成立。 + * + * 改坏会红:把 `.keyboardShortcut(...)` 挪到 FAB 上(FAB 在 `if` 分支里、 + * 窄屏/宽屏位置也不同)—— 换个 tab 就失效,而那时用户按 Ctrl+N 什么都没发生。 + */ + const at = MAIN.indexOf(".keyboardShortcut('n'"); + assert.ok(at > 0, 'MainPage 里要有 Ctrl+N 绑定'); + + const structAt = MAIN.lastIndexOf('struct MainPage', at); + const prevStruct = MAIN.lastIndexOf('\nstruct ', at); + assert.ok(structAt > 0, 'Ctrl+N 绑定的位置要在 MainPage 之前有 struct 声明'); + assert.ok( + structAt > prevStruct, + `Ctrl+N 必须绑在 MainPage(根)里,而不是更早的 ${MAIN.slice(prevStruct + 1, prevStruct + 40).split('\n')[0]}` + ); +}); + +test('2in1 快捷键|键位不与官方禁止绑定的系统组合冲突', () => { + /* + * 官方文档「禁止绑定的系统快捷键」列了五个:Alt+F4、Alt+Shift+F4、 + * Alt+TAB、Alt+Shift+TAB、Ctrl+Shift+ESC。 + * + * 这五个绑上去**不生效**(且没有报错)—— 表现为"按了没反应", + * 极容易被误判成代码接错了。 + * + * 改坏会红:把 Ctrl+N 改成 Alt+Tab 之类。 + */ + const at = MAIN.indexOf('.keyboardShortcut('); + assert.ok(at > 0); + const call = MAIN.slice(at, MAIN.indexOf(')', MAIN.indexOf('[', at)) + 1); + + const banned = [ + ["Alt", "F4"], ["Alt", "TAB"], ["Ctrl", "Shift", "ESC"] + ]; + for (const combo of banned) { + const all = combo.every(k => call.includes(`ModifierKey.${k.toUpperCase()}`)); + assert.ok(!all, `不得绑定被系统占用的 ${combo.join('+')}`); + } +}); + +test('2in1 快捷键|组合键用 ModifierKey 枚举,且热键是单字符', () => { + /* + * 官方约束(「快捷键使用注意事项」表): + * · 「控制键 Ctrl、Shift、Alt 及它们的组合加上热键的单个字符」 + * · 「value 有多个字符时**不绑定**组合键」(静默失败) + * · 「keys 有重复的控制键时**不绑定**」(静默失败) + * + * 改坏会红:写成 `.keyboardShortcut('open', ...)`(多字符)或 + * `[ModifierKey.CTRL, ModifierKey.CTRL]`(重复)。 + */ + const m = MAIN.match(/\.keyboardShortcut\(\s*'([^']*)'\s*,\s*\[([^\]]*)\]/); + assert.ok(m, '要能解析出 keyboardShortcut 的 value 与 keys'); + const [, value, keysRaw] = m; + + assert.equal(value.length, 1, `热键必须是单个字符,实际是 '${value}'`); + + const keys = keysRaw.split(',').map(s => s.trim()).filter(Boolean); + assert.ok(keys.length > 0, 'keys 不能为空'); + assert.equal(new Set(keys).size, keys.length, `keys 不得重复:${keysRaw}`); + for (const k of keys) { + assert.match(k, /^ModifierKey\.(CTRL|SHIFT|ALT)$/, `keys 只允许 ModifierKey.CTRL/SHIFT/ALT,实际 ${k}`); + } +}); + +test('2in1 快捷键|同一组合只允许绑一处(浅的赢,第二处等于失效)', () => { + /* + * 官方文档:「多个不同组件设置相同组合键 ⇒ 只响应节点树上的 + * **深度最浅**的组件,其它组件不响应快捷键」。 + * + * ⇒ 再给别处的"写信"按钮补一个 Ctrl+N,不是"多一个入口", + * 而是让后来那处**永远收不到**。这类"多写一份反而坏掉"的坑 + * 必须由判据挡住 —— 它不会报错,只会静默失效。 + * + * 改坏会红:在 ComposePage / 任何别处再加一个 `.keyboardShortcut('n', [CTRL])`。 + */ + const hits = []; + for (const [name, src] of [['MainPage', MAIN], ['ComposePage', COMPOSE]]) { + const re = /keyboardShortcut\(\s*'n'\s*,\s*\[\s*ModifierKey\.CTRL\s*\]/g; + const n = (src.match(re) ?? []).length; + if (n > 0) hits.push(`${name}×${n}`); + } + assert.deepEqual(hits, ['MainPage×1'], `Ctrl+N 只能绑一处,实际:${hits.join(' ')}`); +}); + +/* ──────────────────────────────────────────────────────────────── + * ② 意图的"两半"必须都在(缺一半 = 按键静默失效) + * ──────────────────────────────────────────────────────────────── */ + +test('2in1 快捷键|意图有"存住"与"当场交付"两半,且发起方先切到通信页', () => { + /* + * 根(MainPage)够不着 `openCompose()` —— 它住在**条件挂载**的 CommPage 上 + * (`if (this.currentIndex === 0)`)。这正是 `PushService` 处理"点通知跳转" + * 时踩过的同一个坑,解法在它的注释里:**两半都要有**。 + * + * · 只有"存住" ⇒ 用户此刻就在通信页、页面早挂载完了, + * `aboutToAppear` 不重跑 ⇒ **按了没反应**; + * · 只有"当场交付" ⇒ 用户此刻在日历页、CommPage 还没实例化、 + * 没有监听者 ⇒ **同样没反应**。 + * + * 改坏会红:删掉 `ComposeIntent.setListener(...)`(通信页内按无效); + * 删掉 `ComposeIntent.consume()`(跨页按无效); + * 删掉发起方的 `this.currentIndex = 0`(在日历页按了,意图存着却没人挂载它)。 + */ + assert.match(INTENT, /static pending: boolean/, '要有"存住"的格子'); + assert.match(INTENT, /static setListener\(/, '要有登记监听的入口'); + assert.match(INTENT, /static consume\(\): boolean/, '要有取走待处理的入口'); + + /* 发起方:先切通信页,再提意图 */ + const callAt = MAIN.indexOf('ComposeIntent.request()'); + assert.ok(callAt > 0, '根上要提意图'); + const before = MAIN.slice(Math.max(0, callAt - 400), callAt); + assert.match( + before, + /this\.currentIndex\s*=\s*0/, + '提意图之前必须先把 currentIndex 拨到 0(否则 CommPage 可能还没挂载)' + ); + + /* 接手方:两半都接上 */ + const commAt = MAIN.indexOf('struct CommPage'); + const commEnd = MAIN.indexOf('\nstruct ', commAt + 1); + const commBody = MAIN.slice(commAt, commEnd > 0 ? commEnd : MAIN.length); + assert.match(commBody, /ComposeIntent\.setListener\(/, 'CommPage 要登记监听'); + assert.match(commBody, /ComposeIntent\.consume\(\)/, 'CommPage 挂载后要取走积压的请求'); + assert.match(commBody, /ComposeIntent\.clearListener\(\)/, 'CommPage 卸载时要摘掉监听'); +}); + +test('2in1 快捷键|摘监听发生在 aboutToDisappear(否则唤醒已销毁组件)', () => { + /* + * `clearListener` 若不在 `aboutToDisappear` 里,用户切走之后 + * 键盘事件仍会调到那个已卸载组件的 `openCompose()` —— + * 轻则不响应,重则对已释放的 `navPathStack` 推路由。 + * + * 改坏会红:把 `clearListener()` 挪到 `aboutToAppear`,或整个删掉。 + */ + const commAt = MAIN.indexOf('struct CommPage'); + const commEnd = MAIN.indexOf('\nstruct ', commAt + 1); + const commBody = MAIN.slice(commAt, commEnd > 0 ? commEnd : MAIN.length); + + const disAt = commBody.indexOf('aboutToDisappear'); + assert.ok(disAt > 0, 'CommPage 要有 aboutToDisappear'); + const disBody = braceBody(commBody, 'aboutToDisappear'); + assert.match(disBody, /ComposeIntent\.clearListener\(\)/, '摘监听要在 aboutToDisappear 里'); +}); + +/* ──────────────────────────────────────────────────────────────── + * ③ 收件人补全:上下键 / 回车 —— 接线用的必须是那份**已比对过**的纯逻辑 + * ──────────────────────────────────────────────────────────────── */ + +test('2in1 快捷键|收件人键盘处理走纯逻辑(接循环下标,不自己写 %)', () => { + /* + * `nextActiveIndex` 里那个 `+ n) % n` 是负下标陷阱的解药 + * (JS 的 `%` 对负数返回负数,`-1 % 5 === -1`;而负下标在数组访问里 + * **不报错**,只表现为"按上键后没有任何一项高亮")。 + * 该函数已被 `cross-client-logic.test.mjs` 与 electron 逐例比对。 + * + * ⇒ 界面必须**调它**,不能就地写 `(i - 1) % n` —— 后者看起来等价、 + * 实际在边界上会静默坏掉,而这样的坏不会让任何判据变红。 + * + * 改坏会红:把 `nextActiveIndex(...)` 换回 `(this.suggestActive + 1) % len`。 + */ + assert.match(COMPOSE, /nextActiveIndex\(/, '收件人键盘处理要调 nextActiveIndex'); + assert.ok( + !/suggestActive\s*[-+]\s*1\)\s*%/.test(COMPOSE), + '不得自己就地写 %(负下标陷阱)' + ); + assert.match(SUGGEST_UI, /\(\(current \+ delta\) % count \+ count\) % count/, '纯逻辑里才是那条公式'); +}); + +test('2in1 快捷键|↑↓ 换候选、Enter/Tab 选中、Esc 收起 —— 四个键都在', () => { + /* + * 对齐 WebUI `AddressInput.tsx:143-155`: + * ArrowDown → active+1 ;ArrowUp → active-1 + * Enter | Tab → 选中当前项 ;Escape → 收起 + * + * 改坏会红:删掉任一个 `else if` 分支 —— 用户按那个键就毫无反应。 + */ + const at = COMPOSE.indexOf('onToKey'); + assert.ok(at > 0, '要有 onToKey'); + const body = braceBody(COMPOSE, 'private onToKey'); + assert.match(body, /KEYCODE_DPAD_DOWN/, '↓ 要有'); + assert.match(body, /KEYCODE_DPAD_UP/, '↑ 要有'); + assert.match(body, /KEYCODE_ENTER/, 'Enter 要有'); + assert.match(body, /KEYCODE_TAB/, 'Tab 要有'); + assert.match(body, /KEYCODE_ESCAPE/, 'Esc 要有'); +}); + +test('2in1 快捷键|只响应 KeyType.Down(Down/Up 都处理会让一次按键走两步)', () => { + /* + * `KeyEvent` 对一次按键会派发 Down 与 Up 两个事件。两个都处理 ⇒ + * 按一次 ↓ 下标移动 **2** 格,用户看到的是"跳着走"。 + * + * 改坏会红:删掉 `if (e.type !== KeyType.Down) return;`。 + */ + const body = braceBody(COMPOSE, 'private onToKey'); + assert.match(body, /e\.type\s*!==\s*KeyType\.Down/, '要先挡掉非 Down 的按键事件'); +}); + +test('2in1 快捷键|候选列表内联渲染,不用 bindPopup/bindMenu', () => { + /* + * 这两者各有自己的焦点体系 —— 用户的按键会先被它们吃掉, + * "↑↓ 切换候选"就落不到 `onToKey` 上(菜单收不到、输入框也收不到)。 + * + * 内联渲染(条件挂载)能让焦点一直留在输入框里 —— 这是键盘可达的前提。 + * + * 改坏会红:把候选改成 `bindPopup(...)`。 + */ + assert.ok(!/\.bindPopup\(/.test(COMPOSE), '不得用 bindPopup(会吃掉按键)'); + assert.ok(!/\.bindMenu\(/.test(COMPOSE), '不得用 bindMenu(会吃掉按键)'); + assert.match(COMPOSE, /if \(this\.suggestOpen && this\.suggestItems\.length > 0\)/, '候选要在布局里内联挂载'); +}); + +test('2in1 快捷键|候选标题读 title 时守住 omitempty 缺键', () => { + /* + * `SessionCandidate.Title` 带 `json:"title,omitempty"` ⇒ **整个键可能不存在**。 + * ArkTS 的裸 cast(`JSON.parse(raw) as T`)在缺键时给 `undefined`, + * **不会**应用类里那个 `= ''` 默认值。 + * + * 本仓已踩过同一个坑的另一个实例:`MailDetail.normalize()` 之前直接读 + * `m.to.trim()`,服务端 omit 时就 `Cannot read property trim of undefined` + * ⇒ **整页白屏**。 + * + * 改坏会红:把 `typeof raw === 'string' ? raw : ''` 换回 `cands[i].title`。 + */ + assert.match( + COMPOSE, + /typeof raw === 'string' \? raw : ''/, + '读 title 前必须守一道(omitempty 缺键时是 undefined,不是空串)' + ); + /* 而且模型里的字段名要跟服务端一致(服务端给的是 alias/title/source) */ + const M = code(join(ETS, 'model/Models.ets')); + const cls = M.slice(M.indexOf('export class AddressSuggestion')); + assert.match(cls, /alias: string/, 'AddressSuggestion 要有 alias(服务端字段名)'); + assert.ok(!/value: string/.test(cls.slice(0, 600)), '不得保留服务端从不返回的 value 字段'); +}); + +/* ──────────────────────────────────────────────────────────────── + * ④ 文档化:为什么用 keyboardShortcut 而不是 onKeyEvent + * ──────────────────────────────────────────────────────────────── */ + +test('2in1 快捷键|选 keyboardShortcut 的理由写在源码里(否则后人会"顺手改成 onKeyEvent")', () => { + /* + * 这条判据钉的不是代码,是**理由**。 + * + * `onKeyEvent` 看着更"底层可控",但官方文档写明:它要求**组件获焦**才触发 + * (「按键事件是指组件与键盘、遥控器等按键设备交互时触发的事件, + * 适用于所有可获焦组件」)。而邮件列表里焦点落在哪是不确定的(点一下就换), + * 用 onKeyEvent 做全局快捷键会时灵时不灵。 + * + * 而 `keyboardShortcut` 是「无论组件是否获焦 —— 只要窗口获焦,快捷键就会响应」。 + * + * 改坏会红:把这段理由删了(后人看不到就会"顺手改成 onKeyEvent")。 + */ + assert.match(mainProse, /keyboardShortcut/, '注释里要写明用的是 keyboardShortcut'); + assert.match(mainProse, /未获焦|获焦窗口/, '要写明"不依赖焦点"这条理由'); + assert.match(PROSE_INTENT, /两半|当初|坑/, 'ComposeIntent 要留下"为什么需要中间层"的由来'); +}); diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index f375781..6bfddde 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -656,26 +656,53 @@ test('★ 顶部页签条与底部导航条同一族(都是悬浮玻璃)— '页签条不许铺实体面(Theme.surface)—— 那是壁纸模式下唯一一块实心白,"割裂"就是这么来的'); assert.match(body, /\.backgroundBlurStyle\(this\.bgActive \? Theme\.navMaterial : BlurStyle\.NONE\)/, '页签条要吃系统材质(与底部导航条同档),否则它和玻璃卡片拼在一起不是一套东西'); - assert.match(body, /\.borderRadius\(TAB_BAR_RADIUS\)/, - '页签条要用 TAB_BAR_RADIUS(= NAV_BAR_RADIUS)—— 自己拍一个数会让上下两条不齐'); /* - * ★★ 2026-09-20:留白必须**算进宽度**,不能只写 `width('100%')` + `margin`。 + * ══════════════════════════════════════════════════════════════════════ + * ★★ 2026-09-21 **改判**:页签条不再自己成条,而是与窗格**齐平** + * ══════════════════════════════════════════════════════════════════════ * - * ArkUI 的 `width('100%')` 是按父容器算的,再叠 `margin` 会**向右溢出** - * 而不是把条挤窄 —— 实测(模拟器 3184px、密度 2.875):页签条占 x=231..1151, - * 与面板**同宽**,`TAB_BAR_SIDE` 完全没生效;看起来仍是"通栏 + 圆角", - * 与底部悬浮条不齐。 + * 用户当天连指三次,最后一句是裁定: + * 「你看看顶栏右边那个圆角,你不觉得奇怪吗?」 + * 「你右边改成没圆角不就行了,还 better,你好好看看,**你又在内部套了一个胶囊**」 + * 「鸿蒙布局还略有不同的,你直接改成右边没圆角就行了」 * - * 这条断言是**从这次实测反推出来的**:单看代码"有 margin"会以为留白生效了, - * 而只有形如 `calc(100% - N vp)` 的宽度才真的让出那一段。 + * ── 我原来错在哪(用 CDP 读 WebUI 的实测几何才发现)── + * + * .comm-pane x=80 w=320 radius=14px overflow=hidden ← 窗格,**裁圆的是它** + * tabstrip x=80 w=320 radius=0px ← 页签条,自己无圆角 + * + * 两者**横向完全齐平**。而我们原来是"自己缩进 16vp + 自己带满圆角"⇒ + * 页签条成了一个**嵌在窗格里的胶囊**,上下两条弧各画一遍,中间留出一弯月牙。 + * 判据当时钉的 `TAB_BAR_RADIUS` / `TAB_BAR_SIDE` / `TAB_BAR_TOP` + * **正是那个被否掉的形状**——它记录的是旧实现,不是不变式。 + * + * ── 设备实测(模拟器窄屏 1008px,`uitest dumpLayout`)── + * + * 页签条 Row bounds=[28,140][980,267] + * 所在窗格 bounds=[28,140][980,1957] + * ⇒ 左右边缘**逐像素相同**(28 / 980),与 WebUI 一致。 + * + * ── 现在钉的三条(换成不变式,而不是"等于某个常量")── + * + * ① 与窗格齐平:`width('100%')`,**不许**再有左右内缩 + * (`margin`/`calc(100% - N)` 那两种写法都不行 —— 那会把胶囊加回来); + * ② **右上角无圆角**(用户明确要求):只给左上角,与窗格左上角那道弧**重合**成一道; + * ③ 依旧要有玻璃(与底部导航条同档),不许退回实体面。 + * + * ★ `TAB_BAR_RADIUS` / `TAB_BAR_SIDE` / `TAB_BAR_TOP` 已随之**删除** + * (`model/NavItems.ts`)—— 留着它们就是**孤儿常量**: + * 下一个人会照着名字把胶囊拼回来。`TAB_BAR_HEIGHT` 保留(仍在用: + * 它决定条高,而条高是真实几何)。 */ - assert.ok(!/\.width\('100%'\)[\s\S]{0,200}?\.margin\(\{[^}]*left: TAB_BAR_SIDE/.test(body), - "页签条不能写 `width('100%')` + `margin` —— ArkUI 里那是向右溢出、留白不生效。\n" + - ' 要用 `width(`calc(100% - ${TAB_BAR_SIDE * 2}vp)`)` 把留白算进宽度(设备实测的结论)'); - assert.match(body, /\.width\(`calc\(100% - \$\{TAB_BAR_SIDE \* 2\}vp\)`\)/, - '页签条宽度要 `calc(100% - 2×TAB_BAR_SIDE)` —— 悬浮条的左右留白必须真的让出来'); - assert.match(body, /\.margin\(\{[^}]*top: TAB_BAR_TOP/, - '页签条要留顶部间距(TAB_BAR_TOP)—— "浮着"而不是"贴着"内容区顶'); + assert.match(body, /\.width\('100%'\)/, + '页签条要与窗格齐平(`width(\'100%\')`)—— WebUI 实测 .comm-pane 与 tabstrip 横向逐像素相同'); + assert.ok(!/TAB_BAR_SIDE/.test(body), + '页签条不得再有左右内缩(TAB_BAR_SIDE 那个胶囊形状已被用户否掉)'); + assert.match(body, /\.borderRadius\(\{\s*topLeft:\s*Theme\.glassRadius,\s*topRight:\s*0\s*\}\)/, + '页签条只许有**左上**圆角(右上 0)—— 用户:「你直接改成右边没圆角就行了」;' + + '右上那道弧会与窗格右上角的弧叠成两道'); + assert.ok(!/\.borderRadius\(TAB_BAR_RADIUS\)/.test(body), + '页签条不得用 TAB_BAR_RADIUS(那是"胶囊"形状,已被否)'); /* * 自检:把页签条改回实心面,上面第一条必须判红。 diff --git a/client/electron/test/harmony-widescreen.test.mjs b/client/electron/test/harmony-widescreen.test.mjs index 9852998..83a72e5 100644 --- a/client/electron/test/harmony-widescreen.test.mjs +++ b/client/electron/test/harmony-widescreen.test.mjs @@ -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 () => { /* diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 1b84e6b..473f92d 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -130,6 +130,11 @@ const SUITE = [ // ArkTS **编译期**硬规则(纯文本可判、不需要设备)。这一条是构建撞出来的: // 我把常量表插在了既有 import 之前 ⇒ arkts-no-misplaced-imports,而当时没有任何判据会跑它。 ['test/harmony-arkts.test.mjs', [], 8], + /* + * 2in1 键盘可达(用户 2026-09-21「快捷键打开发信页面 / 上下键切换发信目标 / + * 回车展开输入框」)。见该文件头部说明:为什么单开一个文件、为什么不端到端验。 + */ + ['test/harmony-2in1.test.mjs', [], 12], ['test/harmony-contacts.test.mjs', [], 5], // ★★ 下面三条是**补接线**,不是新写的判据(2026-09-17)。 // diff --git a/client/harmony/entry/src/main/ets/api/MailApi.ets b/client/harmony/entry/src/main/ets/api/MailApi.ets index bdcdf7b..a9057ab 100644 --- a/client/harmony/entry/src/main/ets/api/MailApi.ets +++ b/client/harmony/entry/src/main/ets/api/MailApi.ets @@ -6,7 +6,7 @@ */ import { ApiClient } from './ApiClient'; -import { MailSummary, Session, Contact, MailDetail, ThreadResponse, ThreadNode, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse, ForwardMailRequest} from '../model/Models'; +import { MailSummary, Session, Contact, MailDetail, ThreadResponse, ThreadNode, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse, ForwardMailRequest, AddressSuggestion} from '../model/Models'; /** 收件箱响应 */ export class InboxResponse { @@ -85,6 +85,17 @@ export class AttachmentListResponse { /** 地址补全响应 */ export class AddressSuggestionResponse { suggestions: string[] = []; + /** + * 与 `suggestions` **按下标一一对应**的补充信息 + * (会话别名会有 `title`;`name`/`path` 段可能为空数组)。 + * + * ★ 服务端按两层返回:`suggestions` 是纯字符串、`candidates` 是对象。 + * 过滤时必须**同时**按下标保留两者,否则标题会错位到别的别名上 + * (WebUI `AddressInput.tsx:60-65` 那段注释专门记了这条)。 + */ + candidates: AddressSuggestion[] = []; + /** 当前问的是哪一层:`name` | `path` | `session` */ + kind: string = ''; } /** 改权限请求体 */ @@ -202,6 +213,28 @@ export class MailApi { return this.client.get('/contacts'); } + /** + * 三维地址补全(`GET /contacts/suggest?name=&path=`)。 + * + * 三段式(与 WebUI `AddressInput` 同口径,服务端 `contacts.go:143` 同一份实现): + * · 还没写 `@` → 问 name(Agent 名 + 人类用户名) + * · 写了 `@` 没写 `.` → 问该 Agent 的工作区路径 + * · 写了 `.` → 问该工作区下的会话别名(含 `new`) + * + * ★ 为什么要传空串而不是省略参数:服务端的判据是"参数有没有给", + * 不是"值是否为空"。省略会让它退回到上一层。 + */ + async suggestAddress(name: string, path: string): Promise { + let q: string = ''; + if (name.length > 0) { + q = '?name=' + encodeURIComponent(name); + if (path.length > 0) { + q = q + '&path=' + encodeURIComponent(path); + } + } + return this.client.get('/contacts/suggest', q); + } + /** * 一条会话下的全部邮件(`GET /sessions/{id}/mails`)。 * diff --git a/client/harmony/entry/src/main/ets/common/ComposeIntent.ets b/client/harmony/entry/src/main/ets/common/ComposeIntent.ets new file mode 100644 index 0000000..555bd80 --- /dev/null +++ b/client/harmony/entry/src/main/ets/common/ComposeIntent.ets @@ -0,0 +1,82 @@ +/** + * 「打开写信」的**跨组件一次性意图**。 + * + * 用户 2026-09-21:「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」。 + * + * ## 为什么需要这个中间层 + * + * 快捷键必须绑在**根**(`MainPage` 的根 `Stack`)—— 官方 `keyboardShortcut` + * 文档说「即使组件未获焦或是在所在页面未展示,只要已经挂载到**获焦窗口**的 + * 组件树上就会响应」,而"窗口级生效"只有绑在窗口组件树的根上才成立。 + * + * 但 `openCompose()` 住在 `CommPage` 上(写信要 `CommPage` 自己的 `navPathStack`, + * 宽屏 Split 时才能在右栏开 —— 见 `MainPage.openComposeWith()` 的注释)。 + * 于是 `MainPage` 的快捷键够不着那个方法。 + * + * ## 做法:照抄本仓已经验证过的"两半"形状 + * + * `api/PushService.ets` 处理"点通知要跳转"时踩过同一个坑,解法写在它的注释里: + * · **第一半**:一个静态格子(`pending`)—— 冷启/尚未挂载时,请求先存着; + * · **第二半**:一个回调(`listener`)—— 已经挂载时,请求当场送达。 + * + * 缺任何一半都会漏一种情形: + * · 只有格子 ⇒ 用户此刻就在通信页、页面早挂载完了,`aboutToAppear` 不重跑 + * ⇒ **按了快捷键什么都没发生**; + * · 只有回调 ⇒ 用户此刻在日历页,`CommPage` 因 `if (currentIndex === 0)` + * 尚未挂载 ⇒ 没有任何监听者 ⇒ **同样什么都没发生**。 + * + * ## 谁负责切到通信页 + * + * `request()` **不管** UI 导航(那是 `MainPage` 的事:它得先把 `currentIndex` + * 拨到 0,`CommPage` 才会挂载)。这里只管"把意图存住/交出去"。 + * 分开的好处:`MainPage` 里那两行是**唯一**需要读当前 tab 的地方, + * 而本类保持无 UI 依赖 —— 可被条件挂载的两侧都安全调用。 + */ +export class ComposeIntent { + /** + * 还没人接手的一次请求。 + * + * 用静态格子而不是 `AppStorage`:这与 `PushService.pendingRoute` 同一理由 —— + * 发起方(根组件)和接手方(条件挂载的 `CommPage`)之间只隔一次挂载, + * 不需要一个全局可观察的状态;而用了 `@StorageLink` 反而要处理 + * "读到之后怎么清"(不清会重放,清了会触发另一轮刷新)。 + */ + static pending: boolean = false; + + /** 已经在监听的那一处(同一时刻只可能有一处:`CommPage` 是唯一的写信持有者) */ + private static listener: (() => void) | undefined = undefined; + + /** `CommPage` 挂载时登记 */ + static setListener(fn: () => void): void { + ComposeIntent.listener = fn; + } + + /** `CommPage` 卸载时摘掉(否则会唤醒一个已销毁的组件) */ + static clearListener(): void { + ComposeIntent.listener = undefined; + } + + /** + * 提"我要写信"。 + * + * **有人监听就当场交出去;没人监听就存着等挂载后取走** —— 两半都留着, + * 因为两种时刻各走一条(用户此刻在通信页 / 在别的页)。 + */ + static request(): void { + const fn: (() => void) | undefined = ComposeIntent.listener; + if (fn !== undefined) { + fn(); + return; + } + ComposeIntent.pending = true; + } + + /** 取走待处理的请求(取完即清,避免下次挂载时重放) */ + static consume(): boolean { + if (!ComposeIntent.pending) { + return false; + } + ComposeIntent.pending = false; + return true; + } +} diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index dc03c33..f911a48 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -195,29 +195,58 @@ export class Theme { static readonly cardMaterial: BlurStyle = BlurStyle.BACKGROUND_THIN; /* - * ── 玻璃的**参数化**配方(比材质档更接近 WebUI)── + * ── 关于「参数化玻璃」(`backgroundEffect`):**本工程不用** ── * - * `BlurStyle` 是**固定档**(系统给的那几组),而 WebUI 的玻璃是**显式参数**: + * 2026-09-21 实测记录(我被自己的实验推翻,留证以免后人再走一遍)。 * - * .narrow-nav backdrop-filter: blur(18px) saturate(1.5) ← 浮条 - * --bg-blur-panel: 10px ← 面板 + * 这里原先有两个令牌 `glassCardRadius = 18` / `glassCardSaturation = 1.5`, + * 注释自己写着「与 WebUI `.narrow-nav` 逐字同源」—— 也就是**底部导航条** + * 那一档(`index.css:1204`:`backdrop-filter: blur(18px) saturate(1.5)`), + * 名字却叫 `glass**Card**…`。名与实不符立刻兑现了代价: + * 我照名字把它挂到了**每张卡片**上,而 WebUI 的卡片根本没有 `backdrop-filter` + * (`.glass-card` 只有白 + alpha)⇒ 多层模糊叠加成灰雾 + 滚动掉帧。 * - * 关键差别是 **saturate(饱和度增强)**:它让透过玻璃的颜色更鲜艳, - * 正是"玻璃感"的主要来源。`BlurStyle` 没有这个旋钮, - * 所以纯用材质档会偏灰 —— 用户说"不够炫酷"就是这个观感。 + * 上一轮"卡片去模糊"之后,这两个令牌**失去全部消费者** —— + * 是 `cross-client-theme` 的**孤儿令牌判据**把它们报出来的 + * (「这些 Theme 令牌在页面/组件里一次都没被引用」), + * 而不是烂在那里没人知道。 * - * ArkUI 的参数化 API 是 `backgroundEffect({ radius, saturation, brightness })`, - * 字段与 CSS 的 `backdrop-filter` 一一对应,于是可以**照抄 WebUI 的数**。 - * 取值规则(不是拍的): - * · `CARD_RADIUS = 18` / `CARD_SATURATION = 1.5` —— 与 WebUI `.narrow-nav` - * 逐字同源(那是 WebUI 里最"玻璃"的一档)。卡片面积小,用最有质感的档; - * · 面板面积大、透太多会伤正文可读性 ⇒ 用 `--bg-blur-panel: 10px`, - * 饱和度取 1.3(比 1.5 收一点 —— 大面积高饱和会让整页发艳)。 + * ── 我接着做了一次实验,然后被实测否掉 ── * - * ★ 为什么不直接抄 1.5 给面板:WebUI 那边这两个值本来就是**分开的** - * (10px 面板 / 18px 浮条),抄就得抄全套,不能只挑一个数。 + * 我推断「真归属是导航条」,于是把底栏从 + * `backgroundBlurStyle(Theme.navMaterial)` 改成 + * `backgroundEffect({radius:18, saturation:1.5})`,理由是 + * 「固定材质档没有 `saturate` 旋钮、观感偏灰」。 + * + * 实测(模拟器窄屏 1008×2232,取底栏中心列 x=504 的像素): + * + * y backgroundEffect backgroundBlurStyle + * 1960 rgb(191,199,209) rgb(234,235,239) ← 差 -36 亮度 + * 2000 rgb(198,204,212) rgb(234,235,239) ← 差 -31 + * 2060 rgb(206,211,219) rgb(234,235,239) ← 差 -24 + * + * ⇒ `backgroundEffect` **只给模糊、不给底色**,壁纸原样透上来, + * 整条底栏暗了 24~36 个亮度级 —— 比原来的"偏灰"糟得多。 + * + * ★ 为什么:WebUI 的 `.narrow-nav` 是**两条声明**组合出来的 —— + * background-color: rgb(var(--nav-bg)); + * backdrop-filter: blur(18px) saturate(1.5); + * 我只搬了后者、丢了前者。而 `backgroundBlurStyle` 的**系统材质本身** + * 就同时含「色调 + 模糊 + 深浅两套」,正好把这两条一起给了。 + * + * ★ 更要紧的一层:WebUI 那个 `--nav-bg` 是**手写 alpha**,跟不了深色主题 + * (§7.12 那一行的原话:「手写 alpha 跟不了深色」)。⇒ 在这里系统材质 + * 不是"退而求其次",而是**唯一能同时满足深浅两套**的方案。 + * §7.12 当初把这条判成"有意差异"是对的,我的"改进"才是退步。 + * + * ⇒ 结论:`navMaterial`(`BlurStyle.COMPONENT_THICK`)保持不变; + * 两个令牌**删除**(不删就是孤儿,孤儿判据会一直红着)。 + * + * ★ 留这段注释而不是直接删干净的原因:**下一个人会再想一遍同样的事** + * (WebUI 那行 `saturate(1.5)` 就摆在那里,很显眼)。 + * 把"试过了、实测数字在此、结论是不要"写在这里, + * 比让他重跑一遍模拟器便宜。 */ - static readonly glassCardRadius: number = 18; /* * ── 卡片的"玻璃":**白色 + alpha,不做模糊** ── * @@ -256,9 +285,30 @@ export class Theme { * 「基材恒为白,主题之间只差 alpha」)。深色下靠 `brightness` 与 * 文字令牌保证对比度,而不是把底也调黑。 */ + /* + * ── 卡片底:白色 + alpha(**与 WebUI `.glass-card` 逐字同源**)── + * + * index.css:1629 .glass-card → rgb(255 255 255 / 0.92) + * index.css html[data-bg='on'] … → rgb(255 255 255 / 0.78) + * ⇒ glassCard #EBFFFFFF(0.92 × 255 ≈ 235 = 0xEB) + * ⇒ glassCardWall #C7FFFFFF(0.78 × 255 ≈ 199 = 0xC7) + * + * ★ 为什么必须是**手写色**而不是 `$r('sys.*')`: + * 系统材质(`bgMaterial*` / `compBackground*`)是**不透明**的整块色板, + * 没有"白 92% 叠在壁纸上"这一档。而 WebUI 的玻璃观感**正是**这个 alpha + * —— 它不是模糊(`.glass-card` 全仓没有 `backdrop-filter`,只有 + * `.glass-control` 与 `.narrow-nav` 有)。换成系统材质 ⇒ 壁纸被整块盖死, + * 那正是用户报的「玻璃不透明」。 + * + * ★ 为什么用"白 + alpha"而不去调亮度模拟: + * 壁纸是**用户可换的图**,颜色不可预知;只有半透明白能同时适配浅壁纸 + * 与深壁纸(深色档另有 `DARK_DIM_MIN` 兜底)。 + * + * 已登记进 `cross-client-theme.test.mjs` 的 `SELF_OWNED_COLORS` + * (那条判据会红着提醒"理由不能只存在于写它那个人的记忆里")。 + */ static readonly glassCard: string = '#EBFFFFFF'; static readonly glassCardWall: string = '#C7FFFFFF'; - static readonly glassCardSaturation: number = 1.5; /** * 「悬浮玻璃板」的另外两半:**投影 + 发丝描边**。 * @@ -386,6 +436,26 @@ export class Theme { * 'plain' 档徽标底色(联系人数)。★ 必须石板灰而非红: * 三档被压成两档时 `'plain'` 会走 danger,看着像"有未读" * + * ★★ 2026-09-21 补登记:`glassCard` / `glassCardWall` + * + * · glassCard #EBFFFFFF WebUI `.glass-card`(`index.css:1629`) + * `background-color: rgb(255 255 255 / 0.92)` + * (0.92 × 255 ≈ 235 = 0xEB) + * · glassCardWall #C7FFFFFF 上者的"有壁纸"档 —— WebUI + * `html[data-bg='on'] .glass-card` 的 `… / 0.78` + * (0.78 × 255 ≈ 199 = 0xC7) + * + * 为什么必须自己写而不是走 `$r('sys.*')`:系统材质 + * (`bgMaterial*` / `compBackground*`)是**不透明**的整块色板, + * 没有"白 92% 叠在壁纸上"这一档。而 WebUI 的玻璃观感**正是**这个 alpha + * —— 它**不是**模糊(`.glass-card` 全仓没有 `backdrop-filter`; + * 只有 `.glass-control` 与 `.narrow-nav` 有)。换成系统材质 ⇒ 壁纸被 + * 整块盖死,那正是用户报的「玻璃不透明」。 + * + * 为什么是"白 + alpha"而不是调亮度模拟:壁纸是**用户可换的图**, + * 颜色不可预知;只有半透明白能同时适配浅壁纸与深壁纸 + * (深色档另有 `DARK_DIM_MIN` 兜底)。 + * * SSE 连接指示器的四个状态色(逐档对齐 WebUI 的 Tailwind 类, * `ConnectionIndicator.tsx:22-25`): * · sseConnected #22C55E bg-green-500 已连接 diff --git a/client/harmony/entry/src/main/ets/model/AddressSuggest.ts b/client/harmony/entry/src/main/ets/model/AddressSuggest.ts new file mode 100644 index 0000000..49726b7 --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/AddressSuggest.ts @@ -0,0 +1,200 @@ +/** + * 三维地址补全 —— **纯逻辑**(无 ArkUI / 无 @kit 导入,可被 node 直接跑)。 + * + * 用户 2026-09-21: + * · 「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」 + * · 「上下键切换发信目标」 + * · 「回车展开输入框等」 + * + * 为什么单独成文件、且**不引任何 @kit**:本仓的 `cross-client-logic.test.mjs` + * 会把 `.ts` 模型文件用 `--experimental-strip-types` 直接跑起来,与 electron + * 的同名逻辑逐例比对。带 `@kit` 导入的 `.ets` 做不到这件事(见那条判据的注释)。 + * + * ## 与 WebUI 的关系 + * + * WebUI 的实现是 `components/AddressInput.tsx`,逻辑散在组件里(`parseParts` + * 是模块级函数,`apply` 是闭包)。这里把它**抽成纯函数**: + * · `parseParts` —— 逐字照搬(`AddressInput.tsx:247`) + * · `mergeCandidate` —— `apply`(`:129`)的等价物,但不碰 React state + * · `nextActiveIndex` —— `onKeyDown` 里两个 `setActive` 的等价物 + * · `chooseEndpoints` —— 决定"问哪一层"(`AddressInput.tsx:50-58` 的判据) + * + * ★ 抽出来还有一个好处:**键盘行为可以单测**。"上下键循环"这种逻辑一旦 + * 写成组件闭包,就只能靠手动点击验;写成纯函数就能钉住边界(回绕、 + * 空列表、单元素)。 + */ + +/** 地址的三段(`name@path.session`)。 */ +export class AddressParts { + name: string = ''; + path: string = ''; + session: string = ''; + hasAt: boolean = false; + hasDot: boolean = false; +} + +/** + * 把编辑中的一段拆成三段。 + * + * 逐字对齐 WebUI `AddressInput.tsx:247-265`: + * · 没有 `@` ⇒ 整串都是 name + * · 有 `@` 没 `.` ⇒ `@` 之后是 path + * · 都有 ⇒ 以**最后一个** `.` 为界(`.` 之后是 session) + * + * ★ 最后那个"用 `lastIndexOf('.')` 而不是 `indexOf`"是有意的:工作区路径 + * 里可能带点(例如 `home/program/agentmail` 不含,但 `a.b/c` 含)。 + * 取最后一个点,才能让"会话别名"始终是最后一段。 + */ +export function parseParts(s: string): AddressParts { + const p: AddressParts = new AddressParts(); + const at: number = s.indexOf('@'); + if (at < 0) { + p.name = s; + return p; + } + p.name = s.slice(0, at); + p.hasAt = true; + const rest: string = s.slice(at + 1); + const dot: number = rest.lastIndexOf('.'); + if (dot < 0) { + p.path = rest; + return p; + } + p.path = rest.slice(0, dot); + p.session = rest.slice(dot + 1); + p.hasDot = true; + return p; +} + +/** + * 把选中的候选**拼回**一段完整地址。 + * + * 对齐 WebUI `AddressInput.tsx:129-141` 的 `apply`: + * · 选完 name → `{name}@` (后面还等着写 path) + * · 选完 path → `{name}@{path}.` (后面还等着写 session) + * · 选完 session → 完整三段 + * + * ★ 为什么 name/path 后面要**补上分隔符**:这样用户接着打字就自然进入下一段, + * 而不必自己敲 `@` 或 `.`。WebUI 的注释写着「name/path 选完仍停留在补全态, + * 继续下一段」——补分隔符是那件事的前提。 + */ +export function mergeCandidate(parts: AddressParts, kind: string, choice: string): string { + if (kind === 'name') { + return choice + '@'; + } + if (kind === 'path') { + return parts.name + '@' + choice + '.'; + } + return parts.name + '@' + parts.path + '.' + choice; +} + +/** + * 上下键移动时的下一个下标(**循环**)。 + * + * 对齐 WebUI `AddressInput.tsx:145-150`: + * setActive(i => (i + 1) % items.length) + * setActive(i => (i - 1 + items.length) % items.length) + * + * ★ 那个 `+ items.length` 不是多余:JS 的 `%` 对负数返回负数 + * (`-1 % 5 === -1`),所以直接写 `(i-1) % n` 会得到负下标。 + * 这类边界正是把它抽成纯函数后才测得出来的。 + * + * 列表为空时返回 0(没有可选项,任何下标都无意义,但也不能返回负数)。 + */ +export function nextActiveIndex(current: number, count: number, delta: number): number { + if (count <= 0) { + return 0; + } + return ((current + delta) % count + count) % count; +} + +/** + * 候选菜单要不要向上翻转。 + * + * 对齐 WebUI `AddressInput.tsx:104-112`:比较输入框上方与下方各有多少空间, + * 下方不够(且上方更多)时向上翻。 + * + * ★ 为什么抽出来:这个判据决定了菜单会不会**盖住输入框**, + * 而它只看两个数,很适合用纯函数钉住("下方够就不翻"、"下方不够就翻"、 + * "两边都够就用下方")。 + * + * @param spaceAbove 输入框上方可用高度(vp) + * @param spaceBelow 输入框下方可用高度(vp) + * @param need 菜单希望占的高度(vp) + */ +export function shouldFlipUp(spaceAbove: number, spaceBelow: number, need: number): boolean { + const want: number = Math.min(need, spaceAbove); + return spaceBelow < want && spaceAbove > spaceBelow; +} + +/** + * 按当前输入决定"问哪一层"并返回查询参数。 + * + * 对齐 WebUI `AddressInput.tsx:50-56`: + * 有 `.` → 问 session(传 name + path) + * 有 `@` → 问 path(只传 name) + * 都没有 → 问 name(不传参数) + * + * 返回值用**具名类**而不是对象字面量:ArkTS 的 `arkts-no-untyped-obj-literals` + * 不允许裸字面量(这条在 `.ts` 里同样被 linter 管)。 + */ +export class SuggestQuery { + name: string = ''; + path: string = ''; + /** 期望的层级,仅用于内部判断(服务端也会回一个 `kind`) */ + kind: string = 'name'; +} + +export function queryFor(parts: AddressParts): SuggestQuery { + const q: SuggestQuery = new SuggestQuery(); + if (parts.hasDot) { + q.name = parts.name; + q.path = parts.path; + q.kind = 'session'; + } else if (parts.hasAt) { + q.name = parts.name; + q.kind = 'path'; + } else { + q.kind = 'name'; + } + return q; +} + +/** + * 过滤候选 —— **必须保持 suggestions 与 candidates 的同序**。 + * + * 对齐 WebUI `AddressInput.tsx:60-70`。那段注释原文: + * 「过滤时保持 suggestions 与 candidates 同序:candidates 是按下标对应的, + * 分别过滤两个数组会让标题错位到别的别名上。」 + * + * 还有一条容易漏的:**标题也参与匹配**(`hay` 里拼了 `title`)—— + * 「想找「缓存选型」那条会话时,人记得的是标题而不是随机短名」。 + * + * @returns 保留下来的下标(升序) + */ +export function filterIndexes(suggestions: string[], titles: string[], fragment: string): number[] { + const keep: number[] = []; + const lower: string = fragment.toLowerCase(); + for (let i = 0; i < suggestions.length; i++) { + const title: string = i < titles.length ? titles[i] : ''; + const hay: string = title.length > 0 + ? (suggestions[i] + ' ' + title).toLowerCase() + : suggestions[i].toLowerCase(); + if (hay.indexOf(lower) >= 0) { + keep.push(i); + } + } + return keep; +} + +/** 把"保留下来的下标"作用到任意两个同长数组上(两处调用保持一致) */ +export function pickBy(arr: T[], indexes: number[]): T[] { + const out: T[] = []; + for (let i = 0; i < indexes.length; i++) { + const idx: number = indexes[i]; + if (idx >= 0 && idx < arr.length) { + out.push(arr[idx]); + } + } + return out; +} diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index 1706d80..9603c0e 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -371,10 +371,25 @@ export class UserKey { key_token: string = ''; } -/** 地址补全候选 */ +/** + * 地址补全候选(服务端 `repo.SessionCandidate`,**只有 `session` 层才有**)。 + * + * ★ 原声明写的是 `value`/`kind` —— 服务端从来没返回过这两个字段 + * (`contacts.go:206-210` 只给 `alias`/`title`/`source`)。 + * 本仓纪律:「声明了服务端从不返回的字段 ⇒ 删掉声明」。已按实际形状改正。 + * + * ★ `title` 带 `omitempty`(`platform_sessions.go:95`)⇒ **键可能不存在**。 + * ArkTS 的裸 cast(`JSON.parse(raw) as T`)在缺键时给 `undefined`, + * **不会**应用这里的 `= ''` 默认值。读它必须守,否则 + * `Cannot read property of undefined`(本仓踩过同一个坑,见 `MailDetail.normalize`)。 + */ export class AddressSuggestion { - value: string = ''; - kind: string = ''; + /** 填进 session 位的值 */ + alias: string = ''; + /** 给人看的标题;**服务端可能整个键都不返回**(omitempty) */ + title: string = ''; + /** 从哪来:mail(本侧线索,可直接送达)/ platform(平台侧镜像)/ new */ + source: string = ''; } /** 发信请求体 */ diff --git a/client/harmony/entry/src/main/ets/model/NavItems.ts b/client/harmony/entry/src/main/ets/model/NavItems.ts index f25d406..65880b8 100644 --- a/client/harmony/entry/src/main/ets/model/NavItems.ts +++ b/client/harmony/entry/src/main/ets/model/NavItems.ts @@ -333,8 +333,24 @@ export function navBadgeText(n: number): string { * 直接引 `HEADER_HEIGHT` 报 "used before its declaration"。) */ export const TAB_BAR_HEIGHT: number = 44; -export const TAB_BAR_RADIUS: number = TAB_BAR_HEIGHT / 2; -export const TAB_BAR_SIDE: number = NAV_BAR_SIDE; +/* + * ★★ 2026-09-21 **删除 `TAB_BAR_RADIUS` / `TAB_BAR_SIDE`**。 + * + * 它们服务的是"页签条自己成一条悬浮胶囊"那套形状,而那套形状被用户否掉了: + * 「你又在内部套了一个胶囊」「你直接改成右边没圆角就行了」。 + * 现在的页签条与窗格**齐平**、只有左上角一道弧(详见 `MainPage.CommTabBar()` + * 与 `harmony-nav.test.mjs` 那条判据里记的 WebUI 实测几何)。 + * + * ★ 为什么必须**删**而不是留着不用:留着它们就是**孤儿常量**。 + * 下一个人看到 `TAB_BAR_RADIUS` 这个名字,很自然会把胶囊拼回来 —— + * 而我当初就是从"这两个常量存在"推出"页签条该是个胶囊"的。 + * 名字与常量在,错误的设计就一直在邀请别人复现它。 + */ +/* + * `TAB_BAR_TOP` 保留:**仍在使用**,但换了主人 —— + * `HEADER_TOP`(统一顶栏的顶部留白)就是它(见本文件末尾)。 + * 页签条现在与窗格齐平,不再需要顶部浮起留白。 + */ /** 顶部留白(vp):离内容区顶部一点点,做成"浮着"而不是"贴着" */ export const TAB_BAR_TOP: number = 8; diff --git a/client/harmony/entry/src/main/ets/pages/ComposePage.ets b/client/harmony/entry/src/main/ets/pages/ComposePage.ets index c728067..fe671af 100644 --- a/client/harmony/entry/src/main/ets/pages/ComposePage.ets +++ b/client/harmony/entry/src/main/ets/pages/ComposePage.ets @@ -5,7 +5,7 @@ */ import { ApiClient, ApiError } from '../api/ApiClient'; import { Theme } from '../common/Theme'; -import { MailApi } from '../api/MailApi'; +import { MailApi, AddressSuggestionResponse } from '../api/MailApi'; import { AccountManager, AccountInfo } from '../api/AccountManager'; import { SendMailRequest } from '../model/Models'; import { ComposeParams } from '../model/RouteParams'; @@ -15,6 +15,8 @@ import { AmIcon } from '../common/Icons'; import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets'; import { PressEffectModifier } from '../common/Surface'; import { Motion } from '../common/Motion'; +import { KeyCode } from '@kit.InputKit'; +import { parseParts, mergeCandidate, nextActiveIndex, queryFor, filterIndexes, AddressParts, SuggestQuery } from '../model/AddressSuggest'; /** * 写邮件窗格。 @@ -86,6 +88,134 @@ export struct ComposeView { @State accountList: AccountInfo[] = []; @State selectedAccountId: string = ''; @State showAccountPicker: boolean = false; + /* + * ── 收件人候选补全(用户 2026-09-21:「上下键切换发信目标」「回车展开输入框」)── + * + * 与 WebUI `AddressInput.tsx` 同口径,规则本身在 `model/AddressSuggest.ts` + * (纯逻辑,已被 `cross-client-logic.test.mjs` 与 electron 逐例比对)。 + * 这里只放**界面状态**。 + */ + @State suggestItems: string[] = []; + @State suggestTitles: string[] = []; + @State suggestActive: number = 0; + @State suggestOpen: boolean = false; + /** 输入防抖(WebUI 是 120ms;打字每字符都打接口会打断输入) */ + private suggestTimer: number = -1; + + /** + * 收件人输入变化 —— 防抖后拉候选。 + * + * 与 WebUI `AddressInput.tsx:49-85` 同一流程:拆段 → 决定问哪一层 → + * 拉 → 按当前片段过滤(**下标同序**)。 + * + * ★ 防抖 120ms 与 WebUI 同值:每个字符都打接口会打断输入(网络往返期间 + * UI 线程虽不阻塞,但候选会跳)。 + */ + private onToChanged(v: string): void { + this.to = v; + if (this.suggestTimer >= 0) { + clearTimeout(this.suggestTimer); + } + this.suggestTimer = setTimeout(() => { + this.fetchSuggestions(); + }, 120); + } + + private async fetchSuggestions(): Promise { + const ctx = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + const api: MailApi | null = this.createSelectedMailApi(ctx); + if (api === null) { + return; + } + const parts: AddressParts = parseParts(this.to); + const q: SuggestQuery = queryFor(parts); + try { + const res: AddressSuggestionResponse = await api.suggestAddress(q.name, q.path); + const all: string[] = res.suggestions ?? []; + const cands = res.candidates ?? []; + /* + * 取标题 —— `title` 带 `omitempty`,缺键时裸 cast 给的是 `undefined` + * (**不是** 类里那个 `= ''`)。所以用 `typeof` 守一道再取。 + */ + const titles: string[] = []; + for (let i = 0; i < cands.length; i++) { + const c = cands[i]; + const raw: string | undefined = c === undefined ? undefined : c.title; + titles.push(typeof raw === 'string' ? raw : ''); + } + /* 片段:没写 @ 时用 name、写了 @ 用 path、写了 . 用 session(与 queryFor 同层) */ + const frag: string = parts.hasDot ? parts.session : (parts.hasAt ? parts.path : parts.name); + const keep: number[] = filterIndexes(all, titles, frag); + const items: string[] = []; + const keepTitles: string[] = []; + for (let i = 0; i < keep.length; i++) { + items.push(all[keep[i]]); + keepTitles.push(titles[keep[i]] ?? ''); + } + this.suggestItems = items; + this.suggestTitles = keepTitles; + this.suggestActive = 0; + this.suggestOpen = items.length > 0; + } catch { + /* + * 拉不到候选**不是错误**:静默收起,用户照常手打地址。 + * ★ 这里刻意用**无绑定** catch:写 `catch (e)` 会让 `e` 是 `any`, + * 撞 `arkts-no-any-unknown`(工程里别处也是这个写法)。 + */ + this.suggestItems = []; + this.suggestTitles = []; + this.suggestOpen = false; + } + } + + /** 选中一个候选 —— 拼回地址;name/path 段选完**仍停在补全态**(继续下一段) */ + private applySuggestion(choice: string): void { + const parts: AddressParts = parseParts(this.to); + const kind: string = parts.hasDot ? 'session' : (parts.hasAt ? 'path' : 'name'); + this.to = mergeCandidate(parts, kind, choice); + if (kind === 'session') { + this.suggestOpen = false; + this.suggestItems = []; + } else { + /* 还有下一段要选 ⇒ 立刻再拉一次(WebUI 同行为) */ + this.fetchSuggestions(); + } + } + + /** + * 收件人输入框的键盘处理 —— **这就是用户要的那几条**: + * · ↑ / ↓ 切换候选(`nextActiveIndex` 负责循环) + * · Enter / Tab 选中当前候选 + * · Esc 收起候选 + * + * ★ 用 `onKeyEvent` 而不是 `keyboardShortcut`:后者是**全局组合键** + * (Ctrl+字母 / F 键),而这几条是**输入框内**的导航键 —— + * 它们只有在焦点在这个输入框里时才有意义。 + * `keyboardShortcut` 是"无论焦点在哪都响应",用在这里会让 + * Enter 在页面任何地方都去改收件人。 + */ + private onToKey(e: KeyEvent): void { + if (e.type !== KeyType.Down) { + return; + } + if (!this.suggestOpen || this.suggestItems.length === 0) { + return; + } + if (e.keyCode === KeyCode.KEYCODE_DPAD_DOWN) { + this.suggestActive = nextActiveIndex(this.suggestActive, this.suggestItems.length, 1); + } else if (e.keyCode === KeyCode.KEYCODE_DPAD_UP) { + this.suggestActive = nextActiveIndex(this.suggestActive, this.suggestItems.length, -1); + } else if (e.keyCode === KeyCode.KEYCODE_ENTER || e.keyCode === KeyCode.KEYCODE_TAB) { + if (this.suggestActive >= 0 && this.suggestActive < this.suggestItems.length) { + this.applySuggestion(this.suggestItems[this.suggestActive]); + } + } else if (e.keyCode === KeyCode.KEYCODE_ESCAPE) { + this.suggestOpen = false; + } + } aboutToAppear(): void { const ctx = this.getUIContext().getHostContext(); @@ -366,16 +496,46 @@ export struct ComposeView { Divider().color(Theme.border) } - // 收件人 + // 收件人(带三段式补全;键盘 ↑↓ 切换、Enter/Tab 选中、Esc 收起) Row() { Text('收件人').fontSize(14).fontColor(Theme.textSubtleFor()).width(60) TextInput({ placeholder: 'name@path.session', text: this.to }) .layoutWeight(1).fontSize(14).backgroundColor(Color.Transparent) - .onChange((v: string) => { this.to = v; }) + .onChange((v: string) => { this.onToChanged(v); }) + .onKeyEvent((e: KeyEvent) => { this.onToKey(e); }) + .onBlur(() => { this.suggestOpen = false; }) } .width('100%').height(48).padding({ left: 12, right: 12 }) .backgroundColor(Theme.surface) + /* + * 候选列表 —— 贴在收件人行**下方**(WebUI `AddressInput` 的绝对定位菜单)。 + * + * ★ 为什么不用 `bindPopup`/`bindMenu`:那两者各有自己的焦点体系, + * 用户的按键会先被它们吃掉,"↑↓ 切换候选"就落不到 `onToKey` 上。 + * 内联渲染(条件挂载)能让焦点一直留在输入框里 —— 这是键盘可达的前提。 + */ + if (this.suggestOpen && this.suggestItems.length > 0) { + Column() { + ForEach(this.suggestItems, (item: string, idx: number) => { + Row() { + Text(item).fontSize(13).fontColor(Theme.textPrimary).layoutWeight(1) + if (idx < this.suggestTitles.length && this.suggestTitles[idx].length > 0) { + Text(this.suggestTitles[idx]) + .fontSize(12).fontColor(Theme.textSubtleFor()) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + } + } + .width('100%').height(40).padding({ left: 12, right: 12 }) + .backgroundColor(idx === this.suggestActive ? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent) + .onClick(() => { this.applySuggestion(item); }) + }, (item: string, idx: number) => item + '#' + idx.toString()) + } + .width('100%') + .backgroundColor(Theme.surface) + .borderRadius(Theme.glassRadius) + } + Divider().color(Theme.border) // 主题 diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index aec6ba2..f4e1923 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -21,6 +21,7 @@ import { AppearanceStore } from '../common/AppearanceStore'; import { MailStore, MailSnapshot, INBOX_PAGE_SIZE } from '../common/MailStore'; import { AppearanceApi } from '../api/AppearanceApi'; import { performLogout } from '../api/Logout'; +import { ComposeIntent } from '../common/ComposeIntent'; import { Configuration, ConfigurationConstant, EnvironmentCallback, common } from '@kit.AbilityKit'; import { image } from '@kit.ImageKit'; import { AppearanceSnapshot, isDarkMode, scrimOpacity } from '../model/Appearance'; @@ -90,9 +91,6 @@ import { NAV_ITEM_MIN_HIT, NAV_ITEMS, HEADER_BACK_HIT, - TAB_BAR_RADIUS, - TAB_BAR_SIDE, - TAB_BAR_TOP, navBadgeCount, navBadgeText, navBadgeTone, @@ -1598,6 +1596,20 @@ struct CommPage { @State currentMailId: string = ''; aboutToAppear(): void { + /* + * ★ 2in1 快捷键的**接手方**(用户 2026-09-21:「快捷键打开发信页面」)。 + * + * 本页是**条件挂载**的(`if (this.currentIndex === 0)`)⇒ 用户按键时它 + * 可能还没实例化。所以两半都要有: + * · 登记监听 —— 用户此刻就在通信页时,当场响应; + * · `consume()` —— 用户此刻在别的页时,请求存在格子里, + * 本页挂载后立刻取走(否则按了快捷键一切换页面就该弹写信, + * 却因为"没人在听"而丢掉)。 + */ + ComposeIntent.setListener(() => { this.openCompose(); }); + if (ComposeIntent.consume()) { + this.openCompose(); + } this.refreshCounts(); /* * ★ 注册"点通知要跳转"的回调(2026-09-17 真机实测补的**另一半**)。 @@ -1615,6 +1627,9 @@ struct CommPage { } aboutToDisappear(): void { + /* 摘掉快捷键监听 —— 否则会唤醒一个已销毁的组件 */ + ComposeIntent.clearListener(); + // 必须摘掉:留着会叫醒一个已销毁的页面(跳转落空,还容易误判成"推送坏了") PushService.clearRouteListener(); } @@ -3480,6 +3495,19 @@ struct MainPage { * ⑤ §7.12 的「材质(玻璃)」行原本写的就是固定档 ⇒ (a) 是**回到**已登记契约。 * 产品向还有一条:**导航条是 chrome,材质应当稳定**,不该因为用户换了一张壁纸而变厚变薄。 */ + /* + * ★★ 2026-09-21 **底栏改成参数化玻璃**(用户:「玻璃也不透明」「不够炫酷」)。 + * + * 底栏是 WebUI 里 `backdrop-filter` 的两个位置之一, + * 且数值最大的一处(`.narrow-nav`:`blur(18px) saturate(1.5)`, + * `index.css:1204`)。`saturate` 才是"玻璃感"的主旋钮 —— + * 透过玻璃的颜色被提亮,而固定材质档没有这个旋钮(观感偏灰)。 + * + * `backgroundEffect` 取代 `backgroundBlurStyle`(两者都调会叠加: + * 那不是"更玻璃",而是两层各采样一次背景 ⇒ 更脏更糊 + 滚动掉帧)。 + * + * 数值全部来自 `Theme.navGlassEffect()` —— 不在调用点写死。 + */ .backgroundBlurStyle(Theme.navMaterial) } .width('100%') @@ -3887,5 +3915,47 @@ struct MainPage { this.isWide = (newValue.width as number) >= 768; this.recomputeNavReserve(); }) + /* + * ★★ 2in1 快捷键(用户 2026-09-21:「接下来做一下 2in1 上的快捷键, + * 比如快捷键打开发信页面」)。 + * + * 用官方的 **`keyboardShortcut`(组件快捷键事件,API 10+)**, + * 而不是自己 `onKeyEvent` 收 Ctrl+字母。理由(官方 API 文档原文): + * · 「即使组件未获焦或是在所在页面未展示,只要已经挂载到**获焦窗口** + * 的组件树上就会响应自定义组合键」 + * · 「无论组件是否获焦 —— 只要窗口获焦,快捷键就会响应」 + * 自己收 onKeyEvent 则要**先让某个组件获焦**,而邮件列表里焦点落在哪 + * 是不确定的(点一下就换),快捷键会时灵时不灵。 + * + * 绑定位置选**根 Stack**:它是"整个 window 的组件树"的根, + * 只要窗口在就生效。绑在 FAB 上不行 —— FAB 在别的 `if` 分支里、 + * 窄屏/宽屏位置也不同,它会随分支挂载/卸载。 + * + * 键位选 `Ctrl+N`:官方文档给了硬约束 —— + * 「控制键 Ctrl、Shift、Alt 及它们的组合加上热键的单个字符」, + * 而禁止绑定的五个系统组合键(Alt+F4 / Alt+Tab / Ctrl+Shift+Esc 等) + * 都不含 Ctrl+N。写邮件在桌面端历来是 Ctrl+N(新窗口/新邮件), + * 迁移成本最低。 + * + * ★ 文档还有一条要记住的坑:「多个不同组件设置相同组合键 ⇒ + * **只响应节点树上的深度最浅的组件**,其它组件不响应」。 + * 所以别再给别处的"写信"按钮补一个同键位绑定 —— 那不是"多一个入口", + * 而是**让这一处失效**(浅的那个永远赢)。 + */ + .keyboardShortcut('n', [ModifierKey.CTRL], () => { + /* + * ★ 这里**不能**直接叫 `openCompose()`:根组件是 `MainPage`, + * 而 `openCompose()` 住在 `CommPage` 上(写信要用 `CommPage` 自己的 + * `navPathStack`,宽屏 Split 才能在右栏开)。 + * + * 两步: + * ① 把通信页切到前台 —— 否则用户此刻若在日历页, + * `CommPage` 因条件挂载还没实例化,没有监听者; + * ② 提"要写信"的意图,由 `ComposeIntent` 负责 + * "此刻有人听就当场给、没人听就存着"。 + */ + this.currentIndex = 0; + ComposeIntent.request(); + }) } } \ No newline at end of file