跨端: 鸿蒙 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:
@ -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 靠 <form> 天然有,我们漏了)', () => {
|
||||
/*
|
||||
* ── 这条钉的是一个**真实漏掉的差异** ──
|
||||
* WebUI 登录页是 `<form onSubmit={submit}>`(`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\)/,
|
||||
'两条是**互补**的:这条管屏幕四边、装饰那条管窗口标题栏,删任一条都会露馅');
|
||||
});
|
||||
|
||||
@ -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 同值(一比一复刻的骨架)', () => {
|
||||
|
||||
@ -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)。
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user