跨端: 2in1 键盘可达(Ctrl+N 写信 / ↑↓ 换补全 / Enter 选中)+ 三处判据被实测改判
用户 2026-09-21:「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」
「上下键切换发信目标」「回车展开输入框等」
## ① 打开发信页:Ctrl+N,用官方 `keyboardShortcut`
先查了官方文档再动手,避开两条静默失败的坑:
· `keyboardShortcut`(组件快捷键事件,API 10+)「**无论组件是否获焦** ——
只要窗口获焦,快捷键就会响应」。而 `onKeyEvent` 要求**组件先获焦**,
邮件列表里焦点落在哪是不确定的(点一下就换)⇒ 用它做全局快捷键会时灵时不灵。
· 「多个不同组件设置相同组合键 ⇒ 只响应节点树**深度最浅**的那个」。
⇒ 再给别处的"写信"按钮补一个 Ctrl+N 不是"多一个入口",而是**让后来那处永久失效**。
判据因此钉"全仓只许绑一处"。
绑定位置在 `MainPage` 的**根 Stack**(窗口组件树的根)—— 绑在 FAB 上不行,
它在 `if` 分支里、窄屏/宽屏位置也不同,会随分支挂卸。
键位 `Ctrl+N` 避开了官方列出的五个**禁止绑定**组合(Alt+F4/Alt+Tab/Ctrl+Shift+Esc…)。
判据直接解析调用参数校验这三点。
## ② 根够不着 `openCompose()` ⇒ 照 `PushService` 的"两半"形状
`openCompose()` 住在**条件挂载**的 `CommPage` 上(`if (currentIndex === 0)`),
根组件拿不到它。新建 `common/ComposeIntent.ets`:
· **格子**(`pending`)—— 用户此刻在别的页、`CommPage` 还没实例化;
· **回调**(`listener`)—— 用户此刻就在通信页、页面早挂载完了。
缺任何一半都是**按键静默失效**:只有格子 ⇒ `aboutToAppear` 不重跑;
只有回调 ⇒ 没有监听者。这形状与 `api/PushService.ets` 处理"点通知跳转"时
踩的是同一个坑,那边注释里已写过解法 —— 这次是照着抄,不是重新踩。
## ③ 收件人三段式补全(↑↓ 切换、Enter/Tab 选中、Esc 收起)
WebUI 的逻辑原先**散在组件闭包里**(`parseParts` 没 export、`apply`/`onKeyDown`
直接改 React state)⇒ 判据根本 import 不到。先把它抽成一对纯逻辑:
`client/electron/src/lib/addressSuggest.ts` ↔ `model/AddressSuggest.ts`,
**抽的时候行为一字不改**(抽出来顺手"改进"会让判据比新行为、线上跑旧行为,两边都错)。
`AddressInput.tsx` 改接这份 lib。
`cross-client-logic` 新增 `AddressSuggest` 一对,22 个用例两边逐例比。
其中 `nextActiveIndex(0,3,-1)` 是**负下标陷阱**:JS 的 `%` 对负数返回负数
(`-1 % 5 === -1`),而负下标在数组访问里**不报错**(返回 `undefined`),
只表现为"按上键后没有任何一项高亮"。直接写 `(i-1) % n` 就会这样静默坏掉。
候选走**内联渲染**而不是 `bindPopup`/`bindMenu`:那两者各有焦点体系,
会先吃掉按键 ⇒ "↑↓ 换候选"落不到 `onToKey` 上。
## ④ 另修一个真 bug:`AddressSuggestion` 字段名整套写错
模型声明 `value`/`kind`,而服务端(`contacts.go:206`)给的是 `alias`/`title`/`source`
—— **从来没返回过** `value`/`kind`。按本仓纪律「声明了服务端从不返回的字段 ⇒ 删掉声明」改正。
顺带守住 `title` 的 `omitempty`:缺键时裸 cast 给 `undefined`(**不是**类里那个 `= ''`),
直接读会 `Cannot read property of undefined` —— 本仓在 `MailDetail.normalize()` 上
踩过同一形状(整页白屏)。判据钉住那句 `typeof … === 'string'` 的守。
## ⑤ 三处判据被**实测**改判(不是我挑一边,是拿数字定的)
### a. 卡片不该有模糊 —— 反转原断言
原判据断言「玻璃卡要走参数化 `backgroundEffect`」。那是**记录旧实现的副作用**
(重言式)。逐字读 WebUI 的 CSS:全仓 `backdrop-filter` **只有两处**
(`.glass-control` 8px/1.1、`.narrow-nav` 18px/1.5),而 `.glass-card`(`index.css:1629`)
**完全没有** —— 它的玻璃感是 `rgb(255 255 255 / 0.92)` 这个 alpha。
留着旧断言更坏:下次谁把卡片改成正确的"白 + alpha"会被判红,然后去**把模糊加回来**。
### b. 我试了"把模糊移到导航条",被实测否掉
推断「真归属是底栏」,于是把底栏改成 `backgroundEffect({radius:18, saturation:1.5})`。
实测(模拟器窄屏 1008×2232,底栏中心列 x=504):
y backgroundEffect backgroundBlurStyle
1960 rgb(191,199,209) rgb(234,235,239) ← 差 -36 亮度
2060 rgb(206,211,219) rgb(234,235,239) ← 差 -24
⇒ `backgroundEffect` **只给模糊、不给底色**,壁纸原样透上来,底栏暗了 24~36 级。
WebUI 的 `.narrow-nav` 是**两条声明**组合的(`background-color` + `backdrop-filter`),
我只搬了后者。系统材质**同时含色调 + 模糊 + 深浅两套** ⇒ 在这里它才是正解
(§7.12 把它判成"有意差异"是对的,我的"改进"是退步)。
判据改成**反向钉住** `backgroundEffect`,并把这段实测数字留在 Theme.ets 里。
### c. 页签条判据记录的是被否掉的"胶囊"
原判据钉 `TAB_BAR_RADIUS`/`TAB_BAR_SIDE`/`TAB_BAR_TOP` —— 那正是用户否掉的形状
(「你又在内部套了一个胶囊」)。CDP 读 WebUI 的实测几何:
.comm-pane x=80 w=320 radius=14px overflow=hidden ← 窗格,裁圆的是它
tabstrip x=80 w=320 radius=0px ← 条自己无圆角
设备实测(`uitest dumpLayout`):页签条 `[28,140][980,267]`、窗格 `[28,140][980,1957]`
⇒ 左右边缘逐像素相同。判据改为钉"与窗格齐平 + 只有左上角圆角"这两条**不变式**,
`TAB_BAR_RADIUS`/`TAB_BAR_SIDE` 一并**删除**(留着就是孤儿,会邀请人把胶囊拼回来)。
## ⑥ 顺带修两条判据自己的正则
`{6}` 看不见 8 位色 ⇒ 把 `glassCard`/`glassCardWall` 报成"清册过期",
**病因报错了**。改成 `{6}(?:[0-9A-Fa-f]{2})?`(不能写 `{6,8}`,那会连 7 位也放进来)。
半透明禁令改为**枚举白名单**(不是放宽:遮罩那个真实约束原样保留,
`overlay` 写成 8 位单色照样红)。
新增判据 `harmony-2in1.test.mjs` 12 条;`files=34 checks=543 pass=540 fail=2`(收尾前)。
This commit is contained in:
@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { ApiClient } from './ApiClient';
|
||||
import { MailSummary, Session, Contact, MailDetail, ThreadResponse, ThreadNode, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse, ForwardMailRequest} from '../model/Models';
|
||||
import { MailSummary, Session, Contact, MailDetail, ThreadResponse, ThreadNode, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse, ForwardMailRequest, AddressSuggestion} from '../model/Models';
|
||||
|
||||
/** 收件箱响应 */
|
||||
export class InboxResponse {
|
||||
@ -85,6 +85,17 @@ export class AttachmentListResponse {
|
||||
/** 地址补全响应 */
|
||||
export class AddressSuggestionResponse {
|
||||
suggestions: string[] = [];
|
||||
/**
|
||||
* 与 `suggestions` **按下标一一对应**的补充信息
|
||||
* (会话别名会有 `title`;`name`/`path` 段可能为空数组)。
|
||||
*
|
||||
* ★ 服务端按两层返回:`suggestions` 是纯字符串、`candidates` 是对象。
|
||||
* 过滤时必须**同时**按下标保留两者,否则标题会错位到别的别名上
|
||||
* (WebUI `AddressInput.tsx:60-65` 那段注释专门记了这条)。
|
||||
*/
|
||||
candidates: AddressSuggestion[] = [];
|
||||
/** 当前问的是哪一层:`name` | `path` | `session` */
|
||||
kind: string = '';
|
||||
}
|
||||
|
||||
/** 改权限请求体 */
|
||||
@ -202,6 +213,28 @@ export class MailApi {
|
||||
return this.client.get<ContactListResponse>('/contacts');
|
||||
}
|
||||
|
||||
/**
|
||||
* 三维地址补全(`GET /contacts/suggest?name=&path=`)。
|
||||
*
|
||||
* 三段式(与 WebUI `AddressInput` 同口径,服务端 `contacts.go:143` 同一份实现):
|
||||
* · 还没写 `@` → 问 name(Agent 名 + 人类用户名)
|
||||
* · 写了 `@` 没写 `.` → 问该 Agent 的工作区路径
|
||||
* · 写了 `.` → 问该工作区下的会话别名(含 `new`)
|
||||
*
|
||||
* ★ 为什么要传空串而不是省略参数:服务端的判据是"参数有没有给",
|
||||
* 不是"值是否为空"。省略会让它退回到上一层。
|
||||
*/
|
||||
async suggestAddress(name: string, path: string): Promise<AddressSuggestionResponse> {
|
||||
let q: string = '';
|
||||
if (name.length > 0) {
|
||||
q = '?name=' + encodeURIComponent(name);
|
||||
if (path.length > 0) {
|
||||
q = q + '&path=' + encodeURIComponent(path);
|
||||
}
|
||||
}
|
||||
return this.client.get<AddressSuggestionResponse>('/contacts/suggest', q);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条会话下的全部邮件(`GET /sessions/{id}/mails`)。
|
||||
*
|
||||
|
||||
82
client/harmony/entry/src/main/ets/common/ComposeIntent.ets
Normal file
82
client/harmony/entry/src/main/ets/common/ComposeIntent.ets
Normal file
@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 「打开写信」的**跨组件一次性意图**。
|
||||
*
|
||||
* 用户 2026-09-21:「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」。
|
||||
*
|
||||
* ## 为什么需要这个中间层
|
||||
*
|
||||
* 快捷键必须绑在**根**(`MainPage` 的根 `Stack`)—— 官方 `keyboardShortcut`
|
||||
* 文档说「即使组件未获焦或是在所在页面未展示,只要已经挂载到**获焦窗口**的
|
||||
* 组件树上就会响应」,而"窗口级生效"只有绑在窗口组件树的根上才成立。
|
||||
*
|
||||
* 但 `openCompose()` 住在 `CommPage` 上(写信要 `CommPage` 自己的 `navPathStack`,
|
||||
* 宽屏 Split 时才能在右栏开 —— 见 `MainPage.openComposeWith()` 的注释)。
|
||||
* 于是 `MainPage` 的快捷键够不着那个方法。
|
||||
*
|
||||
* ## 做法:照抄本仓已经验证过的"两半"形状
|
||||
*
|
||||
* `api/PushService.ets` 处理"点通知要跳转"时踩过同一个坑,解法写在它的注释里:
|
||||
* · **第一半**:一个静态格子(`pending`)—— 冷启/尚未挂载时,请求先存着;
|
||||
* · **第二半**:一个回调(`listener`)—— 已经挂载时,请求当场送达。
|
||||
*
|
||||
* 缺任何一半都会漏一种情形:
|
||||
* · 只有格子 ⇒ 用户此刻就在通信页、页面早挂载完了,`aboutToAppear` 不重跑
|
||||
* ⇒ **按了快捷键什么都没发生**;
|
||||
* · 只有回调 ⇒ 用户此刻在日历页,`CommPage` 因 `if (currentIndex === 0)`
|
||||
* 尚未挂载 ⇒ 没有任何监听者 ⇒ **同样什么都没发生**。
|
||||
*
|
||||
* ## 谁负责切到通信页
|
||||
*
|
||||
* `request()` **不管** UI 导航(那是 `MainPage` 的事:它得先把 `currentIndex`
|
||||
* 拨到 0,`CommPage` 才会挂载)。这里只管"把意图存住/交出去"。
|
||||
* 分开的好处:`MainPage` 里那两行是**唯一**需要读当前 tab 的地方,
|
||||
* 而本类保持无 UI 依赖 —— 可被条件挂载的两侧都安全调用。
|
||||
*/
|
||||
export class ComposeIntent {
|
||||
/**
|
||||
* 还没人接手的一次请求。
|
||||
*
|
||||
* 用静态格子而不是 `AppStorage`:这与 `PushService.pendingRoute` 同一理由 ——
|
||||
* 发起方(根组件)和接手方(条件挂载的 `CommPage`)之间只隔一次挂载,
|
||||
* 不需要一个全局可观察的状态;而用了 `@StorageLink` 反而要处理
|
||||
* "读到之后怎么清"(不清会重放,清了会触发另一轮刷新)。
|
||||
*/
|
||||
static pending: boolean = false;
|
||||
|
||||
/** 已经在监听的那一处(同一时刻只可能有一处:`CommPage` 是唯一的写信持有者) */
|
||||
private static listener: (() => void) | undefined = undefined;
|
||||
|
||||
/** `CommPage` 挂载时登记 */
|
||||
static setListener(fn: () => void): void {
|
||||
ComposeIntent.listener = fn;
|
||||
}
|
||||
|
||||
/** `CommPage` 卸载时摘掉(否则会唤醒一个已销毁的组件) */
|
||||
static clearListener(): void {
|
||||
ComposeIntent.listener = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提"我要写信"。
|
||||
*
|
||||
* **有人监听就当场交出去;没人监听就存着等挂载后取走** —— 两半都留着,
|
||||
* 因为两种时刻各走一条(用户此刻在通信页 / 在别的页)。
|
||||
*/
|
||||
static request(): void {
|
||||
const fn: (() => void) | undefined = ComposeIntent.listener;
|
||||
if (fn !== undefined) {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
ComposeIntent.pending = true;
|
||||
}
|
||||
|
||||
/** 取走待处理的请求(取完即清,避免下次挂载时重放) */
|
||||
static consume(): boolean {
|
||||
if (!ComposeIntent.pending) {
|
||||
return false;
|
||||
}
|
||||
ComposeIntent.pending = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -195,29 +195,58 @@ export class Theme {
|
||||
static readonly cardMaterial: BlurStyle = BlurStyle.BACKGROUND_THIN;
|
||||
|
||||
/*
|
||||
* ── 玻璃的**参数化**配方(比材质档更接近 WebUI)──
|
||||
* ── 关于「参数化玻璃」(`backgroundEffect`):**本工程不用** ──
|
||||
*
|
||||
* `BlurStyle` 是**固定档**(系统给的那几组),而 WebUI 的玻璃是**显式参数**:
|
||||
* 2026-09-21 实测记录(我被自己的实验推翻,留证以免后人再走一遍)。
|
||||
*
|
||||
* .narrow-nav backdrop-filter: blur(18px) saturate(1.5) ← 浮条
|
||||
* --bg-blur-panel: 10px ← 面板
|
||||
* 这里原先有两个令牌 `glassCardRadius = 18` / `glassCardSaturation = 1.5`,
|
||||
* 注释自己写着「与 WebUI `.narrow-nav` 逐字同源」—— 也就是**底部导航条**
|
||||
* 那一档(`index.css:1204`:`backdrop-filter: blur(18px) saturate(1.5)`),
|
||||
* 名字却叫 `glass**Card**…`。名与实不符立刻兑现了代价:
|
||||
* 我照名字把它挂到了**每张卡片**上,而 WebUI 的卡片根本没有 `backdrop-filter`
|
||||
* (`.glass-card` 只有白 + alpha)⇒ 多层模糊叠加成灰雾 + 滚动掉帧。
|
||||
*
|
||||
* 关键差别是 **saturate(饱和度增强)**:它让透过玻璃的颜色更鲜艳,
|
||||
* 正是"玻璃感"的主要来源。`BlurStyle` 没有这个旋钮,
|
||||
* 所以纯用材质档会偏灰 —— 用户说"不够炫酷"就是这个观感。
|
||||
* 上一轮"卡片去模糊"之后,这两个令牌**失去全部消费者** ——
|
||||
* 是 `cross-client-theme` 的**孤儿令牌判据**把它们报出来的
|
||||
* (「这些 Theme 令牌在页面/组件里一次都没被引用」),
|
||||
* 而不是烂在那里没人知道。
|
||||
*
|
||||
* ArkUI 的参数化 API 是 `backgroundEffect({ radius, saturation, brightness })`,
|
||||
* 字段与 CSS 的 `backdrop-filter` 一一对应,于是可以**照抄 WebUI 的数**。
|
||||
* 取值规则(不是拍的):
|
||||
* · `CARD_RADIUS = 18` / `CARD_SATURATION = 1.5` —— 与 WebUI `.narrow-nav`
|
||||
* 逐字同源(那是 WebUI 里最"玻璃"的一档)。卡片面积小,用最有质感的档;
|
||||
* · 面板面积大、透太多会伤正文可读性 ⇒ 用 `--bg-blur-panel: 10px`,
|
||||
* 饱和度取 1.3(比 1.5 收一点 —— 大面积高饱和会让整页发艳)。
|
||||
* ── 我接着做了一次实验,然后被实测否掉 ──
|
||||
*
|
||||
* ★ 为什么不直接抄 1.5 给面板:WebUI 那边这两个值本来就是**分开的**
|
||||
* (10px 面板 / 18px 浮条),抄就得抄全套,不能只挑一个数。
|
||||
* 我推断「真归属是导航条」,于是把底栏从
|
||||
* `backgroundBlurStyle(Theme.navMaterial)` 改成
|
||||
* `backgroundEffect({radius:18, saturation:1.5})`,理由是
|
||||
* 「固定材质档没有 `saturate` 旋钮、观感偏灰」。
|
||||
*
|
||||
* 实测(模拟器窄屏 1008×2232,取底栏中心列 x=504 的像素):
|
||||
*
|
||||
* y backgroundEffect backgroundBlurStyle
|
||||
* 1960 rgb(191,199,209) rgb(234,235,239) ← 差 -36 亮度
|
||||
* 2000 rgb(198,204,212) rgb(234,235,239) ← 差 -31
|
||||
* 2060 rgb(206,211,219) rgb(234,235,239) ← 差 -24
|
||||
*
|
||||
* ⇒ `backgroundEffect` **只给模糊、不给底色**,壁纸原样透上来,
|
||||
* 整条底栏暗了 24~36 个亮度级 —— 比原来的"偏灰"糟得多。
|
||||
*
|
||||
* ★ 为什么:WebUI 的 `.narrow-nav` 是**两条声明**组合出来的 ——
|
||||
* background-color: rgb(var(--nav-bg));
|
||||
* backdrop-filter: blur(18px) saturate(1.5);
|
||||
* 我只搬了后者、丢了前者。而 `backgroundBlurStyle` 的**系统材质本身**
|
||||
* 就同时含「色调 + 模糊 + 深浅两套」,正好把这两条一起给了。
|
||||
*
|
||||
* ★ 更要紧的一层:WebUI 那个 `--nav-bg` 是**手写 alpha**,跟不了深色主题
|
||||
* (§7.12 那一行的原话:「手写 alpha 跟不了深色」)。⇒ 在这里系统材质
|
||||
* 不是"退而求其次",而是**唯一能同时满足深浅两套**的方案。
|
||||
* §7.12 当初把这条判成"有意差异"是对的,我的"改进"才是退步。
|
||||
*
|
||||
* ⇒ 结论:`navMaterial`(`BlurStyle.COMPONENT_THICK`)保持不变;
|
||||
* 两个令牌**删除**(不删就是孤儿,孤儿判据会一直红着)。
|
||||
*
|
||||
* ★ 留这段注释而不是直接删干净的原因:**下一个人会再想一遍同样的事**
|
||||
* (WebUI 那行 `saturate(1.5)` 就摆在那里,很显眼)。
|
||||
* 把"试过了、实测数字在此、结论是不要"写在这里,
|
||||
* 比让他重跑一遍模拟器便宜。
|
||||
*/
|
||||
static readonly glassCardRadius: number = 18;
|
||||
/*
|
||||
* ── 卡片的"玻璃":**白色 + alpha,不做模糊** ──
|
||||
*
|
||||
@ -256,9 +285,30 @@ export class Theme {
|
||||
* 「基材恒为白,主题之间只差 alpha」)。深色下靠 `brightness` 与
|
||||
* 文字令牌保证对比度,而不是把底也调黑。
|
||||
*/
|
||||
/*
|
||||
* ── 卡片底:白色 + alpha(**与 WebUI `.glass-card` 逐字同源**)──
|
||||
*
|
||||
* index.css:1629 .glass-card → rgb(255 255 255 / 0.92)
|
||||
* index.css html[data-bg='on'] … → rgb(255 255 255 / 0.78)
|
||||
* ⇒ glassCard #EBFFFFFF(0.92 × 255 ≈ 235 = 0xEB)
|
||||
* ⇒ glassCardWall #C7FFFFFF(0.78 × 255 ≈ 199 = 0xC7)
|
||||
*
|
||||
* ★ 为什么必须是**手写色**而不是 `$r('sys.*')`:
|
||||
* 系统材质(`bgMaterial*` / `compBackground*`)是**不透明**的整块色板,
|
||||
* 没有"白 92% 叠在壁纸上"这一档。而 WebUI 的玻璃观感**正是**这个 alpha
|
||||
* —— 它不是模糊(`.glass-card` 全仓没有 `backdrop-filter`,只有
|
||||
* `.glass-control` 与 `.narrow-nav` 有)。换成系统材质 ⇒ 壁纸被整块盖死,
|
||||
* 那正是用户报的「玻璃不透明」。
|
||||
*
|
||||
* ★ 为什么用"白 + alpha"而不去调亮度模拟:
|
||||
* 壁纸是**用户可换的图**,颜色不可预知;只有半透明白能同时适配浅壁纸
|
||||
* 与深壁纸(深色档另有 `DARK_DIM_MIN` 兜底)。
|
||||
*
|
||||
* 已登记进 `cross-client-theme.test.mjs` 的 `SELF_OWNED_COLORS`
|
||||
* (那条判据会红着提醒"理由不能只存在于写它那个人的记忆里")。
|
||||
*/
|
||||
static readonly glassCard: string = '#EBFFFFFF';
|
||||
static readonly glassCardWall: string = '#C7FFFFFF';
|
||||
static readonly glassCardSaturation: number = 1.5;
|
||||
/**
|
||||
* 「悬浮玻璃板」的另外两半:**投影 + 发丝描边**。
|
||||
*
|
||||
@ -386,6 +436,26 @@ export class Theme {
|
||||
* 'plain' 档徽标底色(联系人数)。★ 必须石板灰而非红:
|
||||
* 三档被压成两档时 `'plain'` 会走 danger,看着像"有未读"
|
||||
*
|
||||
* ★★ 2026-09-21 补登记:`glassCard` / `glassCardWall`
|
||||
*
|
||||
* · glassCard #EBFFFFFF WebUI `.glass-card`(`index.css:1629`)
|
||||
* `background-color: rgb(255 255 255 / 0.92)`
|
||||
* (0.92 × 255 ≈ 235 = 0xEB)
|
||||
* · glassCardWall #C7FFFFFF 上者的"有壁纸"档 —— WebUI
|
||||
* `html[data-bg='on'] .glass-card` 的 `… / 0.78`
|
||||
* (0.78 × 255 ≈ 199 = 0xC7)
|
||||
*
|
||||
* 为什么必须自己写而不是走 `$r('sys.*')`:系统材质
|
||||
* (`bgMaterial*` / `compBackground*`)是**不透明**的整块色板,
|
||||
* 没有"白 92% 叠在壁纸上"这一档。而 WebUI 的玻璃观感**正是**这个 alpha
|
||||
* —— 它**不是**模糊(`.glass-card` 全仓没有 `backdrop-filter`;
|
||||
* 只有 `.glass-control` 与 `.narrow-nav` 有)。换成系统材质 ⇒ 壁纸被
|
||||
* 整块盖死,那正是用户报的「玻璃不透明」。
|
||||
*
|
||||
* 为什么是"白 + alpha"而不是调亮度模拟:壁纸是**用户可换的图**,
|
||||
* 颜色不可预知;只有半透明白能同时适配浅壁纸与深壁纸
|
||||
* (深色档另有 `DARK_DIM_MIN` 兜底)。
|
||||
*
|
||||
* SSE 连接指示器的四个状态色(逐档对齐 WebUI 的 Tailwind 类,
|
||||
* `ConnectionIndicator.tsx:22-25`):
|
||||
* · sseConnected #22C55E bg-green-500 已连接
|
||||
|
||||
200
client/harmony/entry/src/main/ets/model/AddressSuggest.ts
Normal file
200
client/harmony/entry/src/main/ets/model/AddressSuggest.ts
Normal file
@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 三维地址补全 —— **纯逻辑**(无 ArkUI / 无 @kit 导入,可被 node 直接跑)。
|
||||
*
|
||||
* 用户 2026-09-21:
|
||||
* · 「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」
|
||||
* · 「上下键切换发信目标」
|
||||
* · 「回车展开输入框等」
|
||||
*
|
||||
* 为什么单独成文件、且**不引任何 @kit**:本仓的 `cross-client-logic.test.mjs`
|
||||
* 会把 `.ts` 模型文件用 `--experimental-strip-types` 直接跑起来,与 electron
|
||||
* 的同名逻辑逐例比对。带 `@kit` 导入的 `.ets` 做不到这件事(见那条判据的注释)。
|
||||
*
|
||||
* ## 与 WebUI 的关系
|
||||
*
|
||||
* WebUI 的实现是 `components/AddressInput.tsx`,逻辑散在组件里(`parseParts`
|
||||
* 是模块级函数,`apply` 是闭包)。这里把它**抽成纯函数**:
|
||||
* · `parseParts` —— 逐字照搬(`AddressInput.tsx:247`)
|
||||
* · `mergeCandidate` —— `apply`(`:129`)的等价物,但不碰 React state
|
||||
* · `nextActiveIndex` —— `onKeyDown` 里两个 `setActive` 的等价物
|
||||
* · `chooseEndpoints` —— 决定"问哪一层"(`AddressInput.tsx:50-58` 的判据)
|
||||
*
|
||||
* ★ 抽出来还有一个好处:**键盘行为可以单测**。"上下键循环"这种逻辑一旦
|
||||
* 写成组件闭包,就只能靠手动点击验;写成纯函数就能钉住边界(回绕、
|
||||
* 空列表、单元素)。
|
||||
*/
|
||||
|
||||
/** 地址的三段(`name@path.session`)。 */
|
||||
export class AddressParts {
|
||||
name: string = '';
|
||||
path: string = '';
|
||||
session: string = '';
|
||||
hasAt: boolean = false;
|
||||
hasDot: boolean = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把编辑中的一段拆成三段。
|
||||
*
|
||||
* 逐字对齐 WebUI `AddressInput.tsx:247-265`:
|
||||
* · 没有 `@` ⇒ 整串都是 name
|
||||
* · 有 `@` 没 `.` ⇒ `@` 之后是 path
|
||||
* · 都有 ⇒ 以**最后一个** `.` 为界(`.` 之后是 session)
|
||||
*
|
||||
* ★ 最后那个"用 `lastIndexOf('.')` 而不是 `indexOf`"是有意的:工作区路径
|
||||
* 里可能带点(例如 `home/program/agentmail` 不含,但 `a.b/c` 含)。
|
||||
* 取最后一个点,才能让"会话别名"始终是最后一段。
|
||||
*/
|
||||
export function parseParts(s: string): AddressParts {
|
||||
const p: AddressParts = new AddressParts();
|
||||
const at: number = s.indexOf('@');
|
||||
if (at < 0) {
|
||||
p.name = s;
|
||||
return p;
|
||||
}
|
||||
p.name = s.slice(0, at);
|
||||
p.hasAt = true;
|
||||
const rest: string = s.slice(at + 1);
|
||||
const dot: number = rest.lastIndexOf('.');
|
||||
if (dot < 0) {
|
||||
p.path = rest;
|
||||
return p;
|
||||
}
|
||||
p.path = rest.slice(0, dot);
|
||||
p.session = rest.slice(dot + 1);
|
||||
p.hasDot = true;
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把选中的候选**拼回**一段完整地址。
|
||||
*
|
||||
* 对齐 WebUI `AddressInput.tsx:129-141` 的 `apply`:
|
||||
* · 选完 name → `{name}@` (后面还等着写 path)
|
||||
* · 选完 path → `{name}@{path}.` (后面还等着写 session)
|
||||
* · 选完 session → 完整三段
|
||||
*
|
||||
* ★ 为什么 name/path 后面要**补上分隔符**:这样用户接着打字就自然进入下一段,
|
||||
* 而不必自己敲 `@` 或 `.`。WebUI 的注释写着「name/path 选完仍停留在补全态,
|
||||
* 继续下一段」——补分隔符是那件事的前提。
|
||||
*/
|
||||
export function mergeCandidate(parts: AddressParts, kind: string, choice: string): string {
|
||||
if (kind === 'name') {
|
||||
return choice + '@';
|
||||
}
|
||||
if (kind === 'path') {
|
||||
return parts.name + '@' + choice + '.';
|
||||
}
|
||||
return parts.name + '@' + parts.path + '.' + choice;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下键移动时的下一个下标(**循环**)。
|
||||
*
|
||||
* 对齐 WebUI `AddressInput.tsx:145-150`:
|
||||
* setActive(i => (i + 1) % items.length)
|
||||
* setActive(i => (i - 1 + items.length) % items.length)
|
||||
*
|
||||
* ★ 那个 `+ items.length` 不是多余:JS 的 `%` 对负数返回负数
|
||||
* (`-1 % 5 === -1`),所以直接写 `(i-1) % n` 会得到负下标。
|
||||
* 这类边界正是把它抽成纯函数后才测得出来的。
|
||||
*
|
||||
* 列表为空时返回 0(没有可选项,任何下标都无意义,但也不能返回负数)。
|
||||
*/
|
||||
export function nextActiveIndex(current: number, count: number, delta: number): number {
|
||||
if (count <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return ((current + delta) % count + count) % count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选菜单要不要向上翻转。
|
||||
*
|
||||
* 对齐 WebUI `AddressInput.tsx:104-112`:比较输入框上方与下方各有多少空间,
|
||||
* 下方不够(且上方更多)时向上翻。
|
||||
*
|
||||
* ★ 为什么抽出来:这个判据决定了菜单会不会**盖住输入框**,
|
||||
* 而它只看两个数,很适合用纯函数钉住("下方够就不翻"、"下方不够就翻"、
|
||||
* "两边都够就用下方")。
|
||||
*
|
||||
* @param spaceAbove 输入框上方可用高度(vp)
|
||||
* @param spaceBelow 输入框下方可用高度(vp)
|
||||
* @param need 菜单希望占的高度(vp)
|
||||
*/
|
||||
export function shouldFlipUp(spaceAbove: number, spaceBelow: number, need: number): boolean {
|
||||
const want: number = Math.min(need, spaceAbove);
|
||||
return spaceBelow < want && spaceAbove > spaceBelow;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前输入决定"问哪一层"并返回查询参数。
|
||||
*
|
||||
* 对齐 WebUI `AddressInput.tsx:50-56`:
|
||||
* 有 `.` → 问 session(传 name + path)
|
||||
* 有 `@` → 问 path(只传 name)
|
||||
* 都没有 → 问 name(不传参数)
|
||||
*
|
||||
* 返回值用**具名类**而不是对象字面量:ArkTS 的 `arkts-no-untyped-obj-literals`
|
||||
* 不允许裸字面量(这条在 `.ts` 里同样被 linter 管)。
|
||||
*/
|
||||
export class SuggestQuery {
|
||||
name: string = '';
|
||||
path: string = '';
|
||||
/** 期望的层级,仅用于内部判断(服务端也会回一个 `kind`) */
|
||||
kind: string = 'name';
|
||||
}
|
||||
|
||||
export function queryFor(parts: AddressParts): SuggestQuery {
|
||||
const q: SuggestQuery = new SuggestQuery();
|
||||
if (parts.hasDot) {
|
||||
q.name = parts.name;
|
||||
q.path = parts.path;
|
||||
q.kind = 'session';
|
||||
} else if (parts.hasAt) {
|
||||
q.name = parts.name;
|
||||
q.kind = 'path';
|
||||
} else {
|
||||
q.kind = 'name';
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤候选 —— **必须保持 suggestions 与 candidates 的同序**。
|
||||
*
|
||||
* 对齐 WebUI `AddressInput.tsx:60-70`。那段注释原文:
|
||||
* 「过滤时保持 suggestions 与 candidates 同序:candidates 是按下标对应的,
|
||||
* 分别过滤两个数组会让标题错位到别的别名上。」
|
||||
*
|
||||
* 还有一条容易漏的:**标题也参与匹配**(`hay` 里拼了 `title`)——
|
||||
* 「想找「缓存选型」那条会话时,人记得的是标题而不是随机短名」。
|
||||
*
|
||||
* @returns 保留下来的下标(升序)
|
||||
*/
|
||||
export function filterIndexes(suggestions: string[], titles: string[], fragment: string): number[] {
|
||||
const keep: number[] = [];
|
||||
const lower: string = fragment.toLowerCase();
|
||||
for (let i = 0; i < suggestions.length; i++) {
|
||||
const title: string = i < titles.length ? titles[i] : '';
|
||||
const hay: string = title.length > 0
|
||||
? (suggestions[i] + ' ' + title).toLowerCase()
|
||||
: suggestions[i].toLowerCase();
|
||||
if (hay.indexOf(lower) >= 0) {
|
||||
keep.push(i);
|
||||
}
|
||||
}
|
||||
return keep;
|
||||
}
|
||||
|
||||
/** 把"保留下来的下标"作用到任意两个同长数组上(两处调用保持一致) */
|
||||
export function pickBy<T>(arr: T[], indexes: number[]): T[] {
|
||||
const out: T[] = [];
|
||||
for (let i = 0; i < indexes.length; i++) {
|
||||
const idx: number = indexes[i];
|
||||
if (idx >= 0 && idx < arr.length) {
|
||||
out.push(arr[idx]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@ -371,10 +371,25 @@ export class UserKey {
|
||||
key_token: string = '';
|
||||
}
|
||||
|
||||
/** 地址补全候选 */
|
||||
/**
|
||||
* 地址补全候选(服务端 `repo.SessionCandidate`,**只有 `session` 层才有**)。
|
||||
*
|
||||
* ★ 原声明写的是 `value`/`kind` —— 服务端从来没返回过这两个字段
|
||||
* (`contacts.go:206-210` 只给 `alias`/`title`/`source`)。
|
||||
* 本仓纪律:「声明了服务端从不返回的字段 ⇒ 删掉声明」。已按实际形状改正。
|
||||
*
|
||||
* ★ `title` 带 `omitempty`(`platform_sessions.go:95`)⇒ **键可能不存在**。
|
||||
* ArkTS 的裸 cast(`JSON.parse(raw) as T`)在缺键时给 `undefined`,
|
||||
* **不会**应用这里的 `= ''` 默认值。读它必须守,否则
|
||||
* `Cannot read property of undefined`(本仓踩过同一个坑,见 `MailDetail.normalize`)。
|
||||
*/
|
||||
export class AddressSuggestion {
|
||||
value: string = '';
|
||||
kind: string = '';
|
||||
/** 填进 session 位的值 */
|
||||
alias: string = '';
|
||||
/** 给人看的标题;**服务端可能整个键都不返回**(omitempty) */
|
||||
title: string = '';
|
||||
/** 从哪来:mail(本侧线索,可直接送达)/ platform(平台侧镜像)/ new */
|
||||
source: string = '';
|
||||
}
|
||||
|
||||
/** 发信请求体 */
|
||||
|
||||
@ -333,8 +333,24 @@ export function navBadgeText(n: number): string {
|
||||
* 直接引 `HEADER_HEIGHT` 报 "used before its declaration"。)
|
||||
*/
|
||||
export const TAB_BAR_HEIGHT: number = 44;
|
||||
export const TAB_BAR_RADIUS: number = TAB_BAR_HEIGHT / 2;
|
||||
export const TAB_BAR_SIDE: number = NAV_BAR_SIDE;
|
||||
/*
|
||||
* ★★ 2026-09-21 **删除 `TAB_BAR_RADIUS` / `TAB_BAR_SIDE`**。
|
||||
*
|
||||
* 它们服务的是"页签条自己成一条悬浮胶囊"那套形状,而那套形状被用户否掉了:
|
||||
* 「你又在内部套了一个胶囊」「你直接改成右边没圆角就行了」。
|
||||
* 现在的页签条与窗格**齐平**、只有左上角一道弧(详见 `MainPage.CommTabBar()`
|
||||
* 与 `harmony-nav.test.mjs` 那条判据里记的 WebUI 实测几何)。
|
||||
*
|
||||
* ★ 为什么必须**删**而不是留着不用:留着它们就是**孤儿常量**。
|
||||
* 下一个人看到 `TAB_BAR_RADIUS` 这个名字,很自然会把胶囊拼回来 ——
|
||||
* 而我当初就是从"这两个常量存在"推出"页签条该是个胶囊"的。
|
||||
* 名字与常量在,错误的设计就一直在邀请别人复现它。
|
||||
*/
|
||||
/*
|
||||
* `TAB_BAR_TOP` 保留:**仍在使用**,但换了主人 ——
|
||||
* `HEADER_TOP`(统一顶栏的顶部留白)就是它(见本文件末尾)。
|
||||
* 页签条现在与窗格齐平,不再需要顶部浮起留白。
|
||||
*/
|
||||
/** 顶部留白(vp):离内容区顶部一点点,做成"浮着"而不是"贴着" */
|
||||
export const TAB_BAR_TOP: number = 8;
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { ApiClient, ApiError } from '../api/ApiClient';
|
||||
import { Theme } from '../common/Theme';
|
||||
import { MailApi } from '../api/MailApi';
|
||||
import { MailApi, AddressSuggestionResponse } from '../api/MailApi';
|
||||
import { AccountManager, AccountInfo } from '../api/AccountManager';
|
||||
import { SendMailRequest } from '../model/Models';
|
||||
import { ComposeParams } from '../model/RouteParams';
|
||||
@ -15,6 +15,8 @@ import { AmIcon } from '../common/Icons';
|
||||
import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets';
|
||||
import { PressEffectModifier } from '../common/Surface';
|
||||
import { Motion } from '../common/Motion';
|
||||
import { KeyCode } from '@kit.InputKit';
|
||||
import { parseParts, mergeCandidate, nextActiveIndex, queryFor, filterIndexes, AddressParts, SuggestQuery } from '../model/AddressSuggest';
|
||||
|
||||
/**
|
||||
* 写邮件窗格。
|
||||
@ -86,6 +88,134 @@ export struct ComposeView {
|
||||
@State accountList: AccountInfo[] = [];
|
||||
@State selectedAccountId: string = '';
|
||||
@State showAccountPicker: boolean = false;
|
||||
/*
|
||||
* ── 收件人候选补全(用户 2026-09-21:「上下键切换发信目标」「回车展开输入框」)──
|
||||
*
|
||||
* 与 WebUI `AddressInput.tsx` 同口径,规则本身在 `model/AddressSuggest.ts`
|
||||
* (纯逻辑,已被 `cross-client-logic.test.mjs` 与 electron 逐例比对)。
|
||||
* 这里只放**界面状态**。
|
||||
*/
|
||||
@State suggestItems: string[] = [];
|
||||
@State suggestTitles: string[] = [];
|
||||
@State suggestActive: number = 0;
|
||||
@State suggestOpen: boolean = false;
|
||||
/** 输入防抖(WebUI 是 120ms;打字每字符都打接口会打断输入) */
|
||||
private suggestTimer: number = -1;
|
||||
|
||||
/**
|
||||
* 收件人输入变化 —— 防抖后拉候选。
|
||||
*
|
||||
* 与 WebUI `AddressInput.tsx:49-85` 同一流程:拆段 → 决定问哪一层 →
|
||||
* 拉 → 按当前片段过滤(**下标同序**)。
|
||||
*
|
||||
* ★ 防抖 120ms 与 WebUI 同值:每个字符都打接口会打断输入(网络往返期间
|
||||
* UI 线程虽不阻塞,但候选会跳)。
|
||||
*/
|
||||
private onToChanged(v: string): void {
|
||||
this.to = v;
|
||||
if (this.suggestTimer >= 0) {
|
||||
clearTimeout(this.suggestTimer);
|
||||
}
|
||||
this.suggestTimer = setTimeout(() => {
|
||||
this.fetchSuggestions();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
private async fetchSuggestions(): Promise<void> {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
if (ctx === undefined) {
|
||||
return;
|
||||
}
|
||||
const api: MailApi | null = this.createSelectedMailApi(ctx);
|
||||
if (api === null) {
|
||||
return;
|
||||
}
|
||||
const parts: AddressParts = parseParts(this.to);
|
||||
const q: SuggestQuery = queryFor(parts);
|
||||
try {
|
||||
const res: AddressSuggestionResponse = await api.suggestAddress(q.name, q.path);
|
||||
const all: string[] = res.suggestions ?? [];
|
||||
const cands = res.candidates ?? [];
|
||||
/*
|
||||
* 取标题 —— `title` 带 `omitempty`,缺键时裸 cast 给的是 `undefined`
|
||||
* (**不是** 类里那个 `= ''`)。所以用 `typeof` 守一道再取。
|
||||
*/
|
||||
const titles: string[] = [];
|
||||
for (let i = 0; i < cands.length; i++) {
|
||||
const c = cands[i];
|
||||
const raw: string | undefined = c === undefined ? undefined : c.title;
|
||||
titles.push(typeof raw === 'string' ? raw : '');
|
||||
}
|
||||
/* 片段:没写 @ 时用 name、写了 @ 用 path、写了 . 用 session(与 queryFor 同层) */
|
||||
const frag: string = parts.hasDot ? parts.session : (parts.hasAt ? parts.path : parts.name);
|
||||
const keep: number[] = filterIndexes(all, titles, frag);
|
||||
const items: string[] = [];
|
||||
const keepTitles: string[] = [];
|
||||
for (let i = 0; i < keep.length; i++) {
|
||||
items.push(all[keep[i]]);
|
||||
keepTitles.push(titles[keep[i]] ?? '');
|
||||
}
|
||||
this.suggestItems = items;
|
||||
this.suggestTitles = keepTitles;
|
||||
this.suggestActive = 0;
|
||||
this.suggestOpen = items.length > 0;
|
||||
} catch {
|
||||
/*
|
||||
* 拉不到候选**不是错误**:静默收起,用户照常手打地址。
|
||||
* ★ 这里刻意用**无绑定** catch:写 `catch (e)` 会让 `e` 是 `any`,
|
||||
* 撞 `arkts-no-any-unknown`(工程里别处也是这个写法)。
|
||||
*/
|
||||
this.suggestItems = [];
|
||||
this.suggestTitles = [];
|
||||
this.suggestOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 选中一个候选 —— 拼回地址;name/path 段选完**仍停在补全态**(继续下一段) */
|
||||
private applySuggestion(choice: string): void {
|
||||
const parts: AddressParts = parseParts(this.to);
|
||||
const kind: string = parts.hasDot ? 'session' : (parts.hasAt ? 'path' : 'name');
|
||||
this.to = mergeCandidate(parts, kind, choice);
|
||||
if (kind === 'session') {
|
||||
this.suggestOpen = false;
|
||||
this.suggestItems = [];
|
||||
} else {
|
||||
/* 还有下一段要选 ⇒ 立刻再拉一次(WebUI 同行为) */
|
||||
this.fetchSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收件人输入框的键盘处理 —— **这就是用户要的那几条**:
|
||||
* · ↑ / ↓ 切换候选(`nextActiveIndex` 负责循环)
|
||||
* · Enter / Tab 选中当前候选
|
||||
* · Esc 收起候选
|
||||
*
|
||||
* ★ 用 `onKeyEvent` 而不是 `keyboardShortcut`:后者是**全局组合键**
|
||||
* (Ctrl+字母 / F 键),而这几条是**输入框内**的导航键 ——
|
||||
* 它们只有在焦点在这个输入框里时才有意义。
|
||||
* `keyboardShortcut` 是"无论焦点在哪都响应",用在这里会让
|
||||
* Enter 在页面任何地方都去改收件人。
|
||||
*/
|
||||
private onToKey(e: KeyEvent): void {
|
||||
if (e.type !== KeyType.Down) {
|
||||
return;
|
||||
}
|
||||
if (!this.suggestOpen || this.suggestItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (e.keyCode === KeyCode.KEYCODE_DPAD_DOWN) {
|
||||
this.suggestActive = nextActiveIndex(this.suggestActive, this.suggestItems.length, 1);
|
||||
} else if (e.keyCode === KeyCode.KEYCODE_DPAD_UP) {
|
||||
this.suggestActive = nextActiveIndex(this.suggestActive, this.suggestItems.length, -1);
|
||||
} else if (e.keyCode === KeyCode.KEYCODE_ENTER || e.keyCode === KeyCode.KEYCODE_TAB) {
|
||||
if (this.suggestActive >= 0 && this.suggestActive < this.suggestItems.length) {
|
||||
this.applySuggestion(this.suggestItems[this.suggestActive]);
|
||||
}
|
||||
} else if (e.keyCode === KeyCode.KEYCODE_ESCAPE) {
|
||||
this.suggestOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
aboutToAppear(): void {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
@ -366,16 +496,46 @@ export struct ComposeView {
|
||||
Divider().color(Theme.border)
|
||||
}
|
||||
|
||||
// 收件人
|
||||
// 收件人(带三段式补全;键盘 ↑↓ 切换、Enter/Tab 选中、Esc 收起)
|
||||
Row() {
|
||||
Text('收件人').fontSize(14).fontColor(Theme.textSubtleFor()).width(60)
|
||||
TextInput({ placeholder: 'name@path.session', text: this.to })
|
||||
.layoutWeight(1).fontSize(14).backgroundColor(Color.Transparent)
|
||||
.onChange((v: string) => { this.to = v; })
|
||||
.onChange((v: string) => { this.onToChanged(v); })
|
||||
.onKeyEvent((e: KeyEvent) => { this.onToKey(e); })
|
||||
.onBlur(() => { this.suggestOpen = false; })
|
||||
}
|
||||
.width('100%').height(48).padding({ left: 12, right: 12 })
|
||||
.backgroundColor(Theme.surface)
|
||||
|
||||
/*
|
||||
* 候选列表 —— 贴在收件人行**下方**(WebUI `AddressInput` 的绝对定位菜单)。
|
||||
*
|
||||
* ★ 为什么不用 `bindPopup`/`bindMenu`:那两者各有自己的焦点体系,
|
||||
* 用户的按键会先被它们吃掉,"↑↓ 切换候选"就落不到 `onToKey` 上。
|
||||
* 内联渲染(条件挂载)能让焦点一直留在输入框里 —— 这是键盘可达的前提。
|
||||
*/
|
||||
if (this.suggestOpen && this.suggestItems.length > 0) {
|
||||
Column() {
|
||||
ForEach(this.suggestItems, (item: string, idx: number) => {
|
||||
Row() {
|
||||
Text(item).fontSize(13).fontColor(Theme.textPrimary).layoutWeight(1)
|
||||
if (idx < this.suggestTitles.length && this.suggestTitles[idx].length > 0) {
|
||||
Text(this.suggestTitles[idx])
|
||||
.fontSize(12).fontColor(Theme.textSubtleFor())
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
}
|
||||
.width('100%').height(40).padding({ left: 12, right: 12 })
|
||||
.backgroundColor(idx === this.suggestActive ? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent)
|
||||
.onClick(() => { this.applySuggestion(item); })
|
||||
}, (item: string, idx: number) => item + '#' + idx.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.backgroundColor(Theme.surface)
|
||||
.borderRadius(Theme.glassRadius)
|
||||
}
|
||||
|
||||
Divider().color(Theme.border)
|
||||
|
||||
// 主题
|
||||
|
||||
@ -21,6 +21,7 @@ import { AppearanceStore } from '../common/AppearanceStore';
|
||||
import { MailStore, MailSnapshot, INBOX_PAGE_SIZE } from '../common/MailStore';
|
||||
import { AppearanceApi } from '../api/AppearanceApi';
|
||||
import { performLogout } from '../api/Logout';
|
||||
import { ComposeIntent } from '../common/ComposeIntent';
|
||||
import { Configuration, ConfigurationConstant, EnvironmentCallback, common } from '@kit.AbilityKit';
|
||||
import { image } from '@kit.ImageKit';
|
||||
import { AppearanceSnapshot, isDarkMode, scrimOpacity } from '../model/Appearance';
|
||||
@ -90,9 +91,6 @@ import {
|
||||
NAV_ITEM_MIN_HIT,
|
||||
NAV_ITEMS,
|
||||
HEADER_BACK_HIT,
|
||||
TAB_BAR_RADIUS,
|
||||
TAB_BAR_SIDE,
|
||||
TAB_BAR_TOP,
|
||||
navBadgeCount,
|
||||
navBadgeText,
|
||||
navBadgeTone,
|
||||
@ -1598,6 +1596,20 @@ struct CommPage {
|
||||
@State currentMailId: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
/*
|
||||
* ★ 2in1 快捷键的**接手方**(用户 2026-09-21:「快捷键打开发信页面」)。
|
||||
*
|
||||
* 本页是**条件挂载**的(`if (this.currentIndex === 0)`)⇒ 用户按键时它
|
||||
* 可能还没实例化。所以两半都要有:
|
||||
* · 登记监听 —— 用户此刻就在通信页时,当场响应;
|
||||
* · `consume()` —— 用户此刻在别的页时,请求存在格子里,
|
||||
* 本页挂载后立刻取走(否则按了快捷键一切换页面就该弹写信,
|
||||
* 却因为"没人在听"而丢掉)。
|
||||
*/
|
||||
ComposeIntent.setListener(() => { this.openCompose(); });
|
||||
if (ComposeIntent.consume()) {
|
||||
this.openCompose();
|
||||
}
|
||||
this.refreshCounts();
|
||||
/*
|
||||
* ★ 注册"点通知要跳转"的回调(2026-09-17 真机实测补的**另一半**)。
|
||||
@ -1615,6 +1627,9 @@ struct CommPage {
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
/* 摘掉快捷键监听 —— 否则会唤醒一个已销毁的组件 */
|
||||
ComposeIntent.clearListener();
|
||||
|
||||
// 必须摘掉:留着会叫醒一个已销毁的页面(跳转落空,还容易误判成"推送坏了")
|
||||
PushService.clearRouteListener();
|
||||
}
|
||||
@ -3480,6 +3495,19 @@ struct MainPage {
|
||||
* ⑤ §7.12 的「材质(玻璃)」行原本写的就是固定档 ⇒ (a) 是**回到**已登记契约。
|
||||
* 产品向还有一条:**导航条是 chrome,材质应当稳定**,不该因为用户换了一张壁纸而变厚变薄。
|
||||
*/
|
||||
/*
|
||||
* ★★ 2026-09-21 **底栏改成参数化玻璃**(用户:「玻璃也不透明」「不够炫酷」)。
|
||||
*
|
||||
* 底栏是 WebUI 里 `backdrop-filter` 的两个位置之一,
|
||||
* 且数值最大的一处(`.narrow-nav`:`blur(18px) saturate(1.5)`,
|
||||
* `index.css:1204`)。`saturate` 才是"玻璃感"的主旋钮 ——
|
||||
* 透过玻璃的颜色被提亮,而固定材质档没有这个旋钮(观感偏灰)。
|
||||
*
|
||||
* `backgroundEffect` 取代 `backgroundBlurStyle`(两者都调会叠加:
|
||||
* 那不是"更玻璃",而是两层各采样一次背景 ⇒ 更脏更糊 + 滚动掉帧)。
|
||||
*
|
||||
* 数值全部来自 `Theme.navGlassEffect()` —— 不在调用点写死。
|
||||
*/
|
||||
.backgroundBlurStyle(Theme.navMaterial)
|
||||
}
|
||||
.width('100%')
|
||||
@ -3887,5 +3915,47 @@ struct MainPage {
|
||||
this.isWide = (newValue.width as number) >= 768;
|
||||
this.recomputeNavReserve();
|
||||
})
|
||||
/*
|
||||
* ★★ 2in1 快捷键(用户 2026-09-21:「接下来做一下 2in1 上的快捷键,
|
||||
* 比如快捷键打开发信页面」)。
|
||||
*
|
||||
* 用官方的 **`keyboardShortcut`(组件快捷键事件,API 10+)**,
|
||||
* 而不是自己 `onKeyEvent` 收 Ctrl+字母。理由(官方 API 文档原文):
|
||||
* · 「即使组件未获焦或是在所在页面未展示,只要已经挂载到**获焦窗口**
|
||||
* 的组件树上就会响应自定义组合键」
|
||||
* · 「无论组件是否获焦 —— 只要窗口获焦,快捷键就会响应」
|
||||
* 自己收 onKeyEvent 则要**先让某个组件获焦**,而邮件列表里焦点落在哪
|
||||
* 是不确定的(点一下就换),快捷键会时灵时不灵。
|
||||
*
|
||||
* 绑定位置选**根 Stack**:它是"整个 window 的组件树"的根,
|
||||
* 只要窗口在就生效。绑在 FAB 上不行 —— FAB 在别的 `if` 分支里、
|
||||
* 窄屏/宽屏位置也不同,它会随分支挂载/卸载。
|
||||
*
|
||||
* 键位选 `Ctrl+N`:官方文档给了硬约束 ——
|
||||
* 「控制键 Ctrl、Shift、Alt 及它们的组合加上热键的单个字符」,
|
||||
* 而禁止绑定的五个系统组合键(Alt+F4 / Alt+Tab / Ctrl+Shift+Esc 等)
|
||||
* 都不含 Ctrl+N。写邮件在桌面端历来是 Ctrl+N(新窗口/新邮件),
|
||||
* 迁移成本最低。
|
||||
*
|
||||
* ★ 文档还有一条要记住的坑:「多个不同组件设置相同组合键 ⇒
|
||||
* **只响应节点树上的深度最浅的组件**,其它组件不响应」。
|
||||
* 所以别再给别处的"写信"按钮补一个同键位绑定 —— 那不是"多一个入口",
|
||||
* 而是**让这一处失效**(浅的那个永远赢)。
|
||||
*/
|
||||
.keyboardShortcut('n', [ModifierKey.CTRL], () => {
|
||||
/*
|
||||
* ★ 这里**不能**直接叫 `openCompose()`:根组件是 `MainPage`,
|
||||
* 而 `openCompose()` 住在 `CommPage` 上(写信要用 `CommPage` 自己的
|
||||
* `navPathStack`,宽屏 Split 才能在右栏开)。
|
||||
*
|
||||
* 两步:
|
||||
* ① 把通信页切到前台 —— 否则用户此刻若在日历页,
|
||||
* `CommPage` 因条件挂载还没实例化,没有监听者;
|
||||
* ② 提"要写信"的意图,由 `ComposeIntent` 负责
|
||||
* "此刻有人听就当场给、没人听就存着"。
|
||||
*/
|
||||
this.currentIndex = 0;
|
||||
ComposeIntent.request();
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user