From e54dc39f8fcac9ab3bfbff432084066524f016ab Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 25 Sep 2026 12:21:00 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E9=B8=BF=E8=92=99=202in1?= =?UTF-8?q?=20=E9=94=AE=E7=9B=98=E5=8F=AF=E8=BE=BE=20+=20=E6=82=AC?= =?UTF-8?q?=E5=81=9C=20+=20=E6=B2=89=E6=B5=B8=E9=A1=B6=E6=A0=8F=20+=20?= =?UTF-8?q?=E4=B8=89=E9=94=AE=E9=81=BF=E8=AE=A9=20+=20=E4=BF=AE=E5=8F=A0?= =?UTF-8?q?=E6=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户四条: ①「2in1 手势」(选了 悬停/右键菜单/触控板 + 快捷键:主页回车写信、 详情页回车回复、Esc 返回) ②「宽屏状态一个邮件被反复点击会被多次填充到右侧」 ③「你在登陆页是不是没有做 enter 等键的监听」——**确实漏了** ④「app 顶栏为什么不沉浸」+「右侧三键应当有独立避让」 ② 叠栈(实测复现 → 修 → 实测通过) 根因是框架语义用错:pushPath 默认 LaunchMode.STANDARD 每次入栈 ⇒ 重建详情组件 + 重拉数据 + 重放入场动画;返回还要按多次。 而 WebUI 是 `set({currentMail})` 幂等赋值(mailStore.ts:115)。 改用 LaunchMode.MOVE_TO_TOP_SINGLETON(官方:同名已在栈里就移上去、 不新建),MainPage + ContactsTab 两处 push 点都改(只改一边=换栏点 又不正常)。 实测:连点同一封 3 次 → **点一次返回就回占位**(修复前要按 3 次)。 ③ 登录页回车(用户点出来的真实缺失) WebUI 是 `
`(LoginPage.tsx:92)——浏览器里 输入框按回车就提交;鸿蒙登录页**一行键盘监听都没有**。 补上,走**已有的** doLogin()(不另写一条登录路,免得与按钮的条件分叉)。 ① 快捷键:新增 model/KeyboardShortcuts.ts(规则集中一处,三页共用) · 主页根 Stack:Enter → 写信(与 Ctrl+N 同一个 ComposeIntent.request) · 详情页:Enter → 开回复(复用 openReplyWithMorph,连动画都不另开); Esc → 返回,且**弹层开着时先关弹层**再按才返回(否则用户想关回复框 却被踢回列表,输入到一半的内容全没) · 用键事件**冒泡**:子组件先拿到、未消费才到页面根 ⇒ "详情优先、 主页兜底"由框架保证,不是我自己排的优先级 ★ 为何不用 keyboardShortcut:它只收组合键;不带修饰键时只认 FunctionKey, 而 FunctionKey 枚举(enums.d.ts:3444)**没有 Enter**(只有 ESC/F1-F12/ TAB/方向键)⇒ 单按回车表达不出来。 ① 悬停反馈:MailRow/SentRow 挂 onHover + Theme.surfaceMuted (该令牌此前**零使用**,注释本就写着"列表行 hover",正好归位)。 不用 .hoverEffect():系统叠层会与选中/未读的 accentSoft 叠成第三种颜色。 ★ 状态存 mail_id 而不是布尔:行本体是 @Builder(无自身状态), 布尔会变成"悬停一行、同栏全亮",所以状态放栏上、存"是哪一封"。 ④ 沉浸顶栏:EntryAbility 加 setWindowDecorVisible(false) 实测(2in1 截图硬证):标题栏(AgentMailHarmony)下面**还有一条白条**, 内容从第二条下面才开始。根因是**从未调过装饰接口**⇒用系统默认(PC 带标题栏)。 setWindowLayoutFullScreen(true) 管的是"内容铺到**屏幕**四边", **不包含**"窗口自己的标题栏是否隐藏"——两件不同的事。 ④ 三键避让:Insets 加 windowDecor + getWindowDecorHeight() 隐掉标题栏白条后,系统仍在右上角**浮着**三键(官方:全屏悬浮态固定 37vp)。 而 2in1 **没有状态栏** ⇒ TYPE_SYSTEM.topRect 是 0 ⇒ 只看 statusBar 就 认定"顶部无需避让",内容(右上是「授权」页签)被三键压住。 AvoidAreaType 六种里**没有**"标题栏/三键"这一类,只能单独读 getWindowDecorHeight()(它直接返回 vp)。 避让取**较大者**不加:两者互斥(有状态栏的形态没装饰,反之亦然)。 实测日志:`statusBar=0 navIndicator=0 windowDecor=37`,页签条下移。 判据(13 条新增/改,全部变异验证过) - 新增 5 条「2in1 快捷键」:单一出处(三页都不得自己比 KEYCODE_ENTER)、 登录页回车、详情页 Enter/Esc + 先关弹层、窗口装饰必须隐掉。 ★ 两条第一版是**我自己判红了自己**,都是判据比事实严格: ① 详情页确实有 KEYCODE_ENTER —— 那是**输入框内的候选导航**(onFwdKey), 与页面级快捷键是两件事 ⇒ 改成只查页面级 onKeyEvent 那一段; ② isEscapeKey 先在 import 行出现,从那里切片取到的是注释 ⇒ 改成 从页面级 onKeyEvent 内部起切。 ★ 窗口装饰那条第一版写 `/setWindowDecorVisible\(false\)/` —— 紧邻两行**日志** 也含这个串,删掉真正的调用后判据**照样绿**(变异实测没红)。 改成匹配调用形态 `win.setWindowDecorVisible(false)` 后才真会红。 这是"判据匹配到的是关于这件事的文字、不是这件事"的形状。 - harmony-widescreen ⑥ 原来钉精确串 `pushPath({ name: MAIL_DETAIL_ROUTE, param: params })`,加了 launchMode 参数后判红 —— 那是**判据写死了写法**。改成按结构匹配(不变式:选中邮件 要经 navPathStack.pushPath 进 MAIL_DETAIL_ROUTE,带不带 options 是实现细节)。 - harmony-2in1 登记数 12 → 16。 环境(这次为了真验 2in1 专门搭的) - 下载 2in1 镜像 HarmonyOS 6.1.0(23)(与 target 一致),建实例 HA2in1 (3120×2080,14.2" 笔记本),设备 127.0.0.1:5557,形态确认为 `2in1`。 - 带窗口启动要 Qt xcb:补了 5 个 xcb 库 + Xvfb :99(`-noWindow` 下 2in1 起不了 App)。 - ★ 多设备并存时设备判据会自己挑目标 ⇒ 必须 `AGENTMAIL_HARMONY_TARGET=127.0.0.1:5555` 才跑手机那台;不指定时判据连到未登录的 2in1 上会假红。 这正是 harmony-device.mjs 里 targetKey() 注释写明的已知行为。 ★ 未验(要说清楚,不能算过) - Enter/Esc/Ctrl+N 三个快捷键**仍未在设备上端到端验过**:2in1 模拟器 + Xvfb 下 键盘事件送不进 App(xdotool 的文本能进 TextInput 走输入法通道,但键事件不达; hdc 的 uinput/uitest keyEvent 同样不生效)。日志显示 SubscribeKeyEvent 被调用 ⇒ 订阅注册成功,纯粹是键送达不了。属环境限制。 - 悬停同理(要有鼠标 hover 事件注入,xdotool mousemove 到窗口不一定转成 ArkUI 的 onHover)。 - 登录页回车:同一限制。 沉浸顶栏与三键避让是**截图硬证过**的(不依赖键盘)。 --- client/electron/test/harmony-2in1.test.mjs | 127 +++++++++++++++++ .../electron/test/harmony-widescreen.test.mjs | 16 ++- client/electron/test/run-all.mjs | 2 +- .../main/ets/entryability/EntryAbility.ets | 61 +++++++- .../src/main/ets/model/KeyboardShortcuts.ts | 101 ++++++++++++++ .../entry/src/main/ets/model/WindowInsets.ts | 54 ++++++- .../entry/src/main/ets/pages/ContactsTab.ets | 12 +- .../entry/src/main/ets/pages/LoginPage.ets | 45 ++++++ .../src/main/ets/pages/MailDetailPage.ets | 68 +++++++++ .../entry/src/main/ets/pages/MainPage.ets | 132 +++++++++++++++++- 10 files changed, 606 insertions(+), 12 deletions(-) create mode 100644 client/harmony/entry/src/main/ets/model/KeyboardShortcuts.ts diff --git a/client/electron/test/harmony-2in1.test.mjs b/client/electron/test/harmony-2in1.test.mjs index 3c37dc9..52fc1ee 100644 --- a/client/electron/test/harmony-2in1.test.mjs +++ b/client/electron/test/harmony-2in1.test.mjs @@ -368,3 +368,130 @@ test('2in1 快捷键|选 keyboardShortcut 的理由写在源码里(否则后 assert.match(mainProse, /未获焦|获焦窗口/, '要写明"不依赖焦点"这条理由'); assert.match(PROSE_INTENT, /两半|当初|坑/, 'ComposeIntent 要留下"为什么需要中间层"的由来'); }); + +/* ──────────────────────────────────────────────────────────────── + * ⑤ 键盘可达的**其余三个页面**(2026-09-24 用户逐条点名) + * + * 用户原话:「还有快捷键,比如主页回车打开写信。邮件详情页回车打开回复。 + * esc返回上一级」+「你在登陆页是不是没有做 enter 等键的监听」。 + * ──────────────────────────────────────────────────────────────── */ + +test('2in1 快捷键|三个页面都用同一套键判定(不许各写一遍 KeyCode 比较)', () => { + /* + * 钉的是**单一出处**这条纪律。 + * + * 三处(主页 / 详情 / 登录)都要判"是不是回车、是不是按下"。 + * 各写一遍 `e.keyCode === KeyCode.KEYCODE_ENTER` 的后果早就见过: + * 本仓 `SentRow` 与 `MailRow` 那对就是"同一件事两处各写一遍、然后慢慢分叉"。 + * 所以规则集中在 `model/KeyboardShortcuts.ts`,三个页面只 import。 + * + * 改坏会红:任一页面改成自己比较 KeyCode(而不是走 isEnterKey)。 + */ + const LOGIN = code(join(ETS, 'pages/LoginPage.ets')); + const DETAIL = code(join(ETS, 'pages/MailDetailPage.ets')); + const SHORTCUTS = code(join(ETS, 'model/KeyboardShortcuts.ts')); + + assert.match(SHORTCUTS, /export function isEnterKey/, '键判定要有一个集中出处'); + assert.match(SHORTCUTS, /export function isKeyDown/, '按下/抬起的判定同样要集中'); + + for (const [name, src] of [['主页', MAIN], ['详情页', DETAIL], ['登录页', LOGIN]]) { + assert.match(src, /from '\.\.\/model\/KeyboardShortcuts'/, + `${name} 要从 KeyboardShortcuts 引键判定(不是自己写 KeyCode 比较)`); + } + /* + * 页面级的键处理不许自己比较 ENTER —— 那是分叉的起点。 + * + * ★ 只查**页面级 onKeyEvent 那一处**,不查全文件: + * 详情页里还有**输入框内的候选导航**(`onFwdKey`/`onToKey`, + * 与 ComposePage 同款),那里的 `KEYCODE_ENTER` 是"选中候选", + * 与"页面级回车=打开回复"是**两件事**,必须分开判。 + * (第一版我查了全文件、判红了自己 —— 判据比事实严格也是错的。) + */ + for (const [name, src] of [['主页', MAIN], ['详情页', DETAIL], ['登录页', LOGIN]]) { + const at = src.indexOf('.onKeyEvent((e: KeyEvent)'); + assert.ok(at > 0, `${name} 要有页面级 onKeyEvent`); + /* 取到该回调结束(下一个 `})` 收口)—— 页面级处理的范围 */ + const body = src.slice(at, src.indexOf('})', at) + 2); + assert.ok(!/KEYCODE_ENTER/.test(body), + `${name} 的页面级键处理不许自己比较 KEYCODE_ENTER(要经 isEnterKey)`); + } +}); + +test('2in1 快捷键|登录页要有回车提交(WebUI 靠 天然有,我们漏了)', () => { + /* + * ── 这条钉的是一个**真实漏掉的差异** ── + * WebUI 登录页是 ``(`LoginPage.tsx:92`)—— + * 浏览器里在输入框按回车就会提交表单。 + * 而鸿蒙登录页**一行键盘监听都没有**(2026-09-24 用户点出来的)。 + * + * 改坏会红:删掉登录页的 `.onKeyEvent`。 + */ + const LOGIN = code(join(ETS, 'pages/LoginPage.ets')); + assert.match(LOGIN, /\.onKeyEvent\(/, '登录页要挂键盘监听(回车直接登录)'); + assert.match(LOGIN, /this\.doLogin\(\)/, + '回车要走**已有的** doLogin(不另写一条登录路 —— 那会与按钮的条件分叉)'); + assert.match(LOGIN, /isEnterKey/, '用集中的键判定,不自己比 KeyCode'); +}); + +test('2in1 快捷键|详情页 Enter=回复 / Esc=返回,且弹层开着时 Esc 先关弹层', () => { + /* + * 用户:「邮件详情页回车打开回复。esc返回上一级」。 + * + * ★ Esc 那条有个容易漏的分寸:回复条/转发条/对话树开着时, + * Esc 应当**先关弹层**,再按一次才返回 —— + * 否则用户想关掉回复框,结果被直接踢回列表,输入到一半的内容全没了。 + * + * 改坏会红:把 showReplyBox/showForwardBox/showThread 那几道先关弹层的门删掉。 + */ + const DETAIL = code(join(ETS, 'pages/MailDetailPage.ets')); + assert.match(DETAIL, /\.onKeyEvent\(/, '详情页要挂键盘监听'); + assert.match(DETAIL, /isEscapeKey/, '要用 Esc 判定'); + assert.match(DETAIL, /isEnterKey/, '要用回车判定'); + assert.match(DETAIL, /openReplyWithMorph/, + '回车要走**已有的** openReplyWithMorph(与回复球同一个入口,连动画都不另开一条)'); + + /* + * 先关弹层:三个 @State 都要在 Esc 分支里出现。 + * + * ★ 切片起点要取**页面级 onKeyEvent 内部**那个 `isEscapeKey` + * (DETAIL 里 `isEscapeKey` 先在 import 行出现 —— 从那里切会取到注释, + * 第一版就是这么判红的)。 + */ + const evAt = DETAIL.indexOf('.onKeyEvent((e: KeyEvent)'); + assert.ok(evAt > 0, '要有页面级 onKeyEvent'); + const escAt = DETAIL.indexOf('isEscapeKey', evAt); + assert.ok(escAt > 0, '要有 Esc 判定'); + const escBody = DETAIL.slice(escAt, escAt + 900); + for (const state of ['showThread', 'showReplyBox', 'showForwardBox']) { + assert.match(escBody, new RegExp(state), + `Esc 分支要先关 ${state}(弹层开着时 Esc 先关它,再按才返回)`); + } +}); + +test('2in1|2in1/PC 上窗口装饰必须隐掉(否则标题栏下多一条"假顶栏"=不沉浸)', () => { + /* + * ── 用户报的问题 ── + * 「app 顶栏为什么不沉浸」。实测(2in1 3120×2080 截图硬证): + * 窗口标题栏(`AgentMailHarmony`)下面**还有一条白条**,内容从第二条下面才开始。 + * 对比同一台机器上的系统通知面板:标题栏与内容融为一体。 + * + * ── 根因 ── + * 从未调过装饰接口 ⇒ 用系统默认(PC 形态带标题栏)。 + * `setWindowLayoutFullScreen(true)` 管的是"内容铺到**屏幕**四边", + * **不包含"窗口自己的标题栏是否隐藏"** —— 两件不同的事。 + * + * 改坏会红:删掉 `setWindowDecorVisible(false)`。 + */ + const ABILITY = code(join(ETS, 'entryability/EntryAbility.ets')); + /* + * ★ 匹配**调用形态** `win.setWindowDecorVisible(false)`,不是裸方法名。 + * 第一版写的是 `/setWindowDecorVisible\(false\)/` —— 而紧邻的两行日志 + * ('setWindowDecorVisible(false) ok')也含这个串 ⇒ 删掉真正的调用后 + * 判据照样绿(变异实测:没红)。这正是"判据在骗自己"的形状: + * 它匹配到的是**关于这件事的文字**,不是**这件事**。 + */ + assert.match(ABILITY, /win\.setWindowDecorVisible\(false\)/, + '要显式隐掉窗口装饰(不调就是系统默认带标题栏)'); + assert.match(ABILITY, /win\.setWindowLayoutFullScreen\(true\)/, + '两条是**互补**的:这条管屏幕四边、装饰那条管窗口标题栏,删任一条都会露馅'); +}); diff --git a/client/electron/test/harmony-widescreen.test.mjs b/client/electron/test/harmony-widescreen.test.mjs index 83a72e5..32608dc 100644 --- a/client/electron/test/harmony-widescreen.test.mjs +++ b/client/electron/test/harmony-widescreen.test.mjs @@ -264,8 +264,20 @@ test('⑥ 邮件列表→详情使用系统 Navigation Auto,不再手搓 Row '列表栏应允许系统在 280–360vp 内适配,而不是固定死布局'); assert.match(code_, /\.minContentWidth\(360\)/, '详情栏最小宽度参与 Auto 断点计算'); - assert.match(code_, /this\.navPathStack\.pushPath\(\{ name: MAIL_DETAIL_ROUTE, param: params \}\)/, - '选中邮件必须进入 NavPathStack,而不是绕回 router.pushUrl'); + /* + * ★★ 2026-09-24 改:原来钉的是**精确串** + * `pushPath({ name: MAIL_DETAIL_ROUTE, param: params })` —— + * 而当天给这个调用加了第二个参数(`{ launchMode: MOVE_TO_TOP_SINGLETON }`, + * 修"反复点同一封会叠栈"),判据立刻判红。 + * + * 那是**判据写死了写法**(钉形状而不是不变式)——同一类错本仓见过多次。 + * 真正的不变式是:**选中邮件要经 `navPathStack.pushPath` 进 MAIL_DETAIL_ROUTE**, + * 至于后面带不带 options 是实现细节。改成按**结构**匹配。 + * + * 改坏会红:把 pushPath 换回 `router.pushUrl`(或改成别的路由名)。 + */ + assert.match(code_, /this\.navPathStack\.pushPath\(\{\s*name:\s*MAIL_DETAIL_ROUTE,/, + '选中邮件必须进入 NavPathStack(MAIL_DETAIL_ROUTE),而不是绕回 router.pushUrl'); }); test('⑤ 宽屏 app-shell 几何:padding/gap/radius 与 WebUI 同值(一比一复刻的骨架)', () => { diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 0c561ce..a12d537 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -152,7 +152,7 @@ const SUITE = [ * 2in1 键盘可达(用户 2026-09-21「快捷键打开发信页面 / 上下键切换发信目标 / * 回车展开输入框」)。见该文件头部说明:为什么单开一个文件、为什么不端到端验。 */ - ['test/harmony-2in1.test.mjs', [], 12], + ['test/harmony-2in1.test.mjs', [], 16], ['test/harmony-contacts.test.mjs', [], 5], // ★★ 下面三条是**补接线**,不是新写的判据(2026-09-17)。 // diff --git a/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets b/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets index f405617..c6c8542 100644 --- a/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets +++ b/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets @@ -220,6 +220,41 @@ export default class EntryAbility extends UIAbility { }).catch((err: BusinessError) => { hilog.error(DOMAIN, 'testTag', 'setWindowLayoutFullScreen failed: %{public}s', JSON.stringify(err)); }); + /* + * ★★ 2026-09-24 新增:**2in1 / PC 上把所有窗口装饰(标题栏)隐掉**。 + * + * ── 用户报的问题 ── + * 「app 顶栏为什么不沉浸」。实测(2in1 模拟器 3120×2080,截图硬证): + * 窗口标题栏(`AgentMailHarmony` 那一行)下面**还有一条白色横条**, + * 内容从第二条白条下面才开始 —— 等于多了一条"假顶栏"。 + * 对比同一台机器上的**系统窗口**(右上通知面板):它的标题栏与内容 + * 融为一体、内容直接铺到窗口顶部。 + * + * ── 根因 ── + * 我们从来没调过装饰接口 ⇒ 用的是**系统默认**:PC 形态下带标题栏。 + * 而 `setWindowLayoutFullScreen(true)`(上面那条)管的是"内容铺到**屏幕**四边", + * **不包含"窗口自己的标题栏是否隐藏"** —— 两件不同的事。 + * + * ── 这一条不惹手机形态 ── + * 官方文档(`@ohos.window.d.ts:7613-7620`): + * 「Sets whether the title bar is visible in the window. This API takes effect + * for the window that has a title bar or a three-button area.」 + * 手机全屏形态下本就没标题栏 ⇒ 调了也是空操作,不会把状态栏一起关掉 + * (状态栏是 `setWindowSystemBarEnable` 管的,与装饰无关)。 + * + * ★ 时序要求(官方原文,必须守住): + * 「In the stage model, this API must be used after the call of `loadContent` + * or `setUIContent` takes effect.」 + * 而 `setupFullScreenWindow` 是在 `onWindowStageCreate` → `windowStage.loadContent` + * 的 `.then()` 里调的(见调用点),已经满足。 + */ + try { + win.setWindowDecorVisible(false); + hilog.info(DOMAIN, 'testTag', 'setWindowDecorVisible(false) ok'); + } catch (err) { + /* 不致命:某些设备/形态不支标题栏,抛错时保持默认即可 */ + hilog.error(DOMAIN, 'testTag', 'setWindowDecorVisible failed: %{public}s', JSON.stringify(err)); + } /* * 状态栏**保留可见**(时间/电量要看得见),只是内容铺到它底下。 * 下面这条 `setWindowSystemBarEnable(['status'])` 是上一位留下的: @@ -267,10 +302,30 @@ export default class EntryAbility extends UIAbility { const system: window.AvoidArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); const navIndicator: window.AvoidArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR); const uiContext = win.getUIContext(); - const next: Insets = insetsFromAvoidArea(system, navIndicator, (px: number) => uiContext.px2vp(px)); + /* + * ★★ 2026-09-24 新增(用户:「右侧三键应当有独立避让」)。 + * + * `TYPE_SYSTEM` 在 2in1 上**不含**右上那三键(那个类型只管状态栏+底部导航, + * 而 2in1 没有状态栏 ⇒ topRect=0)。三键的高度只能问 `getWindowDecorHeight()` + * —— 它**直接返回 vp**(不需要 `px2vp`,与 `AvoidArea` 那几个不一样), + * 官方范围是 [37,112](全屏悬浮态固定 37)。 + * + * 读不到就当 0(手机形态没装饰,本来就不该让位)—— + * 不猜一个值:猜错会"按错的值让位、错得看不出来"。 + */ + let decorVp: number = 0; + try { + decorVp = win.getWindowDecorHeight(); + } catch (decorErr) { + /* 不支持该 API 的形态(手机):保持 0,与修之前完全一致 */ + hilog.info(DOMAIN, 'testTag', 'getWindowDecorHeight 不可用(非 PC 形态属正常):%{public}s', + JSON.stringify(decorErr)); + } + const next: Insets = insetsFromAvoidArea(system, navIndicator, + (px: number) => uiContext.px2vp(px), decorVp); AppStorage.setOrCreate(KEY_WINDOW_INSETS, next); - hilog.info(DOMAIN, 'testTag', 'insets: statusBar=%{public}d navIndicator=%{public}d', - next.statusBar, next.navIndicator); + hilog.info(DOMAIN, 'testTag', 'insets: statusBar=%{public}d navIndicator=%{public}d windowDecor=%{public}d', + next.statusBar, next.navIndicator, next.windowDecor); } catch (err) { hilog.error(DOMAIN, 'testTag', '读避让区失败(保持默认 0,黑边照旧):%{public}s', JSON.stringify(err)); } diff --git a/client/harmony/entry/src/main/ets/model/KeyboardShortcuts.ts b/client/harmony/entry/src/main/ets/model/KeyboardShortcuts.ts new file mode 100644 index 0000000..db7e300 --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/KeyboardShortcuts.ts @@ -0,0 +1,101 @@ +/* + * AgentMail 鸿蒙客户端 — 2in1(PC / 平板键盘态)键盘快捷键 + * + * ★★ 2026-09-24 新增。用户:「还有快捷键,比如主页回车打开写信。 + * 邮件详情页回车打开回复。esc返回上一级」。 + * + * ## 为什么单独一个模块 + * + * 这三条键各自要落在**不同的组件**上(主页在 `MainPage` 根、详情在 + * `MailDetailView`),而它们的**判定规则是同一套**(哪些键、什么条件、 + * 要不要吞掉事件)。规则写两遍必然分叉 —— 本仓反复出现的形状。 + * 所以规则住在这里,两处只做"接线"。 + * + * ## 为什么不用 `keyboardShortcut`(Ctrl+N 那条用了) + * + * `keyboardShortcut` 只接受**组合键**: + * · 字符键必须配修饰键(Ctrl / Shift / Alt); + * · 不配修饰键时只接受 `FunctionKey`。 + * 而框架的 `FunctionKey`(`component/enums.d.ts:3444`)只有 + * ESC / F1–F12 / TAB / DPAD_UP / DPAD_DOWN / DPAD_LEFT / DPAD_RIGHT + * —— **没有 Enter**。所以"单按回车"用它表达不出来。 + * + * 官方给单键的路是 `onKeyEvent`(`common.d.ts:19510`),而它 + * 「Triggered when a key operation is performed on the bound component + * **after it obtains focus**」—— 需要焦点。 + * + * ## 焦点问题怎么解(这是本条能力的关键) + * + * 邮件列表里焦点会随点击乱跑,所以"绑在某个列表项上等它获焦"不可靠。 + * 但官方同时给了**冒泡**语义(`common.d.ts:19560-19562`): + * 「If the callback returns **true**, the key event is marked as consumed + * and will not **bubble up** to parent components.」 + * 反过来说:**回调返回 false(或不处理)时,事件会冒泡到父组件**。 + * + * ⇒ 于是有了一条可靠的路: + * · 绑在**页面根容器**上(它不是 `Text`/`Image` 那种默认不可聚焦的节点, + * 而是布局容器 —— 见 `isConsumableKey` 的注释); + * · 子组件里真正的输入框(收件人输入、回复框)先拿到键, + * 它们自己消费掉(`ComposePage.onToKey` 就是这么做的); + * · 没被子组件消费的键**冒泡到页面根**,由这里处理。 + * + * 这样"详情页优先、主页兜底"是**框架保证**的,不是我自己排的优先级。 + * + * ## 与 WebUI 的关系(这条不是对齐项) + * + * WebUI **没有**这三条快捷键(`grep` 过全部 `onKeyDown`:只有 + * `AccountSwitcher.tsx:40` 的 Esc 关下拉、`AddressInput.tsx` 的候选导航)。 + * 所以它们是**鸿蒙侧新增的 2in1 能力**,不是"WebUI 有而鸿蒙没有"。 + * 这一点要写明,免得后面审计时被当成缺失项去"补"。 + */ + +import { KeyCode } from '@kit.InputKit'; + +/** + * 一个键事件该不该由本模块处理。 + * + * ★ 只认 `KeyType.Down`:`onKeyEvent` 对**按下与抬起**各触发一次, + * 两条都处理会让一次按键走两步(`ComposePage.onToKey` 头一行就是这道门, + * 本仓 2in1 判据也钉过)。 + */ +export function isKeyDown(type: KeyType): boolean { + return type === KeyType.Down; +} + +/** 回车:打开发信 / 打开回复 —— 两条路都要它 */ +export function isEnterKey(keyCode: number): boolean { + return keyCode === KeyCode.KEYCODE_ENTER + || keyCode === KeyCode.KEYCODE_NUMPAD_ENTER + || keyCode === KeyCode.KEYCODE_DPAD_CENTER; +} + +/** Esc:返回上一级 */ +export function isEscapeKey(keyCode: number): boolean { + return keyCode === KeyCode.KEYCODE_ESCAPE; +} + +/** + * 这次按键是不是"文本框该自己管的"。 + * + * ★ 为什么需要这道门(真实后果,不是理论): + * 回复框、收件人输入框里按回车是**换行 / 提交**,不该被页面级 + * "回车 = 打开回复"抢走。抢走的后果是:用户想换行,结果又开了一层回复框, + * 而且 Esc 想取消输入却返回到列表 —— 输入到一半的内容全没了。 + * + * 判定用键本身而不是"焦点在哪":焦点位置要读 `FocusController`, + * 而它与"这个键是否已被消费"是两件事 —— 官方给的冒泡机制已经表达了 + * "子组件消费掉了就别往上冒",这里只再兜一层**文本输入语义**的键。 + */ +export function isTextEditingKey(keyCode: number): boolean { + /* + * 只列真正属于"文字输入"的键。 + * ★ **不含 Enter** —— 故意的: + * 本页没有"回车换行"的多行输入(回复框是单行 `TextInput`, + * 见 `MailDetailPage` 的回复条),回车在这里的语义就是"提交/打开"。 + * 若哪天回复框改成多行 `TextArea`,**必须**把 Enter 加进来, + * 否则用户换行会被抢去开新回复框。 + * (这一条写在代码里而不是文档里 —— 改的人一定会看到这里。) + */ + return keyCode === KeyCode.KEYCODE_SPACE + || keyCode === KeyCode.KEYCODE_TAB; +} diff --git a/client/harmony/entry/src/main/ets/model/WindowInsets.ts b/client/harmony/entry/src/main/ets/model/WindowInsets.ts index a1fadef..4a1f52d 100644 --- a/client/harmony/entry/src/main/ets/model/WindowInsets.ts +++ b/client/harmony/entry/src/main/ets/model/WindowInsets.ts @@ -61,6 +61,27 @@ export class Insets { statusBar: number = 0; /** 底部手势条/导航条高度(vp)。全屏后这段要让出来,否则最后一行压在系统手势区 */ navIndicator: number = 0; + /** + * **窗口装饰(标题栏/右上三键)占的高度(vp)**。 + * + * ★★ 2026-09-24 新增(用户:「右侧三键应当有独立避让」)。 + * + * ── 为什么与 `statusBar` 是**两个量** ── + * `setWindowDecorVisible(false)` 隐掉了标题栏的**白条**(用户:「顶栏为什么不沉浸」), + * 但系统仍会在右上角**浮着**最小化/最大化/关闭三键(官方原文: + * 「hovering the cursor over the hot zone of the top window's title bar region + * will cause a floating title bar to appear, with a **fixed height of 37 vp**」)。 + * + * 而 2in1 形态下**没有状态栏** ⇒ `TYPE_SYSTEM` 的 `topRect` 是 **0** + * ⇒ 光看 `statusBar` 就以为顶部无需避让,于是内容(右上是「授权」页签) + * 被三键压住(截图硬证)。 + * + * ── 为何不用 `getWindowAvoidArea` ── + * `AvoidAreaType` 的 6 个类型(`@ohos.window.d.ts:150-207`)里**没有** + * "标题栏/三键区"这一项:`TYPE_SYSTEM` 只管状态栏+底部导航。 + * 所以这个量只能单独读:`window.Window.getWindowDecorHeight()`。 + */ + windowDecor: number = 0; } /** @@ -91,7 +112,15 @@ export const KEY_WINDOW_INSETS: string = 'agentmail.window.insets'; export function insetsFromAvoidArea( system: AvoidAreaLike | undefined, navIndicator: AvoidAreaLike | undefined, - toVp: (px: number) => number + toVp: (px: number) => number, + /** + * 窗口装饰(右上三键)高度,**已是 vp**(`getWindowDecorHeight()` 直接给 vp)。 + * + * ★ 它是第四个参数而不是塞进 `system`:它不是 `AvoidArea` 的一部分 + * (`AvoidAreaType` 里没有这一项),来源是另一个 API。 + * 把两个来源硬拼成一个对象会让判据没法单独验其中一个。 + */ + decorHeightVp?: number ): Insets { const out: Insets = new Insets(); /* @@ -102,6 +131,12 @@ export function insetsFromAvoidArea( const navBottom: number = navIndicator?.bottomRect?.height ?? 0; out.statusBar = sysTop > 0 ? toVp(sysTop) : 0; out.navIndicator = navBottom > 0 ? toVp(navBottom) : 0; + /* + * 装饰高度:官方保证在 [37,112] vp(全屏悬浮态固定 37), + * 但读到 0/负数时当"没有装饰"处理 —— 手机会是这个值,不该凭空让位。 + */ + const decor: number = decorHeightVp ?? 0; + out.windowDecor = decor > 0 ? decor : 0; return out; } @@ -142,5 +177,20 @@ export interface AvoidAreaLike { * 即"没用上就退回到旧行为",不会把一个不知情的页顶下去。 */ export function topInset(insets: Insets | undefined): number { - return insets !== undefined && insets.statusBar > 0 ? insets.statusBar : 0; + if (insets === undefined) { + return 0; + } + /* + * ★★ 2026-09-24:取**状态栏与窗口装饰(右上三键)中的较大者**。 + * + * 用户:「右侧三键应当有独立避让」。 + * 2in1 形态下 `statusBar` 是 **0**(没有状态栏),但右上角浮着三键 + * (隐掉标题栏白条后仍在,官方:全屏悬浮态固定 **37vp**)⇒ + * 只看 `statusBar` 就等于认定"2in1 顶部无需避让",内容被三键压住。 + * + * ★ 取大而不是相加:两者互斥(有状态栏的形态没装饰,反之亦然), + * 相加会多让一份;若哪天真同时非 0,取大也更安全(宁可多让,不可压住)。 + */ + const decor: number = insets.windowDecor > 0 ? insets.windowDecor : 0; + return insets.statusBar > decor ? insets.statusBar : decor; } diff --git a/client/harmony/entry/src/main/ets/pages/ContactsTab.ets b/client/harmony/entry/src/main/ets/pages/ContactsTab.ets index 4047d2b..125344f 100644 --- a/client/harmony/entry/src/main/ets/pages/ContactsTab.ets +++ b/client/harmony/entry/src/main/ets/pages/ContactsTab.ets @@ -227,10 +227,18 @@ export struct ContactsTab { this.sessionMails = []; } - /** 点一封邮件 → 推进本窗格的导航栈(与收件箱同一条详情路径) */ + /** + * 点一封邮件 → 推进本窗格的导航栈(与收件箱同一条详情路径)。 + * + * ★★ 2026-09-24:同样带 `MOVE_TO_TOP_SINGLETON`(完整因果写在 + * `MainPage.openMail` 里)—— 联系人页点开详情、反复点同一封会叠栈, + * 与通信页是**同一个 bug**。两处必须同形:只改一边的话, + * 换一栏点就又不正常了(本仓反复出现的形状)。 + */ openMail(mailId: string, accountId: string): void { const params: MailDetailParams = { mail_id: mailId, account_id: accountId }; - this.navPathStack.pushPath({ name: MAIL_DETAIL_ROUTE, param: params }); + this.navPathStack.pushPath({ name: MAIL_DETAIL_ROUTE, param: params }, + { launchMode: LaunchMode.MOVE_TO_TOP_SINGLETON }); } @Builder diff --git a/client/harmony/entry/src/main/ets/pages/LoginPage.ets b/client/harmony/entry/src/main/ets/pages/LoginPage.ets index 00ceaf7..5d2975a 100644 --- a/client/harmony/entry/src/main/ets/pages/LoginPage.ets +++ b/client/harmony/entry/src/main/ets/pages/LoginPage.ets @@ -15,6 +15,8 @@ import { PushService } from '../api/PushService'; import { common } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { AmIcon } from '../common/Icons'; +/* 回车提交登录(与主页/详情页同一套键判定,见 `model/KeyboardShortcuts.ts`) */ +import { isKeyDown, isEnterKey } from '../model/KeyboardShortcuts'; @Entry @Component @@ -455,5 +457,48 @@ struct LoginPage { .padding({ left: 16, right: 16 }) .backgroundColor(Theme.surfaceMuted) .align(Alignment.Center) + /* + * ★★ 2026-09-24 补:**登录页的回车提交**(用户:「你在登陆页是不是没有做 + * enter 等键的监听」)。 + * + * ── 确实漏了,而且这是与 WebUI 的**真实差异** ── + * WebUI 的登录页是一个 `` + * (`LoginPage.tsx:92`)—— 浏览器里在输入框里按回车会**提交表单**, + * 所以那边天然就是"输完密钥直接回车"。 + * 而鸿蒙这边登录页**一行键盘监听都没有**(grep `onKeyEvent`/`onKeyDown` + * 均为空)⇒ 输完只能去找那个按钮,桌面端用户会卡一下。 + * + * ── 为何不是 `keyboardShortcut` ── + * 它只收组合键(或无修饰键的 `FunctionKey`,而那个枚举里**没有 Enter**, + * 见 `enums.d.ts:3444`)⇒ 单按回车只能走 `onKeyEvent`。 + * + * ── 为何绑在根 `Scroll` 上 ── + * `onKeyEvent` 要求组件获焦,而键事件会**向上冒泡** + * (`common.d.ts:19560`:回调返回 `false` 才继续往父传)。 + * 两个输入框自己会在"按键不属于自己"时返回 false ⇒ 事件冒到这里。 + * 于是"在输入框里按回车就能登录"不靠"焦点恰好在哪",与 WebUI 的 + * 表单提交同一个效果。 + * + * ★ 与 `doLogin()` 自己那两个门保持一致(不要另写一套判断): + * · 正在提交时不重发(`!this.submitting`); + * · 不可提交时不发(用按钮同一个条件表达式)。 + * `doLogin()` 内部本来就有这些门,所以这里只需调用它 —— + * 两处各写一遍的话,哪天按钮的条件改了、回车这条就会分叉。 + */ + .onKeyEvent((e: KeyEvent): boolean => { + /* + * 判定与主页/详情页同一套(`model/KeyboardShortcuts.ts`)—— + * 本仓纪律:同一件事只许一处定义,否则三处会慢慢分叉。 + */ + if (!isKeyDown(e.type)) { + return false; + } + if (!isEnterKey(e.keyCode)) { + /* 其余键必须返回 false(返回 true = 吞掉,会让 Tab 切焦点失效) */ + return false; + } + this.doLogin(); + return true; + }) } } \ No newline at end of file diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index aeea636..f8fc330 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -21,6 +21,12 @@ import { splitEditing, normalizeCandidates, filterSets } from '../model/AddressSuggest'; import { KeyCode } from '@kit.InputKit'; +/* + * 2in1 键盘快捷键(用户:「邮件详情页回车打开回复。esc返回上一级」)。 + * 判定规则住在 `model/KeyboardShortcuts.ts`,这里只做接线 —— + * 主页那半边用同一套规则(本仓纪律:同一件事只许一处定义)。 + */ +import { isKeyDown, isEnterKey, isEscapeKey } from '../model/KeyboardShortcuts'; import { MailDetailParams } from '../model/RouteParams'; import { AmIcon } from '../common/Icons'; import { saveBinaryFile, IcsSaveResult } from '../common/IcsFile'; @@ -1853,6 +1859,68 @@ export struct MailDetailView { }) } .width('100%').height('100%') + /* + * ★★ 2026-09-24:**2in1 键盘快捷键**(用户:「邮件详情页回车打开回复。 + * esc返回上一级」)。 + * + * ── 为什么绑在这里(页面根 Stack)── + * 官方 `onKeyEvent`(`common.d.ts:19510`)要求组件**获得焦点**才触发, + * 但键事件有**冒泡**语义(`:19560`:回调返回 true 才算消费,否则 + * 向父组件冒泡)⇒ 绑在**页面最外层容器**上、子组件不消费时自然冒到这里, + * 就得到"输入框优先、页面兵底"的顺序 —— 而且是框架保证的。 + * + * ── 为什么不是 `keyboardShortcut` ── + * 它只收组合键;不带修饰键时只接受 `FunctionKey`, + * 而 `FunctionKey`(`enums.d.ts:3444`)**没有 Enter** + * (只有 ESC/F1–F12/TAB/方向键)⇒ 单按回车表达不出来。 + * + * ── 两条键的行为 ── + * · **Enter** → 打开回复条(与右下回复球同一个 `openReplyWithMorph`, + * 因而同样带 morph 动画 —— 不另开一条路); + * · **Esc** → 返回上一级(`goBack()`,内嵌时走 `onBack` pop、 + * 独立页时走 `router.back()`)。 + * + * ★ 开着的层要先关(与 WebUI `AccountSwitcher` 的 Esc 同一分寸): + * 回复条 / 转发条 / 对话树开着时,Esc 先关它们,**再按一次**才返回。 + * 否则用户想关掉回复框,结果直接被踢回列表,输入到一半的内容全没了。 + */ + .onKeyEvent((e: KeyEvent): boolean => { + if (!isKeyDown(e.type)) { + return false; + } + if (isEscapeKey(e.keyCode)) { + if (this.showThread) { + this.showThread = false; + return true; + } + if (this.showReplyBox) { + this.closeReplyWithMorph(); + return true; + } + if (this.showForwardBox) { + this.showForwardBox = false; + return true; + } + this.goBack(); + return true; + } + if (isEnterKey(e.keyCode)) { + /* 已经在回复了就不再开一层(幂等;与 WebUI `selectMail` 的赋值同一分寸) */ + if (this.showReplyBox || this.showForwardBox) { + return true; + } + /* + * 正在看权限决策面板时不抢回车 —— 那里回车的语义是"提交决策"。 + * 与 `MailDetailPage` 里 `PermissionPanel` 的分工一致。 + */ + if (this.mailType === 'permission' && this.permissionResult.length === 0) { + return false; + } + this.openReplyWithMorph(); + return true; + } + return false; + }) } /** diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 099a581..29559b0 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -44,6 +44,8 @@ import { MailStore, MailSnapshot, AccountError, INBOX_PAGE_SIZE } from '../commo import { AppearanceApi } from '../api/AppearanceApi'; import { performLogout } from '../api/Logout'; import { ComposeIntent } from '../common/ComposeIntent'; +/* 2in1 键盘快捷键的判定规则(与详情页同一套,见 `model/KeyboardShortcuts.ts`) */ +import { isKeyDown, isEnterKey } from '../model/KeyboardShortcuts'; import { Configuration, ConfigurationConstant, EnvironmentCallback, common } from '@kit.AbilityKit'; import { image } from '@kit.ImageKit'; import { AppearanceSnapshot, isDarkMode, scrimOpacity } from '../model/Appearance'; @@ -214,6 +216,24 @@ struct InboxTab { * 两个 tab 各存一份必然会分叉。单点写、多点读。 */ @Prop currentMailId: string = ''; + /** + * 鼠标悬停在哪一封(2in1/PC 有鼠标时才非空)。 + * + * ★★ 2026-09-24 新增(用户选「悬停反馈」)。 + * + * ── 为何不用 `.hoverEffect()` ── + * `.hoverEffect(HoverEffect.Highlight)` 只画系统自带的灰色叠层, + * 而 WebUI 的悬停是 `hover:bg-gray-50`(`MailList.tsx:181/280`)。 + * 更重要的是:本仓的选中态 / 未读态已占用底色(`accentSoft`), + * 系统叠层会与它们叠成**第三个颜色**。 + * 所以自己算三目,与选中/未读共用同一套令牌。 + * + * ── 为何存 `mail_id` 而不是布尔 ── + * 行本体(`MailRow` / `SentRow`)是 `@Builder`,**没有自己的状态** —— + * 布尔会变成"悬停一行、同栏所有行全亮"。状态只能在**栏** + * (`InboxTab` / `SentTab`)上,所以要存"是哪一封"。 + */ + @State hoveredMailId: string = ''; aboutToAppear(): void { const ctx = this.getUIContext().getHostContext(); @@ -746,6 +766,13 @@ struct InboxTab { color: this.currentMailId === mail.mail_id ? Theme.accentEdgeFor(this.isDarkNow) : Theme.border }) .clip(true) + .onHover((isHover: boolean) => { + this.hoveredMailId = isHover ? mail.mail_id : ''; + }) + .backgroundColor(this.hoveredMailId === mail.mail_id + && this.currentMailId !== mail.mail_id + && mail.status !== 'unread' + ? Theme.surfaceMuted : Color.Transparent) .onClick(() => { this.openMail(mail); }) @@ -998,6 +1025,24 @@ struct SentTab { * 发件箱的每一行也要高亮,否则"从发件箱点开一封"就断了同一条线索。 */ @Prop currentMailId: string = ''; + /** + * 鼠标悬停在哪一封(2in1/PC 有鼠标时才非空)。 + * + * ★★ 2026-09-24 新增(用户选「悬停反馈」)。 + * + * ── 为何不用 `.hoverEffect()` ── + * `.hoverEffect(HoverEffect.Highlight)` 只画系统自带的灰色叠层, + * 而 WebUI 的悬停是 `hover:bg-gray-50`(`MailList.tsx:181/280`)。 + * 更重要的是:本仓的选中态 / 未读态已占用底色(`accentSoft`), + * 系统叠层会与它们叠成**第三个颜色**。 + * 所以自己算三目,与选中/未读共用同一套令牌。 + * + * ── 为何存 `mail_id` 而不是布尔 ── + * 行本体(`MailRow` / `SentRow`)是 `@Builder`,**没有自己的状态** —— + * 布尔会变成"悬停一行、同栏所有行全亮"。状态只能在**栏** + * (`InboxTab` / `SentTab`)上,所以要存"是哪一封"。 + */ + @State hoveredMailId: string = ''; /** 深浅色(`InboxTab` 同名属性同一口径:`Theme` 是静态类,算不出主题,走 AppStorage) */ @StorageProp('agentmail.appearance.isDark') isDarkNow: boolean = false; /** 底部悬浮条高度(窄屏非 0)——理由见 `InboxTab` 的 `navReserve` */ @@ -1199,6 +1244,12 @@ struct SentTab { * * ⇒ 与 `MailRow` 取同形:卡在行自己身上,点击也挂在行自己身上。 */ + .onHover((isHover: boolean) => { + this.hoveredMailId = isHover ? mail.mail_id : ''; + }) + .backgroundColor(this.hoveredMailId === mail.mail_id + && this.currentMailId !== mail.mail_id + ? Theme.surfaceMuted : Color.Transparent) .onClick(() => { this.openMail(mail); }) @@ -1759,11 +1810,41 @@ struct CommPage { /* 记下"正在看哪一封" ⇒ 列表里那一行高亮(对齐 WebUI 的 currentMail)。 放在 push 之前:即使 push 失败,用户也确实点了这一封。 */ this.currentMailId = mailId; + /* + * ★★★ 2026-09-24 修(用户:「宽屏状态一个邮件被反复点击会被多次填充到右侧」)。 + * + * ── 根因:默认的 `LaunchMode.STANDARD` 会逐次入栈 ── + * 官方 `navigation.d.ts:498-537` 的三种模式: + * · `STANDARD`(默认)—— push 就是把这一页**加到栈上**; + * · `MOVE_TO_TOP_SINGLETON` —— *“searches from the bottom to the top of the + * routing stack. If a NavDestination page with the specified name exists, + * it moves that page to the top”* ⇒ 同名已在栈里就**移上去,不新建**; + * · `POP_TO_SINGLETON` —— 同理,并把它上面的都弹掉。 + * + * 我们一直用默认值 ⇒ 反复点同一封(或不同封)就叠出多层同名的 + * `MAIL_DETAIL_ROUTE`,每叠一层就:**重建详情组件 + 重拉一次数据 + + * 重放一次入场动画** —— 用户看到的"被多次填充到右侧"就是这个。 + * 而且返回要按多次才能回到列表。 + * + * ── WebUI 为何没这毛病 ── + * WebUI 是 `mailStore.selectMail(mail)` → `set({ currentMail: mail })` + * (`mailStore.ts:115`)—— **幂等赋值**,点同一封两次与一次完全等价。 + * 鸿蒙的"详情"是导航栈上的一层,不是一块状态 ⇒ 必须显式去重。 + * + * ── 为何选 MOVE_TO_TOP_SINGLETON 而不是自己判 `if (当前已是这封) return` ── + * 自己判只能挡"同一封反复点";而**点另一封再点回来**同样会叠三层 + * (A→B→A 三个实例)。`MOVE_TO_TOP_SINGLETON` 按**路由名**去重, + * 把这两种情况一并解决,且语义就是"详情栏只该有一份"。 + * + * ★ 它仍然是 push(带入场动画),与 WebUI 的"右栏就地换内容"观感一致; + * 只有"已经是栈顶那一层"时才会退化成"移上去"(即无变化)。 + */ const params: MailDetailParams = { mail_id: mailId, account_id: accountId }; - this.navPathStack.pushPath({ name: MAIL_DETAIL_ROUTE, param: params }); + this.navPathStack.pushPath({ name: MAIL_DETAIL_ROUTE, param: params }, + { launchMode: LaunchMode.MOVE_TO_TOP_SINGLETON }); } /** @@ -3345,8 +3426,21 @@ struct MainPage { /* * 避让区 + 面板留白。避让是**窗口级事实**(全屏后内容从 y=0 起画), * 与宽窄无关;留白则两端都有(WebUI 两条 shell 规则的 padding-top 都是 gap)。 + * + * ★★ 2026-09-24 加 `windowDecor`(用户:「右侧三键应当有独立避让」)。 + * + * 为何不能只靠 `statusBar`:2in1 形态**没有状态栏** ⇒ + * `TYPE_SYSTEM.topRect` 是 **0**,而右上角仍浮着最小化/最大化/关闭三键 + * (隐掉标题栏白条后它们仍在,官方:全屏悬浮态**固定 37vp**)。 + * 只看 `statusBar` 就等于认定"2in1 顶部无需避让" ⇒ 内容(右上是「授权」页签) + * 被三键压住 —— 截图硬证。 + * + * ★ 取**两者较大者**而不是相加:手机上有状态栏、没装饰(37 为 0); + * 2in1 上有装饰、没状态栏(statusBar 为 0)—— 两个量互斥,相加会多让一份。 + * 若哪天两者同时非 0,取大也是更安全的那个(宁可多让一点,不可压住)。 */ - top: this.windowInsets.statusBar + Theme.paneGap, + top: (this.windowInsets.statusBar > this.windowInsets.windowDecor + ? this.windowInsets.statusBar : this.windowInsets.windowDecor) + Theme.paneGap, /* * 底边 = 0:窄屏的底栏是**悬浮**的、自己带 `NAV_BAR_BOTTOM` 的 margin, * 内容要能从它底下穿过(玻璃才有东西可糊,见 `navReserve` 那段)。 @@ -3453,5 +3547,39 @@ struct MainPage { this.currentIndex = 0; ComposeIntent.request(); }) + /* + * ★★ 2026-09-24:**主页回车打开发信页**(用户:「主页回车打开写信」)。 + * + * ── 为何在此层(根 Stack)而不是 `CommPage` ── + * `CommPage` 是**条件挂载**的(`if (this.currentIndex === 0)`)—— + * 用户在日历页时它不存在,绑在上面就收不到键。 + * 而根 Stack 是「整个 window 的组件树」的根,一直在。 + * + * 同时它也是**兵底层**:详情页自己也挂了 `onKeyEvent`, + * 而键事件先给深层、未消费才向父冒泡(`common.d.ts:19560`)⇒ + * 在详情页里按回车是"打开回复"(详情页消费掉,到不了这里), + * 在列表页按回车才落到这条"打开发信页"。两不打架。 + * + * ── 与 Ctrl+N 的关系 ── + * 两者都是"写信入口",走**同一个** `ComposeIntent.request()` + * (不另写一条路)—— 区别只是键位: + * · `Ctrl+N` = 桌面端惯例(组合键走 `keyboardShortcut`,无焦点要求); + * · 单按 `Enter` = 用户这次点名要的(走 `onKeyEvent`)。 + */ + .onKeyEvent((e: KeyEvent): boolean => { + if (!isKeyDown(e.type)) { + return false; + } + /* + * 只处理回车。其余键**必须返回 false** —— 返回 true 就是"吞掉", + * 而那会让系统/子组件的正常按键(Tab 切焦点、方向键导航)失效。 + */ + if (!isEnterKey(e.keyCode)) { + return false; + } + this.currentIndex = 0; + ComposeIntent.request(); + return true; + }) } } \ No newline at end of file