跨端: 鸿蒙 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/, '确认的回调里才发请求');
});

View File

@ -80,3 +80,97 @@ export class ComposeIntent {
return true;
}
}
/**
* 「打开回复」的跨组件一次性意图 —— 与 `ComposeIntent` **完全同形**。
*
* ★★ 2026-09-24 新增。用户:「邮件详情页回车打开回复」。
*
* ── 为何需要它(同样是因为"够不着")──
* 回车是在**主页根**上收到的(详情页自己的 `onKeyEvent` 因组件不获焦而从不触发,
* 实测硬证 —— 详见 `MainPage` 根上那段注释),
* 而"开回复"那个动作(`openReplyWithMorph`)住在 `MailDetailView` 里。
* 根组件拿不到它的实例 ⇒ 与 `openCompose()` 当初撞到的是**同一个问题**,
* 所以用**同一个解法**:一个格子 + 一个回调,两半都留着。
*
* ── 为什么不直接在根上 setState 传下去 ──
* `MailDetailView` 是被 `NavDestination` 挂进去的、参数只传 `mail_id/account_id`
* 那几个 —— 为这个再加一个 `@Prop showReply` 要改三层(壳→页→回调),
* 而且"根改一个值"与"详情正在看哪一封"会分叉(换一封时该清的没清)。
* 意图格子天然是"一次性动作"语义,正好。
*
* ★ 与 `ComposeIntent` 分开两个类而不是加个 mode 参数:
* 两类意图的**接收方完全不同**(一个在 `CommPage`、一个在 `MailDetailView`),
* 合成一个会让"谁该消费它"变得含糊 —— 而含糊正是漏接收的来源。
*/
export class ReplyIntent {
static pending: boolean = false;
private static listener: (() => void) | undefined = undefined;
/** `MailDetailView` 挂载时登记 */
static setListener(fn: () => void): void {
ReplyIntent.listener = fn;
}
/** 卸载时摘掉(否则会唤醒一个已销毁的组件) */
static clearListener(): void {
ReplyIntent.listener = undefined;
}
/** 提"我要回复":有人听就当场给,没人听就存着等挂载后取走 */
static request(): void {
const fn: (() => void) | undefined = ReplyIntent.listener;
if (fn !== undefined) {
fn();
return;
}
ReplyIntent.pending = true;
}
/** 取走待处理的请求(取完即清,避免下次挂载时重放) */
static consume(): boolean {
if (!ReplyIntent.pending) {
return false;
}
ReplyIntent.pending = false;
return true;
}
}
/**
* 「弹掉通信页导航栈的最上一层」的意图(Esc 用)—— 与另两个同形。
*
* ★★ 2026-09-24 新增(用户:「esc返回上一级」)。
*
* Esc 在**主页根**上收到(详情/写信自己的 onKeyEvent 因组件不获焦而不触发),
* 而 `navPathStack` 只有 `CommPage` 持有 ⇒ 同样的"够不着"问题、同样的解法。
*/
export class PopIntent {
static pending: boolean = false;
private static listener: (() => void) | undefined = undefined;
static setListener(fn: () => void): void {
PopIntent.listener = fn;
}
static clearListener(): void {
PopIntent.listener = undefined;
}
static request(): void {
const fn: (() => void) | undefined = PopIntent.listener;
if (fn !== undefined) {
fn();
return;
}
PopIntent.pending = true;
}
static consume(): boolean {
if (!PopIntent.pending) {
return false;
}
PopIntent.pending = false;
return true;
}
}

View File

