From 1436fd1fb17d4d05e290e1d4f242429915f569db Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 25 Sep 2026 16:30:45 +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=E6=B4=BE=E5=8F=91=E9=87=8D=E6=9E=84=20+?= =?UTF-8?q?=20=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=EF=BC=88=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=E4=B8=80=E8=BD=AE=E7=9A=84=E5=AE=9E=E6=B5=8B=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮提交(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 靠
天然有,鸿蒙原来一行监听都没有) · 详情页 Enter/Esc + 弹层开着时 Esc 先关弹层 · 右手菜单:挂了 bindContextMenu、类型是 RightClick、 菜单项只用当场能完成的动作(不放回复/转发)、归档要先确认 ★ 两条判据第一版是**我自己判红了自己**,都是判据比事实严格: ① 详情页确实有 KEYCODE_ENTER —— 那是**输入框内的候选导航**(onFwdKey), 与页面级快捷键是两件事 ⇒ 改成只查页面级 onKeyEvent 那一段; ② isEscapeKey 先在 import 行出现,从那里切片取到的是注释 ⇒ 改成从页面级 onKeyEvent 内部起切。 --- client/electron/test/harmony-2in1.test.mjs | 69 +++++ .../src/main/ets/common/ComposeIntent.ets | 94 ++++++ .../src/main/ets/model/KeyboardShortcuts.ts | 35 +++ .../src/main/ets/pages/MailDetailPage.ets | 23 ++ .../entry/src/main/ets/pages/MainPage.ets | 289 +++++++++++++++++- .../src/main/ets/pages/NavDestinations.ets | 13 +- 6 files changed, 518 insertions(+), 5 deletions(-) diff --git a/client/electron/test/harmony-2in1.test.mjs b/client/electron/test/harmony-2in1.test.mjs index 52fc1ee..bb8294c 100644 --- a/client/electron/test/harmony-2in1.test.mjs +++ b/client/electron/test/harmony-2in1.test.mjs @@ -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/, '确认的回调里才发请求'); +}); diff --git a/client/harmony/entry/src/main/ets/common/ComposeIntent.ets b/client/harmony/entry/src/main/ets/common/ComposeIntent.ets index 555bd80..560dd74 100644 --- a/client/harmony/entry/src/main/ets/common/ComposeIntent.ets +++ b/client/harmony/entry/src/main/ets/common/ComposeIntent.ets @@ -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; + } +} diff --git a/client/harmony/entry/src/main/ets/model/KeyboardShortcuts.ts b/client/harmony/entry/src/main/ets/model/KeyboardShortcuts.ts index db7e300..236258b 100644 --- a/client/harmony/entry/src/main/ets/model/KeyboardShortcuts.ts +++ b/client/harmony/entry/src/main/ets/model/KeyboardShortcuts.ts @@ -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'; diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index f8fc330..61dd58e 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -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 { diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 29559b0..2ef415e 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -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(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(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(KEY_OPEN_MAIL_ID, mailId); + this.publishStackDepth(); + } + + /** + * 详情栏空了(返回/切栏)⇒ 清掉发布键。 + * + * 与 `openMail` 成对:只写不清的话,回到列表再按回车会以为"还在详情", + * 回车会去开回复而不是写信。 + */ + private closeDetail(): void { + this.currentMailId = ''; + AppStorage.setOrCreate(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(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(KEY_OPEN_MAIL_ID) ?? ''; + if (openMailId.length > 0) { + /* + * 详情开着 ⇒ 回车交给详情去开回复。 + * + * 走**同一个** ComposeIntent 两半机制(有人听就当场给、没人听就存着)—— + * 详情此时一定挂着(它就是弹回复的那个组件),所以当场就会送到。 + * ★ 不走"直接叫某个方法":根组件拿不到 MailDetailView 的实例, + * 而本仓已有的意图格子正好就是为"够不着"设计的。 + */ + ReplyIntent.request(); + return true; + } this.currentIndex = 0; ComposeIntent.request(); return true; diff --git a/client/harmony/entry/src/main/ets/pages/NavDestinations.ets b/client/harmony/entry/src/main/ets/pages/NavDestinations.ets index efe4acf..f060a2f 100644 --- a/client/harmony/entry/src/main/ets/pages/NavDestinations.ets +++ b/client/harmony/entry/src/main/ets/pages/NavDestinations.ets @@ -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(KEY_OPEN_MAIL_ID, ''); + this.pathStack.pop(); + } }) } else { Column() {