跨端: 鸿蒙 2in1 键盘可达 + 悬停 + 沉浸顶栏 + 三键避让 + 修叠栈
用户四条:
①「2in1 手势」(选了 悬停/右键菜单/触控板 + 快捷键:主页回车写信、
详情页回车回复、Esc 返回)
②「宽屏状态一个邮件被反复点击会被多次填充到右侧」
③「你在登陆页是不是没有做 enter 等键的监听」——**确实漏了**
④「app 顶栏为什么不沉浸」+「右侧三键应当有独立避让」
② 叠栈(实测复现 → 修 → 实测通过)
根因是框架语义用错:pushPath 默认 LaunchMode.STANDARD 每次入栈 ⇒
重建详情组件 + 重拉数据 + 重放入场动画;返回还要按多次。
而 WebUI 是 `set({currentMail})` 幂等赋值(mailStore.ts:115)。
改用 LaunchMode.MOVE_TO_TOP_SINGLETON(官方:同名已在栈里就移上去、
不新建),MainPage + ContactsTab 两处 push 点都改(只改一边=换栏点
又不正常)。
实测:连点同一封 3 次 → **点一次返回就回占位**(修复前要按 3 次)。
③ 登录页回车(用户点出来的真实缺失)
WebUI 是 `<form onSubmit={submit}>`(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)。
- 登录页回车:同一限制。
沉浸顶栏与三键避让是**截图硬证过**的(不依赖键盘)。
This commit is contained in:
@ -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;
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user