@ -99,3 +99,38 @@ export function isTextEditingKey(keyCode: number): boolean {
return keyCode === KeyCode.KEYCODE_SPACE
|| keyCode === KeyCode.KEYCODE_TAB;
}
/**
* 「此刻在详情页看哪一封」的发布键(`AppStorage`)。
*
* ★★ 2026-09-24 新增。主页根的键盘派发靠它判断回车该干什么:
* · 有值(在详情)→ 回车 = **回复**;
* · 空(在列表) → 回车 = **写信**。
*
* ── 为何必须走 AppStorage 而不是页面参数 ──
* 派发点在 `MainPage` 根(`@Entry`),而"在看哪一封"属于 `CommPage` 的状态 ——
* 两者相隔两层组件,且 `MailDetailView` 是 `NavDestination` 动态挂进去的
* (没有静态的 @Prop 链可传)。AppStorage 是本仓已有的跨层通道
* (`KEY_WINDOW_INSETS` / `KEY_IS_DARK` / 徽标计数都走它)。
*
* ★ 存 `mail_id` 而不是布尔:换一封时要能重新发布,而布尔
* true→true **不产生变化通知**(本仓 `bgContentRev` 注释里记过的坑)。
*/
export const KEY_OPEN_MAIL_ID: string = 'agentmail.comm.openMailId';
/**
* 「通信页导航栈里现在有几层」的发布键(`AppStorage`)。
*
* ★★ 2026-09-24 新增。根上的 **Esc = 返回上一级** 靠它判断"还有没有层可退":
* · `> 0`(详情/写信任一层在)→ 吃掉 Esc,弹一层;
* · `0`(就在列表) → 返回 false 交给系统(否则用户退不出 App)。
*
* ── 为何不用 `KEY_OPEN_MAIL_ID` ──
* 那个只表示**详情**,而写信也占栈上的一层 ——
* 只看它的话,在写信页按 Esc 会被当成"在列表"而不处理。
*
* ── 为何是数字而不是布尔 ──
* `NavPathStack.size()` 是真实层数;发布它比维护一个布尔可靠
* (布尔要在每处 push/pop 各改一次,漏一处就与事实不符)。
*/
export const KEY_COMM_STACK_DEPTH: string = 'agentmail.comm.stackDepth';

View File

