跨端: 鸿蒙 2in1 键盘派发重构 + 右键菜单(补上一轮的实测修正)

上一轮提交(e54dc39)的快捷键实测后发现**详情页的回车开错了东西**,
这一轮是修正 + 补齐。

① 详情页回车开出的是"写信"而不是"回复"(实测截图硬证)
   根因:详情页自己的 `onKeyEvent` **从不触发** —— 官方要求组件**获得焦点**
   才响应(common.d.ts:19510),而页面根 Stack 默认不可聚焦,
   加 `.focusable(true)` 也没人 requestFocus。于是键直接冒到主页根,
   被那条"Enter=写信"抢先。

   ⇒ 改成**根上按状态派发**(与 WebUI 同构:它也是一处全局监听 + 按状态分派):
     · 发布 `KEY_OPEN_MAIL_ID`(CommPage.openMail 写、NavDestinations 返回时清)
       ⇒ 判断"此刻是不是在看某封邮件"
     · 发布 `KEY_COMM_STACK_DEPTH`(navPathStack.size())
       ⇒ 判断 Esc 还有没有层可退(写信也占一层)
     · 根上据此决定:Enter = 回复 or 写信;Esc = 弹一层 or 交还系统
   新增 `ReplyIntent` / `PopIntent`,与既有 `ComposeIntent` **同一"两半"形状**
   (有人听就当场给、没人听就存着)——根组件够不着那两处的实例。

② 右键菜单(用户选「右键菜单」)
   · 邮件行挂 `bindContextMenu(…, ResponseType.RightClick)`;官方枚举只有
     RightClick / LongPress 两项 —— 长按是触屏语义,且左键单击已被
     "打开邮件"占用,只剩右键可用。
   · 菜单项**只放列表层能当场完成**的:标记已读 / 归档会话 / 复制主题。
     ★ **不放**回复/转发:那两个要详情页的表单,在列表行上做只能"先跳详情",
       那不是菜单项该有的语义(点了当场就该有结果)。
   · 归档走系统确认框(破坏性操作,与联系人页同一分寸)。

实测(2in1 模拟器 3120×2080,xdotool 注入真实键鼠)
  · 列表 Enter → 写信页            ✅ 截图
  · 写信页 Esc → 回列表            ✅ 截图
  · 详情页 Enter → 回复框("回复给 pi@…")✅ 截图
  · 邮件行右键 → 菜单(归档会话/复制主题)✅ 截图
  · 复制主题 → 无报错、菜单关闭

★ 一个重要的自我更正
  我先前说"2in1 模拟器上键盘注入不生效、属环境限制"——**那是错的**。
  xdotool 的键事件一直都能到 App(探针日志明确显示
  `Node Stack/68 handle KeyEvent` + handler 被调用)。误判的原因是我当时
  在**登录页**测 Ctrl+N(那页本来就没实现它,当然没反应)。
  教训:探针打进去之前,不要把"没反应"归因于环境。

判据(harmony-2in1 新增 7 条 → 12→19)
  · 三页用同一套键判定(不许各写一遍 KeyCode 比较)
  · 登录页回车提交(WebUI 靠 <form> 天然有,鸿蒙原来一行监听都没有)
  · 详情页 Enter/Esc + 弹层开着时 Esc 先关弹层
  · 右手菜单:挂了 bindContextMenu、类型是 RightClick、
    菜单项只用当场能完成的动作(不放回复/转发)、归档要先确认
  ★ 两条判据第一版是**我自己判红了自己**,都是判据比事实严格:
    ① 详情页确实有 KEYCODE_ENTER —— 那是**输入框内的候选导航**(onFwdKey),
       与页面级快捷键是两件事 ⇒ 改成只查页面级 onKeyEvent 那一段;
    ② isEscapeKey 先在 import 行出现,从那里切片取到的是注释 ⇒
       改成从页面级 onKeyEvent 内部起切。
This commit is contained in:
2026-09-25 16:30:45 +08:00
parent 31939f2b10
commit 1436fd1fb1
6 changed files with 518 additions and 5 deletions

View File

@ -495,3 +495,72 @@ test('2in1|2in1/PC 上窗口装饰必须隐掉(否则标题栏下多一条"
assert.match(ABILITY, /win\.setWindowLayoutFullScreen\(true\)/,
'两条是**互补**的:这条管屏幕四边、装饰那条管窗口标题栏,删任一条都会露馅');
});
/* ────────────────────────────────────────────────────────────────
* ⑥ 右键菜单(2026-09-24 用户选「右键菜单」)
*
* ★ 这不是对齐项:WebUI **没有**右键菜单(`grep onContextMenu` 全仓为空)——
* 所以它是 2in1/PC 的新增桌面能力。写明,免得日后被当成缺失项去"补"。
* ──────────────────────────────────────────────────────────────── */
test('2in1|邮件行要有右键菜单,且用系统的 RightClick(不是自己接鼠标事件)', () => {
/*
* 钉两条:
* ① 菜单挂上了(`bindContextMenu`)—— 删掉它菜单就没了;
* ② 类型是 `ResponseType.RightClick` —— 官方枚举只有
* `RightClick` / `LongPress` 两项,用错就变成"长按出菜单",
* 而长按在触屏上是另一套语义(且左键单击已被"打开邮件"占用)。
*
* 改坏会红:删 bindContextMenu、或把 RightClick 换成 LongPress。
*/
assert.match(MAIN, /\.bindContextMenu\(/,
'邮件行要挂右键菜单(bindContextMenu)');
assert.match(MAIN, /ResponseType\.RightClick/,
'必须是鼠标右键(RightClick)—— LongPress 是触屏长按,语义不同');
});
test('2in1|右键菜单项只用**列表层能当场完成**的动作(不放回复/转发)', () => {
/*
* ★ 这条钉的是一条**判断**,不是实现细节:
* 菜单项应当"点了当场就有结果"。
*
* 回复/转发需要详情页的表单(收件人、正文、附件)——
* 在列表行上做只能"先跳详情再操作",那还不如直接单击那一行。
* 凑一个"点了等于跳转"的菜单项比没有更坏(用户以为在这里能完成)。
*
* 所以菜单里只有三个**当场能完成**的:
* · 标记已读(`MailApi.markRead`)
* · 归档会话(`MailApi.archiveContact`)
* · 复制主题(`pasteboard`,纯本地)
*
* 改坏会红:往菜单里加"回复"/"转发"项。
*/
const at = MAIN.indexOf('MailContextMenu(mail: MailLike)');
assert.ok(at > 0, '菜单 Builder 要存在');
const body = MAIN.slice(at, MAIN.indexOf('@Builder', at + 10));
assert.match(body, /标记已读/, '要有「标记已读」(markRead 在列表层可独立完成)');
assert.match(body, /归档会话/, '要有「归档会话」(archiveContact 同上)');
assert.match(body, /复制主题/, '要有「复制主题」(纯本地)');
assert.ok(!/回复|转发/.test(body),
'菜单里**不放**回复/转发 —— 那两个要详情页的表单,在这里做只能"先跳详情",' +
'那不是菜单项该有的语义(点了当场就该有结果)');
});
test('2in1|右键菜单的破坏性动作(归档)必须先确认', () => {
/*
* 归档是**破坏性**的(Agent 侧会话归档 + 邮箱界面同时移除),
* 而右键菜单是"点一下就发生了"—— 误触代价太大。
* 联系人页那边的归档一直有确认(`ArchiveConfirm` 弹层 / `confirmArchive`),
* 列表行这条也必须有,否则两处对同一件事的分寸不一致。
*
* 改坏会红:把 showAlertDialog 那层确认删掉、直接发请求。
*/
const at = MAIN.indexOf('private archiveRow(mail: MailLike)');
assert.ok(at > 0, 'archiveRow 要存在');
const body = MAIN.slice(at, at + 1200);
assert.match(body, /showAlertDialog|AlertDialog/,
'归档要先弹确认(与联系人页同一分寸)');
/* 真正的请求在确认回调之后(另一个方法里)——确保不是"点了就发" */
assert.match(body, /doArchiveRow/, '确认的回调里才发请求');
});