@ -28,6 +28,7 @@ import { KeyCode } from '@kit.InputKit';
*/
import { isKeyDown, isEnterKey, isEscapeKey } from '../model/KeyboardShortcuts';
import { MailDetailParams } from '../model/RouteParams';
import { ReplyIntent } from '../common/ComposeIntent';
import { AmIcon } from '../common/Icons';
import { saveBinaryFile, IcsSaveResult } from '../common/IcsFile';
import { attachmentLabel } from '../model/Attachment';
@ -277,6 +278,28 @@ export struct MailDetailView {
}
this.loadMail(this.mailId);
});
/*
* ★★ 2026-09-24:接管"回车=回复"(用户:「邮件详情页回车打开回复」)。
*
* ── 为何是接收意图、而不是自己挂 onKeyEvent ──
* 本页自己也挂了 `onKeyEvent`,但**实测从不触发**:
* 官方要求组件**获得焦点**才响应(`common.d.ts:19510`),
* 而本页根 `Stack` 默认不可聚焦、`.focusable(true)` 也没人 requestFocus。
* ⇒ 键直接冒到 `MainPage` 根,那里按状态派发到 `ReplyIntent`(见那个类)。
*
* 两半都接(与 `ComposeIntent` 同一形状):
* · 登记回调 —— 本页已在时,回车当场送达;
* · `consume()` —— 本页刚挂载、请求先到了,这里取走。
*/
ReplyIntent.setListener(() => { this.openReplyWithMorph(); });
if (ReplyIntent.consume()) {
this.openReplyWithMorph();
}
}
aboutToDisappear(): void {
/* 摘掉意图监听 —— 否则会唤醒一个已销毁的组件(与 ComposeIntent 同一纪律) */
ReplyIntent.clearListener();
}
async loadMail(mailId: string): Promise<void> {

View File

@ -43,10 +43,11 @@ import { AppearanceStore } from '../common/AppearanceStore';
import { MailStore, MailSnapshot, AccountError, INBOX_PAGE_SIZE } from '../common/MailStore';
import { AppearanceApi } from '../api/AppearanceApi';
import { performLogout } from '../api/Logout';
import { ComposeIntent } from '../common/ComposeIntent';
import { ComposeIntent, ReplyIntent, PopIntent } from '../common/ComposeIntent';
/* 2in1 键盘快捷键的判定规则(与详情页同一套,见 `model/KeyboardShortcuts.ts`) */
import { isKeyDown, isEnterKey } from '../model/KeyboardShortcuts';
import { isKeyDown, isEnterKey, isEscapeKey, KEY_OPEN_MAIL_ID, KEY_COMM_STACK_DEPTH } from '../model/KeyboardShortcuts';
import { Configuration, ConfigurationConstant, EnvironmentCallback, common } from '@kit.AbilityKit';
import { pasteboard } from '@kit.BasicServicesKit';
import { image } from '@kit.ImageKit';
import { AppearanceSnapshot, isDarkMode, scrimOpacity } from '../model/Appearance';
import { BackgroundPlan, PresetLayer, TRANSPARENT, resolveBackground } from '../model/Wallpaper';
@ -705,12 +706,170 @@ struct InboxTab {
return false;
}
/**
* 邮件行的右键菜单内容(`MailRow` 的 `bindContextMenu` 挂它)。
*
* ★★ 2026-09-24 新增(用户选「右键菜单」)。完整理由见 `MailRow` 上那段。
*
* ★ 用 `Menu`/`MenuItem` 官方组件而不是自绘:
* 右键菜单的分寸(贴点击位置、自动避让屏幕边缘、Esc 关闭、
* 点外部关闭、键盘上下选)都是系统的活。自绘要一项项补,
* 而补漏的那一项就是"用起来怪"的来源(本仓 `bindSheet` 那次同一条理由)。
*/
@Builder
MailContextMenu(mail: MailLike) {
Menu() {
/*
* 标记已读 —— 只在未读时给。
* ★ 已读的再标一次是白跑一趟(服务端幂等,但会让接口日志噪声不断);
* 而"菜单里有项点了没反应"比"没这项"更让人困惑。
* 与详情页 `doMarkRead` 里那道 `status !== 'unread'` 同一个口径。
*/
if (mail.status === 'unread') {
MenuItem({ content: '标记已读', labelInfo: '' })
.onClick(() => { this.markRowRead(mail); })
}
MenuItem({ content: '归档会话', labelInfo: '' })
.onClick(() => { this.archiveRow(mail); })
MenuItem({ content: '复制主题', labelInfo: '' })
.onClick(() => { this.copySubject(mail); })
}
}
/**
* 从列表行标记已读(右键菜单项)。
*
* ★ 与详情页 `doMarkRead` **共用同一套两半**:发请求 → 就地改 store → 失败要说出来。
* 不共用的话,两处会慢慢分叉(本仓反复出现的形状)。
* 但这里拿不到详情页那个 `mailApi`(它按账号构造),
* 所以就地建一个同账号的客户端 —— 与 `loadInbox` 遍历账号时同一做法。
*/
private markRowRead(mail: MailLike): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
const acct: AccountInfo | null = acctMgr.getAccount(mail.source_account_id);
if (acct === null) {
/* 账号没了(退出登录中)⇒ 什么都不做,但也别装作成功 */
return;
}
const client: ApiClient = new ApiClient(ctx);
client.setBase(acct.server);
client.setToken(acct.token);
new MailApi(client).markRead(mail.mail_id).then(() => {
/*
* 成功:就地改 store(它内部会 `publishChange()` 广播修订号,
* 本栏与发件箱都会重拉 —— 与详情页标已读走的是同一条路)。
*/
MailStore.getInstance().markReadLocal(mail.mail_id);
}).catch((e: Error) => {
this.getUIContext().getPromptAction().showToast({ message: '标记已读失败: ' + e.message });
});
}
/**
* 归档会话(右键菜单项)。
*
* ★ 与联系人页 `confirmArchive` 同一个服务端入口(`MailApi.archiveContact`)。
* ★ **破坏性操作先确认** —— 与联系人页同一分寸(那里是 `ArchiveConfirm` 弹层)。
* 这里用系统确认框(`AlertDialog`):菜单点一下就直接归档掉一整条会话,
* 误触的代价太大。
*/
private archiveRow(mail: MailLike): void {
/* 会话标题:优先别名,退回主题 —— 与列表上显示的那行一致 */
const title: string = mail.session_alias.length > 0 ? mail.session_alias : mail.subject;
this.getUIContext().showAlertDialog({
title: '归档会话',
message: `确定归档「${title}」?\n归档后这条会话会从列表移除。`,
primaryButton: {
value: '取消',
action: () => {}
},
secondaryButton: {
value: '归档',
fontColor: Theme.danger,
action: () => { this.doArchiveRow(mail); }
}
});
}
/** 真正发归档请求(确认之后) */
private doArchiveRow(mail: MailLike): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
const acct: AccountInfo | null = acctMgr.getAccount(mail.source_account_id);
if (acct === null) {
return;
}
const client: ApiClient = new ApiClient(ctx);
client.setBase(acct.server);
client.setToken(acct.token);
new MailApi(client).archiveContact(mail.session_id).then(() => {
/*
* 成功:就地移除该会话(不等 SSE)—— 与联系人页 `confirmArchive` 同一做法。
* `dropSession` 是 store 上已有的入口,语义就是"这条会话没了"。
*/
MailStore.getInstance().dropSession(mail.session_id);
this.getUIContext().getPromptAction().showToast({ message: '已归档' });
}).catch((e: Error) => {
this.getUIContext().getPromptAction().showToast({ message: '归档失败: ' + e.message });
});
}
/**
* 复制主题到剪贴板(右键菜单项)。
*
* 桌面端高频动作:把邮件主题贴到别处(issue、聊天、搜索)。
* 用 `pasteboard`(系统剪贴板),不自己存变量 —— 那个只在 App 内有效。
*/
private copySubject(mail: MailLike): void {
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, mail.subject);
pasteboard.getSystemPasteboard().setData(data).then(() => {
this.getUIContext().getPromptAction().showToast({ message: '主题已复制' });
}).catch((e: Error) => {
this.getUIContext().getPromptAction().showToast({ message: '复制失败: ' + e.message });
});
}
@Builder
MailRow(mail: MailLike) {
Row() {
this.MailItem(mail)
}
.width('100%').height(64)
/*
* ★★ 2026-09-24 新增:**邮件行的右键菜单**(用户选「右键菜单」)。
*
* ── 这不是对齐项 ──
* WebUI **没有**右键菜单(`grep onContextMenu` 全仓为空)——
* 所以这是 2in1/PC 场景的**新增桌面能力**,不是"WebUI 有而鸿蒙没有"。
* 写明这一点,免得日后审计把它当成缺失项去"补"。
*
* ── 为什么是右键而不是长按 ──
* `ResponseType.RightClick`:鼠标右键 / 触控板双指点按。
* 长按(`LongPress`)留给触屏——两者同时绑会互相抢事件,
* 而列表行的左键单击已经占用了(打开邮件),只剩右键可用。
*
* ── 菜单里放什么(只放**列表层能独立完成**的)──
* · 标记已读 —— `MailApi.markRead`(不依赖详情页的表单上下文)
* · 归档会话 —— 与联系人页同一个 `MailApi.archiveContact` 入口
* · 复制主题 —— 纯本地
* ★ **不放**回复/转发:那两个要详情页的表单(收件人、正文、附件),
* 在列表行上做只能"先跳详情再操作"——那还不如单击。菜单项应当
* **当场就能完成**,凑不可用的入口比没有更坏。
*/
.bindContextMenu(this.MailContextMenu(mail), ResponseType.RightClick, {
/* 右键菜单贴着点击位置出现(官方:RightClick 时在点击位置显示) */
enableArrow: false,
preview: MenuPreviewMode.NONE,
backgroundColor: Theme.surface,
borderRadius: Theme.radiusCard
})
/*
* 底 + 边**成对**决定这张卡的身份,口径逐项对齐 WebUI(`MailList.tsx:280`):
*
@ -1482,6 +1641,19 @@ struct CommPage {
* 却因为"没人在听"而丢掉)。
*/
ComposeIntent.setListener(() => { this.openCompose(); });
/*
* Esc = 返回上一级(用户:「esc返回上一级」)。
*
* 在**根**上收到键、在这里执行 —— 因为 `navPathStack` 只有本组件持有。
* 弹完要重发层数,否则第二下 Esc 会以为还有层(或反之)。
*/
PopIntent.setListener(() => {
if (this.navPathStack.size() > 0) {
this.navPathStack.pop();
AppStorage.setOrCreate<string>(KEY_OPEN_MAIL_ID, '');
}
this.publishStackDepth();
});
if (ComposeIntent.consume()) {
this.openCompose();
}
@ -1504,6 +1676,7 @@ struct CommPage {
aboutToDisappear(): void {
/* 摘掉快捷键监听 —— 否则会唤醒一个已销毁的组件 */
ComposeIntent.clearListener();
PopIntent.clearListener();
// 必须摘掉:留着会叫醒一个已销毁的页面(跳转落空,还容易误判成"推送坏了")
PushService.clearRouteListener();
@ -1662,6 +1835,8 @@ struct CommPage {
openComposeWith(accountId: string): void {
const params: ComposeParams = { to: '', reply_to: '', session_alias: '', account_id: accountId };
this.navPathStack.pushPath({ name: COMPOSE_ROUTE, param: params });
/* 写信也占一层 ⇒ Esc 要能退它(见 publishStackDepth 的注释) */
this.publishStackDepth();
}
@Builder
@ -1697,6 +1872,7 @@ struct CommPage {
.onClick(() => {
this.commTab = normalizeCommTab(key);
this.navPathStack.clear();
this.publishStackDepth();
this.refreshCounts();
})
}, (key: string) => key)
@ -1806,6 +1982,26 @@ struct CommPage {
.border({ width: { bottom: 1 }, color: Theme.border })
}
/**
* 把通信页导航栈的**层数**发布到 `AppStorage`(根上的 Esc 派发要读它)。
*
* ★★ 2026-09-24 新增(用户:「esc返回上一级」)。
*
* ── 为何发布层数而不是自己维护一个布尔 ──
* `navPathStack.size()` 是**框架的真实状态**;布尔要在每处 push/pop 各改一次,
* 而栈的操作点有四个(详情 push、写信 push、两处 pop、一处 clear)——
* 漏一处的表现是"Esc 行为时对时不对",很难查。
* 发布真实层数则无从分叉。
*
* ── 调用点 ──
* 所有改动栈的地方后面都调一次(`openMail` / `openCompose` / `onBack` / 切栏 clear)。
* 本来更想用一个集中的钩子,但 `NavPathStack` 没有"变化即回调"的入口
* (`Navigation` 只给了 `onNavBarStateChange`/`onNavigationModeChange`,都与栈深无关)。
*/
private publishStackDepth(): void {
AppStorage.setOrCreate<number>(KEY_COMM_STACK_DEPTH, this.navPathStack.size());
}
openMail(mailId: string, accountId: string): void {
/* 记下"正在看哪一封" ⇒ 列表里那一行高亮(对齐 WebUI 的 currentMail)。
放在 push 之前:即使 push 失败,用户也确实点了这一封。 */
@ -1845,6 +2041,39 @@ struct CommPage {
};
this.navPathStack.pushPath({ name: MAIL_DETAIL_ROUTE, param: params },
{ launchMode: LaunchMode.MOVE_TO_TOP_SINGLETON });
/*
* ★★ 2026-09-24:**发布「详情已打开」**,供主页根的键盘派发用。
*
* ── 为何需要它(实测撞出来的)──
* 详情页自己也挂了 `onKeyEvent`(Enter=回复、Esc=返回),
* 但**实测没触发**:官方要求 `onKeyEvent` 在"组件**获得焦点**"后才响应
* (`common.d.ts:19510`),而页面根容器默认不可聚焦 ——
* 加 `.focusable(true)` 也不够(没人主动 requestFocus)。
* 于是键**直接冒到主页根**,被那里那条"Enter=写信"抢先处理:
* 在详情页按回车弹出了**写信页**而不是回复框(截图硬证)。
*
* ── 改成根上派发(与 WebUI 同构)──
* WebUI 也是**一处**全局监听(`App.tsx`)+ 按当前状态分派语义,
* 不是每个页面各挂一个。所以这里只发布状态,
* 由 `MainPage` 根上的 `onKeyEvent` 读它决定"回车该干什么"。
*
* ★ 发布的是 `mail_id`(不是布尔):详情换了一封也要重发,
* 而布尔从 true 再设 true **不产生变化通知** —— 那正是本仓
* `bgContentRev` 注释里记过的坑(“改了没反应”的经典形态)。
*/
AppStorage.setOrCreate<string>(KEY_OPEN_MAIL_ID, mailId);
this.publishStackDepth();
}
/**
* 详情栏空了(返回/切栏)⇒ 清掉发布键。
*
* 与 `openMail` 成对:只写不清的话,回到列表再按回车会以为"还在详情",
* 回车会去开回复而不是写信。
*/
private closeDetail(): void {
this.currentMailId = '';
AppStorage.setOrCreate<string>(KEY_OPEN_MAIL_ID, '');
}
/**
@ -3571,12 +3800,64 @@ struct MainPage {
return false;
}
/*
* 只处理回车。其余键**必须返回 false** —— 返回 true 就是"吞掉",
* 而那会让系统/子组件的正常按键(Tab 切焦点、方向键导航)失效。
* ★★ 2026-09-24:**Esc = 返回上一级**(用户:「esc返回上一级」)。
*
* ── 为何在根上做 ──
* 与回车同一个原因:详情/写信页自己的 `onKeyEvent` 因组件不获焦而**从不触发**
* (实测硬证),键全冒到这里 ⇒ 退层的语义只能在这里派发。
*
* ── 顺序:先关最上层容器,再退导航栈 ──
* 写信页是**导航栈上的一层**(`ComposeDestination`)。
* 实测:在写信页按 Esc 没反应 —— 因为 `ComposePage` 自己的 Esc 只在
* "地址候选列表开着"时生效(`onToKey` 里那道 `if (!this.suggestOpen) return`),
* 候选没开时那个键就冒上来了,而根上原来**没有 Esc 分支**。
*
* 这里不看"是哪一页",只看"栈里有没有东西" ——
* 有就 pop 一层(正是用户要的"返回上一级")。
* 用 AppStorage 上的 `KEY_OPEN_MAIL_ID` 只能表示**详情**,
* 而写信也占一层 ⇒ 改成看 `CommPage` 发布的**栈深度**。
*/
if (isEscapeKey(e.keyCode)) {
const depth: number = AppStorage.get<number>(KEY_COMM_STACK_DEPTH) ?? 0;
if (depth > 0) {
PopIntent.request();
return true;
}
/* 栈空(在列表)⇒ 返回键交给系统(别吞掉,那会让用户退不出 App) */
return false;
}
/*
* ★★ 2026-09-24 改成**按状态派发**(实测撞出来的真 bug)。
*
* 详情页自己也挂了 `onKeyEvent`,但官方要求组件**获得焦点**才响应
* (`common.d.ts:19510`),而它那个根 `Stack` 默认不可聚焦、
* 加 `.focusable(true)` 也没人 requestFocus ⇒ **详情页的处理器从不触发**,
* 键直接冒到这里。
* 实测后果:在详情页按回车弹出的是**写信页**(被下面这条分支抢先),
* 而不是用户要的"回复"(截图硬证)。
*
* ⇒ 这里读 `KEY_OPEN_MAIL_ID`(`CommPage.openMail` 发布)判断
* "此刻是不是在看某封邮件",据此派发:
* · 在看 → 回车=**回复**(与详情页右下那个回复球同一个入口);
* · 在列表 → 回车=**写信**。
* WebUI 也是**一处**全局监听 + 按状态分派(`App.tsx`),不是每页各挂一个。
*/
if (!isEnterKey(e.keyCode)) {
return false;
}
const openMailId: string = AppStorage.get<string>(KEY_OPEN_MAIL_ID) ?? '';
if (openMailId.length > 0) {
/*
* 详情开着 ⇒ 回车交给详情去开回复。
*
* 走**同一个** ComposeIntent 两半机制(有人听就当场给、没人听就存着)——
* 详情此时一定挂着(它就是弹回复的那个组件),所以当场就会送到。
* ★ 不走"直接叫某个方法":根组件拿不到 MailDetailView 的实例,
* 而本仓已有的意图格子正好就是为"够不着"设计的。
*/
ReplyIntent.request();
return true;
}
this.currentIndex = 0;
ComposeIntent.request();
return true;

View File

@ -17,6 +17,7 @@ import { PaneModifier } from '../common/Surface';
import { MailDetailView } from './MailDetailPage';
import { ComposeView } from './ComposePage';
import { MailDetailParams, ComposeParams } from '../model/RouteParams';
import { KEY_OPEN_MAIL_ID } from '../model/KeyboardShortcuts';
@Component
export struct MailDetailDestination {
@ -43,7 +44,17 @@ export struct MailDetailDestination {
initialAccountId: this.accountId,
embedded: true,
navReserve: this.navReserve,
onBack: (): void => { this.pathStack.pop(); }
/*
* 返回:先**清掉"详情开着"的发布键**,再 pop。
*
* ★★ 2026-09-24:不清的后果是回到列表后按回车**仍被当成"在详情"**
* ⇒ 回车会去开回复而不是写信(用户要的正是"主页回车打开写信")。
* 与 `CommPage.openMail` 的发布是**成对的**:一处写、一处清。
*/
onBack: (): void => {
AppStorage.setOrCreate<string>(KEY_OPEN_MAIL_ID, '');
this.pathStack.pop();
}
})
} else {
Column() {