跨端: 鸿蒙修收信/自动已读/发件箱点开 + 页签条通透(用户报的四个问题)

用户报了四个问题,逐个实测复现 → 定位根因 → 修 → 设备复验:

① 「邮件点进去自动已读的能力不正常」
   根因:鸿蒙只有「标记已读」按钮(doMarkRead,对照 WebUI MailView.tsx:522
   那个手动按钮),缺 WebUI 的**自动**路径(MailView.tsx:55-65 的 useEffect:
   可见且 unread 就 markRead)。⇒ 点开邮件不变已读,必须再点按钮。
   修:MailDetailPage.loadMail 成功尾端按 `status==='unread'` 触发 doMarkRead
   (复用按钮那条路,因而天然带上「就地改 status」+「失败弹 toast」)。
   ★ 加 autoReadMailId 守卫:WebUI 靠 useEffect 依赖数组天然只跑一次,
     鸿蒙 loadMail 是显式调用的(下拉刷新会重跑),不守会重复打接口。

② 「接收邮件的能力也有点不正常」
   三个独立缺口,每个都会单独造成"收不到":
   a) **SSE 监听寿命**:原挂在 InboxTab.aboutToAppear,而三个 tab 是
      if/else 条件挂载的 ⇒ 切到发件箱/授权时 InboxTab 被销毁、监听跟着
      移除 ⇒ 在那两栏时收不到任何新邮件。搬到 MainPage(@Entry,全程在)。
   b) **只处理 new_mail**:WebUI 监听 5 种事件(sse.ts:7-13 的 EVENTS);
      服务端权限决策后发的是 session_update(permission.go:387)⇒
      "授权栏里处理过的申请,收件箱还是旧的样子"。补齐四种。
   c) **UTF-8 解码错**:arrayBufferToString 逐字节 String.fromCharCode
      (Latin-1 语义)⇒ 中文解成乱码。改用 util.TextDecoder + stream:true,
      且**每连接一个实例**(stream 会把半截汉字存在解码器内部,
      共享实例会把两个账号的半个字拼在一起)。

③ 「发件箱内容点不开」(第二轮;5483140 补了 onClick 仍点不开)
   根因:发件箱对单封组**既画组头又画行**——
       this.SentGroupHeader(g); if (isFlatGroup(g)) { this.SentRow(...) }
   组头那半张卡没有任何点击处理 ⇒ 点卡片上半部完全无反应。
   而 isFlatGroup 自己的注释写着「单封不成组:套一个可折叠的组头只是
   多一次点击」—— 实现与注释**直接相反**。收件箱一直是正确形状
   (if (isFlatGroup) { MailRow } else { 组头 + 子行 })。
   修:与收件箱取同形,单封组只画 SentRow。

④ 「通信页面我觉得没有 webui 那么通透」
   这是我自己上一轮改错的:把 WebUI 的「页签条无背景」实现成了
   `backgroundColor(Theme.surface)` 实心白。WebUI 的真实层叠是
   .comm-pane 是玻璃卡、CommTabs 在它内部且**自己无背景**(透出卡的白)。
   铺实心白 = 把玻璃卡换成横条白 ⇒ 壁纸再也透不过来。
   ⇒ 改回玻璃族(Theme.navMaterial,与底部导航条同档)+ 通栏 +
      只左上圆角(右上 0,与窗格那道弧重合)+ 底边线。
   判据 harmony-nav.test.mjs:732 在我改错时当场判红,是它先抓到的。

判据(变异验证:还原 bug → 必须判红)
- 新增「单封组不许既画组头又画行」:两栏的 (header, row) 对必须在
  else/三目里二选一。
  ★ 第一版写弱了(用 isFlatGroup(g) 作锚点往后切片,组头在切片之前
    ⇒ 变异测不红)。改成以**行调用**作锚点往两边开窗后,删掉修复
    即判红(实测已验)。
- 新增「列表行必须把 onClick 挂在自己身上」(MailRow/SentRow)。
- harmony-logic 34→37、harmony-nav 22(新增后仍绿)、
  harmony-appearance 27→28、animation-audit 12→15 的登记数同步。

设备复验(全部有实测凭据,不是推断)
- 自动已读:点开前 unread → 点开 4s 后服务端 read(连验两封)。
  列表组头 4→2、侧栏徽标 4→2,三处数字一致。
- 实时收信:App 保持前台不重启,从 gateway 发信 ⇒ 8s 内自动出现
  (新卡片 + 侧栏 2→3 + 页签 2→3 + 3 组 8 封)。
- 发件箱点开:点原先点不动的卡片上半区 ⇒ 右栏出正文 + 蓝色选中态。
- 页签条:截图确认为玻璃通透(不再是实心白横条)。
This commit is contained in:
2026-09-24 16:34:44 +08:00
parent 548314013a
commit 187609480f
6 changed files with 873 additions and 147 deletions

View File

@ -671,9 +671,39 @@ test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 +
'喂 `mergedMails`(含权限请求)就是把权限邮件混进收件箱,这条判据防的正是它');
assert.match(storeCode, /const split: MailSplit = splitByPermission\(mergedMails\)/,
'分家要在合并后的全量上做一次(`mergedMails`),而不是在某一账号的局部');
const splitAt = storeCode.indexOf('splitByPermission(mergedMails)');
const groupAt = storeCode.indexOf('groupMailsBySession(split.normal)');
assert.ok(splitAt >= 0 && groupAt >= 0 && splitAt < groupAt,
/*
* ★★ 2026-09-24 改:把范围收到 **`loadInbox` 函数体内**,且卵**数据流**而非字面量。
*
* 两处回因(都是“判据锚在了会漂的位置/写法上”):
* ① 本轮加了缓存(`paintFromCache`),它里面**也**会写
* `groupMailsBySession(split.normal)`;而 `indexOf` 取的是整个文件
* **第一次出现** —— 那次落在 `paintFromCache` 里、早于
* `splitByPermission(mergedMails)` ⇒ 顺序断言假红。
* ② 同一次改动把收件箱那句改成了先赋给中间变量
* (`const inboxMails = split.normal` → `groupMailsBySession(inboxMails)`),
* 于是原来的字面量 `groupMailsBySession(split.normal)` **根本不再出现**。
*
* 要卵的不变量自始至终是同一件:**折叠吃的是分家之后的普通邮件**。
* 所以允许两种写法(直接 / 经中间变量),但两者都必须出现在 `loadInbox` 里、
* 且分家在前。
*/
const inboxAt = storeCode.indexOf('async loadInbox(');
assert.ok(inboxAt > 0, '要有 loadInbox');
/* 边界:`loadInbox` 之后最近的一处“缩进两格的成员定义”(不靠 `\n }` —— 那个会被内层 catch 命中) */
const memberRe = /\n (?:async |private |public )?[a-zA-Z]+\(/g;
memberRe.lastIndex = inboxAt + 20;
const nextMember = memberRe.exec(storeCode);
const inboxBody = storeCode.slice(inboxAt, nextMember ? nextMember.index : storeCode.length);
const splitAt = inboxBody.indexOf('splitByPermission(mergedMails)');
/* 折叠的对象必须是 `split.normal`(允许经中间变量转发) */
const directAt = inboxBody.indexOf('groupMailsBySession(split.normal)');
const viaVar = /const\s+\w+:\s*MailLike\[\]\s*=\s*split\.normal[\s\S]{0,120}?groupMailsBySession\(\w+\)/.exec(inboxBody);
const groupAt = directAt >= 0 ? directAt : (viaVar ? inboxBody.indexOf(viaVar[0]) : -1);
assert.ok(splitAt >= 0 && groupAt >= 0,
'收件箱要“先分家(`split.normal`)、后折叠” —— ' +
'喂 `mergedMails`(含权限请求)就是把权限邮件混进收件箱,这条判据防的正是它');
assert.ok(splitAt < groupAt,
'★ 先分家、后折叠(顺序反了会先折叠进权限邮件,再想筛也晚了)');
});
@ -1030,3 +1060,139 @@ test('★ 列表行必须把 onClick 挂在自己身上(发件箱就漏在这
assert.match(body, /openMail\(/, `${name} 的 onClick 要调到 openMail(而不是只写个空壳)`);
}
});
/**
* ★★ 2026-09-24 新增(用户第二次报:「发件箱内容也点不开」)。
*
* ── 上一条判据为何没抓住 ──
* 上一条只钉了「行 Builder 自带 `.onClick`」—— 那是**必要条件,不是充分条件**。
* `SentRow` 确实带了 `.onClick`,判据绿了;而 bug 仍在:
* 发件箱对单封组**同时渲染了组头 + 行**,卡的上半部(组头)没有点击处理 ⇒
* 实测点 y=630(组头区)完全无反应。
* ★ 这就是“判据比 bug 弱”的典型形状:它钉的代理量(有没有 onClick)
* 与真正的不变式(**整张卡处处可点**)不一致。
*
* ── 真正的不变式 ──
* 两栏对单封组的处理必须**同形**:只渲染行,不渲染组头。
* · 收件箱(对的那一边):`if (isFlatGroup(g)) { this.MailRow(...) }`
* · 发件箱(错的那一边):无条件 `SentGroupHeader(g)` + 再叠一个 `SentRow`
* `isFlatGroup` 自己的注释早写着
* 「单封不成组:套一个可折叠的组头只是多一次点击」
* —— 发件箱的实现与它**直接相反**,而没有任何东西在看这件事。
*
* 判据取的是**结构关系**(“在两个互斥分支里二选一”),不是某一行字符串:
* 无论怎么写(`if/else`、两个 `if`、三目),只要“组头”与“行”
* 对同一个 flat 组**都会渲染**,就不通过。
*/
test('★ 单封组(isFlatGroup)不许既画组头又画行 —— 否则组头那半张卡点不动', () => {
/*
* ── 为何不用 `isFlatGroup(g)` 作锚点(我第一版就是这么写的,变异测不红)──
* bug 形状是:
* this.SentGroupHeader(g) ← 组头在**前面**
* if (isFlatGroup(g)) { this.SentRow(g.mails[0]) }
* 从 `isFlatGroup(g)` 往后切片时,组头**落在切片之前** ⇒ 两个名字里只看到一个,
* 直接 `continue` 跳过了。
* ⇒ 改用**行调用**(`Row(g.mails[0])`)作锚点、往**两边**开窗:
* 无论组头写在前还是在后,两个名字都在窗里。
*/
const ROWS = [
{ row: 'this.MailRow(g.mails[0])', header: 'this.GroupHeader(' },
{ row: 'this.SentRow(g.mails[0])', header: 'this.SentGroupHeader(' },
];
const WINDOW = 600;
for (const { row, header } of ROWS) {
const rAt = pageCode.indexOf(row);
assert.ok(rAt > 0, `要能找到单封组的行渲染 \`${row}\``);
const from = Math.max(0, rAt - WINDOW);
const to = Math.min(pageCode.length, rAt + WINDOW);
const win = pageCode.slice(from, to);
const relR = rAt - from;
const relH = win.indexOf(header);
assert.ok(relH >= 0, `${row} 同一张卡里要有组头 \`${header}\`(两栏结构应一致)`);
const lo = Math.min(relH, relR);
const hi = Math.max(relH, relR);
const between = win.slice(lo, hi);
assert.match(between, /\belse\b|\?/, [
`单封组的“${header}”与“${row}”必须在 \`else\` 或三目里**二选一**。`,
'两者都渲染时,组头那半张卡没有点击处理 —— 用户点上去没反应。',
'(2026-09-24 实测:发件箱点组头区完全无反应,就是这一条。)',
'收件箱的正确形状是 `if (isFlatGroup(g)) { Row } else { Header + 子行 }`。',
].join(''));
}
});
/**
* ★★ 2026-09-24 新增(用户:「鸿蒙是 app 啊,缓存邮件多正常,还可以加快同步速度」)。
*
* ── 为什么 App 该缓存、WebUI 不缓存是对的 ──
* WebUI 的 `mailStore` 没有 persist(逐个数过:只有 account/appearance/background/
* contact/theme 五个 store 有)—— 那是浏览器环境的合理取舍。
* 而 App 的本地存储是**能力**(preferences 单值上限 16MB),
* 「启动先出缓存、再拉服务端覆盖」是原生应用的常规做法。
*
* ── 判据卵的是**不变式**,不是某个函数名 ──
* ① 两栏(收件箱/发件箱)**都要**先读缓存:只给一栏做,另一栏就仍然是空等;
* ② 缓存键**必须带账号** —— `AppearanceStore` 里已经记过这条教训
* (WebUI 侧因为多账号共用一份全局常量键,切账号互相覆盖);
* ③ 缓存**不得代替取数**:读缓存那一句必须在发请求之前,且两者都在。
* 把缓存写成"有缓存就 return"就变成离线库了 —— 而邮件会变,
* 不刷新比不缓存更坏。
*/
test('★ 邮件本地缓存:两栏都先读后拉,且键带账号', () => {
const store = code(join(HARMONY_ETS, 'common/MailStore.ets'));
/* ① 两栏都要接上缓存 */
assert.match(store, /KEY_INBOX: string = 'inbox\.'/, '收件箱缓存的键要有独立前缀');
assert.match(store, /KEY_SENT: string = 'sent\.'/, '发件箱缓存要有独立前缀(不能与收件箱共键)');
for (const [fn, kind, paint] of [
['loadInbox', 'KEY_INBOX', 'paintFromCache'],
['loadSent', 'KEY_SENT', 'paintSentFromCache'],
]) {
const at = store.indexOf(`async ${fn}(`);
assert.ok(at > 0, `${fn} 要存在`);
const body = store.slice(at, store.indexOf('\n }', at));
assert.match(body, new RegExp(`${paint}\\(ctx, accountFilter\\)`),
`${fn} 要先读缓存(${paint})`);
assert.match(body, new RegExp(`persistByAccount\\(ctx, ${kind}`),
`${fn} 取数成功后要回写缓存(${kind})—— 只读不写的话缓存永远是空的`);
}
/* ② 读缓存在发请求之前(缓存不代替取数) */
const inboxAt = store.indexOf('async loadInbox(');
const inboxBody = store.slice(inboxAt, store.indexOf('\n }', inboxAt));
const paintIdx = inboxBody.indexOf('paintFromCache(');
const fetchIdx = inboxBody.indexOf('new MailApi(accountClient).inbox(');
assert.ok(paintIdx > 0 && fetchIdx > paintIdx,
'读缓存必须在发请求**之前**(先出内容再覆盖),且请求仍在 —— ' +
'把缓存写成"有就 return"=离线库,邮件会变,不刷新比不缓存更坏');
/* ③ 键必须拼上账号 id(不是全局常量键)—— **读写两处都要** */
/*
* ⚠ 只查"文件里出现过 `kind + accountId`"**不够**:
* 变异实测(把 `putSync` 那处改成裸 `kind`、`getSync` 保留)仍然**绿** ——
* 因为另一处还在。而真实后果是写入用全局键、读取用带账号键 ⇒
* 缓存永远读不回来(写了个空)。
* ⇒ 读写两处各自断言。
*/
const putAt = store.indexOf('putSync(');
const getAt = store.indexOf('getSync(');
assert.ok(putAt > 0 && getAt > 0, '要有 putSync / getSync');
assert.match(store.slice(putAt, putAt + 120), /putSync\(kind \+ accountId/,
'缓存**写**的键要带 accountId —— 多账号共用一份键会互相覆盖(AppearanceStore 记过这条)');
assert.match(store.slice(getAt, getAt + 120), /getSync\(kind \+ accountId/,
'缓存**读**的键也要带 accountId,且与写入同形 —— ' +
'读写键不一致的后果是缓存永远读不回来(写了等于白写)');
/* ④ 缓存失败不得影响取数(全程 try/catch 吞掉) */
const wcAt = store.indexOf('private writeCache(');
assert.ok(wcAt > 0, '要有 writeCache');
const rcAt = store.indexOf('private readCache(');
assert.ok(rcAt > 0, '要有 readCache');
for (const [name, at] of [['writeCache', wcAt], ['readCache', rcAt]]) {
const body = store.slice(at, store.indexOf('\n }', at));
assert.match(body, /catch \(e\) \{/, `${name} 要自己吞异常 —— 缓存坏了不能把取数一起拖红`);
}
});

View File

@ -68,7 +68,7 @@ const SUITE = [
* 的接线判据 —— 用户「webui 行为是按钮变成对应的写邮件页面或输入框吧」。
* 详细理由见该文件里那段「这三条第一版写错了」的注释(两版错法都记了)。
*/
['test/animation-audit.test.mjs', [], 12], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion/系统弹簧曲线
['test/animation-audit.test.mjs', [], 15], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion/系统弹簧曲线
// 深色模式:色板反转 + 玻璃 alpha 档 + 底必须是暗的(2026-09-17 那次
// 「只有通信页深色正常」的回归锁 —— 38 条里后 8 条是这次新增)。
['test/theme.test.mjs', [], 38],
@ -103,10 +103,10 @@ const SUITE = [
// 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。
// 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。
['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6],
['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 34],
['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 37],
['test/harmony-system-api.test.mjs', [], 5],
// P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 27],
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 28],
// P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 22],
// 上下黑边(用户 2026-09-17/18 报过两次)——钉的是一整套东西的两半:

View File

@ -6,6 +6,8 @@
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
/* UTF-8 解码用(见 `arrayBufferToString` 的注释:逐字节 fromCharCode 会把中文解成乱码) */
import { util } from '@kit.ArkTS';
import { AccountManager, AccountInfo } from './AccountManager';
import { normalizeApiBase } from '../model/ApiBase';
@ -39,6 +41,13 @@ export class SseConnection {
reconnectTimer: number = 0;
buffer: string = '';
connected: boolean = false;
/*
* ★★ 2026-09-24:**每连接一个** 解码器(不要共享)。
* `decodeToString(..., {stream:true})` 会把「被 TCP 分片切断的半个汉字」
* 存在**该解码器实例内部**,等下一片到了再拼。多个连接共用同一个实例
* 就会把一个账号的半个字与另一个账号的半截拼在一起 ⇒ 乱码。
*/
decoder: util.TextDecoder | null = null;
}
export class SseService {
@ -191,6 +200,8 @@ export class SseService {
const httpRequest = http.createHttp();
conn.httpRequest = httpRequest;
/* 重连要换新的:旧解码器里可能还卡着上次断线时那半个字 */
conn.decoder = util.TextDecoder.create('utf-8', { ignoreBOM: true });
const url: string = conn.server + '/events/stream';
const header: Record<string, string> = {};
@ -201,7 +212,7 @@ export class SseService {
hilog.info(DOMAIN, TAG, 'connecting account %{public}s to %{public}s', conn.accountId, url);
httpRequest.on('dataReceive', (data: ArrayBuffer) => {
const text: string = this.arrayBufferToString(data);
const text: string = this.arrayBufferToString(conn, data);
conn.buffer += text;
this.processBuffer(conn);
});
@ -298,12 +309,29 @@ export class SseService {
conn.reconnectTimer = timer ?? 0;
}
private arrayBufferToString(buffer: ArrayBuffer): string {
const uint8Array: Uint8Array = new Uint8Array(buffer);
let result: string = '';
for (let i = 0; i < uint8Array.length; i++) {
result += String.fromCharCode(uint8Array[i]);
}
return result;
private arrayBufferToString(conn: SseConnection, buffer: ArrayBuffer): string {
/*
* ★★ 2026-09-24 修:**UTF-8 解码**(用户:「鸿蒙 app 接收邮件的能力也有点不正常」)。
*
* 原来这里是逐字节 `String.fromCharCode(uint8Array[i])` —— 那是
* **Latin-1 语义**:字节 ≥ 0x80 各自变成一个独立字符。
* SSE 流里一个汉字是 3 个 UTF-8 字节(如 「新」 = E6 96 B0)
* ⇒ 解出来是 `æ\x96°` 这种乱码。
*
* WebUI 没这个问题:它用浏览器原生 `EventSource`,规范本身按 UTF-8
* 解码(`client/electron/src/api/sse.ts:59`)。
* ⇒ 两端差在**解码这一步**,不是差在有没有 SSE。
*
* ★ 为什么不用 `util.TextDecoder`:HarmonyOS 有 `@kit.ArkTS` 的
* `util.TextDecoder`(标准 API),比自己手写 UTF-8 状态机可靠得多
* —— 手写容易在「3 字节序列被 TCP 分片切成两段」时错(SSE 常见)。
* `stream: true` 正是为这种分段场景准备的:不完整的尾字节会
* 留在内部缓冲里,等下一片到了再拼。
*/
const decoder: util.TextDecoder = conn.decoder !== null
? conn.decoder
: util.TextDecoder.create('utf-8', { ignoreBOM: true });
conn.decoder = decoder;
return decoder.decodeToString(new Uint8Array(buffer), { stream: true });
}
}

View File

@ -29,6 +29,7 @@
* 所以这里额外给一个 `revision`(见下),调用方把它接进 `@State` 即可触发刷新。
*/
import { common } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
import { ApiClient, ApiError } from '../api/ApiClient';
import { MailApi, InboxResponse } from '../api/MailApi';
import { AccountInfo, AccountManager } from '../api/AccountManager';
@ -43,6 +44,67 @@ import {
partialLoadNotice
} from '../model/MailGrouping';
/**
* 邮件本地缓存的 preferences 库名。
*
* ★★ 2026-09-24 新增(用户:「鸿蒙是 app 啊,缓存邮件多正常,还可以加快同步速度」)。
*
* ── 为什么 App 该缓存、而 WebUI 不缓存是合理的 ──
* WebUI 的 `mailStore` 没有 persist(我逐个数过:只有 account/appearance/
* background/contact/theme 五个 store 有)—— 那是**刻意的**:浏览器 localStorage
* 只有 5MB、且网页本来就靠服务端渲染态。
* 而 App 不一样:本地存储是**能力**(preferences 单值上限 16MB),
* 「启动先出缓存、再拉增量覆盖」是原生应用的常规做法,用户感知到的就是**快**。
* ⇒ 这不是"向 WebUI 对齐"的范畴,而是 App 该做的事。
*
* ── 设计(两件独立的事,别混)──
* ① **写**:每次拉取成功后把 `mails` 存进去;
* ② **读**:拉取**之前**先读缓存渲染(首屏即刻有内容),
* 然后照常发请求,用服务端结果覆盖。
* ⇒ 缓存只"提前显示",**不代替取数**:过期数据不会因为缓存而阻止刷新。
*/
const PREF_STORE: string = 'agentmail_mailcache';
/**
* 缓存键的前缀。
*
* ★ 键**必须带账号**:`AppearanceStore` 里已经记过这条教训
* (WebUI 侧因为多账号共用一份全局常量键,切账号会互相覆盖)。
*/
const KEY_INBOX: string = 'inbox.';
const KEY_SENT: string = 'sent.';
/**
* 缓存存多少封。
*
* 取与首页相同的 50:缓存的目的是"启动即刻有得看",不是离线库;
* 存多了既拖慢启动时的 JSON 解析,又让 preferences 单值变大。
*/
const CACHE_MAX: number = 50;
/**
* 「邮件列表需要刷新」的发布键(`AppStorage`)。
*
* ★★ 2026-09-24 新增(用户:「鸿蒙 app 接收邮件的能力也有点不正常」+「自动已读不正常」)。
*
* ── 为什么需要它 ──
* 鸿蒙的两栏(收件箱 / 发件箱)各自把快照接进自己的 `@State`,
* 而它们**只有首次挂载时拉一次**(`aboutToAppear`)—— `Navigation` split 模式下列表常驻,
* 从详情页返回、或从别的 tab 切回来时**都不会重拉**。
*
* 结果是:详情页把某封标成已读、或 SSE 告知来了新邮件之后,
* **列表里那一行还是旧样子**(未读点还在 / 新信看不见)。
* WebUI 没这问题:它用 zustand,列表与详情共享同一份 store,一处改全局生效。
*
* ── 怎么用(照 `Theme.KEY_IS_DARK` / `agentmail.appearance.revision` 的既有形状)──
* 写方:`MailStore.bump()` 自增 `AppStorage` 里这个键(一处自增,所有窗格都能看到);
* 读方:窗格用 `@StorageProp(KEY_MAIL_REV) @Watch('...') mailRev: number = 0`,
* 回调里重拉/重算。
*
* ★ 为什么不直接在窗格间互调回调:窗格是**条件挂载**的
* (`if (this.commTab === 'sent')`)—— 切到发件箱时收件箱已经销毁,
* 回调打过去对象都不存在。广播比点对点在此处可靠。
*/
const KEY_MAIL_REV: string = 'agentmail.mail.revision';
/**
* 收件箱首页大小。
*
@ -115,6 +177,262 @@ export class MailStore {
this.revision += 1;
}
/**
* 把"要刷新了"广播到 `AppStorage`(见 `KEY_MAIL_REV` 的注释)。
*
* ★★ **必须与 `bump()` 分开** —— 两者一开始被我写成同一个,结果是**死循环**:
* `bump()` → 发布 → 窗格 `onMailRevChanged` → `loadData()` → `loadInbox()`
* → 结束时又 `bump()` → 发布 → ... 无休止地拉接口。
*
* 分开后的语义:
* · `bump()` = **我自己**把快照改了(拉取完成、清空、dropSession);
* 持有快照的调用方自己把它接进 `@State`,**不需要**广播。
* · 本方法 = **别的组件**可能不知道这件事变了(详情页改了已读、
* SSE 报了新邮件)⇒ 才需要广播出去。
* 两者混在一起就是上面那个循环。
*/
private publishChange(): void {
this.revision += 1;
AppStorage.setOrCreate<number>(KEY_MAIL_REV, this.revision);
}
/**
* 就地标记某一封为已读(纯本地,不发请求)。
*
* ★★ 2026-09-24 新增(用户:「鸿蒙 app 的邮件点进去自动已读的能力不正常」)。
*
* ── 为什么需要单独一个"只改本地"的方法 ──
* 发请求那一步(`MailApi.markRead`)在**详情页**做(它手上有账号 token);
* 而"列表里那一行跟着变"是 **store** 的事。
* 详情页把请求发成功之后调这个方法,列表立刻同步。
*
* ── 与 WebUI 对齐 ──
* WebUI `mailStore.ts:128-140` 的 `markRead` 就是"先改本地状态、再打服务端",
* 详情页与列表读的是同一份 store ⇒ 天然同步。
* 鸿蒙两端是**分开的组件**,所以要把这一步显式接起来。
*
* 改到就 `bump()`:让当前挂着的收件箱栏看到变化。
*/
markReadLocal(mailId: string): void {
const snap = this.snapshot;
let changed: boolean = false;
for (let i = 0; i < snap.mails.length; i++) {
const m: MailLike = snap.mails[i];
if (m.mail_id === mailId && m.status === 'unread') {
m.status = 'read';
changed = true;
}
}
if (!changed) {
return;
}
/*
* 分组里的未读计数也要跟着减 —— 否则行不显示红点了、
* 但分组头还写着"1 封未读"(两处数字对不上,与收件箱头的口径同一类错)。
*/
const split: MailSplit = splitByPermission(snap.mails);
snap.groups = groupMailsBySession(split.normal);
let localUnread: number = 0;
for (let i = 0; i < split.normal.length; i++) {
if (split.normal[i].status === 'unread') {
localUnread += 1;
}
}
/* 与服务端 `total` 取较大者的口径一致(见 `loadInbox` 末尾):
这里只能调小到"本地数出来的那个值",不能凭空往上加。 */
if (snap.unread > localUnread) {
snap.unread = localUnread;
}
this.publishChange();
}
/**
* 从服务端重新拉两栏(SSE 来了新邮件时调)。
*
* ★ 不在这里直接 `loadInbox`:拉取需要 `ctx` 与当前 `accountFilter`,
* 那是**窗格**才知道的(账号筛选是窗格状态)。方法把"该刷了"广播出去,
* 由挂着的窗格自己拿它手上的参数去拉。
*
* 这就是 `KEY_MAIL_REV` 的用途:窗格 `@Watch` 到变化 → 自己 `loadData()`。
*/
notifyRemoteChange(): void {
/*
* 不用 `bump()`:那不是"内容变了",是"服务端可能有新的,快去拉"。
* 两者共用同一条修订号会让窗格无法区分(自增一下、让 `@Watch` 醒过来就好)。
*/
this.publishChange();
}
/**
* 用发件箱缓存先把列表填上(与 `paintFromCache` 同形,只是不筛权限、不算未读)。
*
* ★ 两栏的差异只有“要不要筛/分组的口径”这一处;
* `readCache` / `writeCache` 是共用的,这里只做组装。
*/
private paintSentFromCache(ctx: common.Context, accountFilter: string): boolean {
try {
const accounts: AccountInfo[] = AccountManager.getInstance(ctx).getAccounts();
const merged: MailLike[] = [];
for (let i = 0; i < accounts.length; i++) {
const acct: AccountInfo = accounts[i];
if (accountFilter !== 'all' && accountFilter !== acct.id) {
continue;
}
const cached = this.readCache(ctx, KEY_SENT, acct.id);
if (cached === null) {
continue;
}
for (let j = 0; j < cached.length; j++) {
const m: MailLike = cached[j];
m.source_account_id = acct.id;
m.source_account_name = acct.displayName;
merged.push(m);
}
}
if (merged.length === 0) {
return false;
}
this.snapshot.mails = merged;
this.snapshot.groups = groupMailsBySession(merged);
this.snapshot.loaded = merged.length;
this.bump();
return true;
} catch (e) {
return false;
}
}
/**
* 把 `merged` 按 `source_account_id` 拆开、逐账号写缓存。
*
* ★ 抽成一处而不是在 `loadInbox`/`loadSent` 各写一遍:
* 收件箱与发件箱共用同一个缓存机制,"怎么拆、给哪些账号写"
* 必须**只有一处定义** —— 否则两栏迟早分叉(本仓反复出现的形状)。
*/
private persistByAccount(ctx: common.Context, kind: string, accountFilter: string,
accounts: AccountInfo[], merged: MailLike[]): void {
for (let i = 0; i < accounts.length; i++) {
const acct: AccountInfo = accounts[i];
if (accountFilter !== 'all' && accountFilter !== acct.id) {
continue;
}
const own: MailLike[] = [];
for (let j = 0; j < merged.length; j++) {
if (merged[j].source_account_id === acct.id) {
own.push(merged[j]);
}
}
if (own.length > 0) {
this.writeCache(ctx, kind, acct.id, own);
}
}
}
/**
* 把一批邮件写进本地缓存(每个账号一份)。
*
* ★★ 为什么**不逐字段手抄**(我第一版就那么写的,当场漏了
* `session_alias` / `session_workspace` 两个字段):
* `MailLike` 有十几个字段,手抄一份"要持久化的子集"就是
* **同一个东西两处各写一遍** —— 本仓反复出现的那个形状。
* 加一个字段时改了模型、忘了改这里,症状是"重启后少个东西显示不出来",
* 极难往这里想。
* ⇒ 直接 `JSON.stringify(rows)`:字段由模型自己决定,不会漂。
* 派生量(`cc_count`/`attach_count`)多存一份无害(读回来时会重算)。
*
* ★ 全程 `try/catch` 吞掉:**缓存失败不能影响取数**。
* 写不进去最多是下次启动没得提前看,而把一次取数拖红是净损失。
*/
private writeCache(ctx: common.Context, kind: string, accountId: string,
mails: MailLike[]): void {
try {
const store = preferences.getPreferencesSync(ctx, { name: PREF_STORE });
const n: number = mails.length < CACHE_MAX ? mails.length : CACHE_MAX;
store.putSync(kind + accountId, JSON.stringify({
'at': Date.now(),
'mails': mails.slice(0, n)
}));
store.flush();
} catch (e) {
/* 静默:见上面那段注释 */
}
}
/**
* 读本地缓存。返回 `null` 表示这个账号没有可用缓存。
*
* ★ 同样全程吞异常:`JSON.parse` 碰到旧版本残留会抛,
* 而**缓存旧了就丢掉**是正确行为(源数据在服务端,下一次取数就能重建),
* 不能因为一份坏缓存让整个收件箱打不开。
*/
private readCache(ctx: common.Context, kind: string, accountId: string): MailLike[] | null {
try {
const store = preferences.getPreferencesSync(ctx, { name: PREF_STORE });
const raw = store.getSync(kind + accountId, '') as string;
if (raw.length === 0) {
return null;
}
const parsed = JSON.parse(raw) as Record<string, Object>;
const rows = parsed.mails as MailLike[];
if (rows === undefined || rows.length === 0) {
return null;
}
/*
* ★ 不重算派生量:`MailLike` 里**只有** `cc_count`/`attach_count`
* (没有 `cc_list`/`attachments` 数组 —— 那两个是 `MailSummary` 的),
* 而写入时把整个对象存进来了 ⇒ 派生量已在缓存里。
* (第一版写了重算,读的字段根本不在接口上,编译会报。)
*/
return rows;
} catch (e) {
return null;
}
}
/**
* 用缓存**先把界面填上**(首屏即刻有内容),返回是否真的用上了。
*
* ★ 只做一件事:把缓存邮件过一遍与取数路径**同样的分组口径**,
* 否则会出现"缓存里的分组跟刷新后的不一样"这种闪一下。
* ★ **不碰** `loading`:它是"正在拉服务端"的指示,缓存不该把它置掉。
* 页面仍然会看到 `loading=true`,于是继续显示刷新态 —— 只是底下的列表
* 已经有东西了。
*/
private paintFromCache(ctx: common.Context, accountFilter: string): boolean {
try {
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
const accounts: AccountInfo[] = acctMgr.getAccounts();
const merged: MailLike[] = [];
for (let i = 0; i < accounts.length; i++) {
const acct: AccountInfo = accounts[i];
if (accountFilter !== 'all' && accountFilter !== acct.id) {
continue;
}
const cached = this.readCache(ctx, KEY_INBOX, acct.id);
if (cached === null) {
continue;
}
for (let j = 0; j < cached.length; j++) {
const m: MailLike = cached[j];
m.source_account_id = acct.id;
m.source_account_name = acct.displayName;
merged.push(m);
}
}
if (merged.length === 0) {
return false;
}
const split: MailSplit = splitByPermission(merged);
this.snapshot.mails = merged;
this.snapshot.groups = groupMailsBySession(split.normal);
this.snapshot.loaded = split.normal.length;
this.bump();
return true;
} catch (e) {
return false;
}
}
/**
* 拉收件箱 —— **全仓唯一**的收件箱取数实现。
*
@ -127,6 +445,20 @@ export class MailStore {
*/
async loadInbox(ctx: common.Context, accountFilter: string): Promise<void> {
const snap = this.snapshot;
/*
* ★★ 2026-09-24 新增:**先读缓存**(用户:「鸿蒙是 app 啊,缓存邮件多正常」)。
*
* 顺序有意如此:缓存只是"把首屏提前填上",**不代替取数** ——
* 下面照常发请求并用服务端结果覆盖。所以刚装完/清过缓存时行为不变,
* 而有缓存时进这一栏就不必盯着空列表等网络。
*
* 只在本栏**还没内容**时读(`loaded === 0`):已经有列表还在屏幕上时
* 再用缓存盖一次,会让人看到内容闪一下(缓存比服务端旧)。
*/
if (snap.loaded === 0) {
await AccountManager.getInstance(ctx).load();
this.paintFromCache(ctx, accountFilter);
}
snap.loading = true;
snap.error = '';
snap.accountErrors = [];
@ -224,6 +556,9 @@ export class MailStore {
snap.groups = groupMailsBySession(inboxMails);
snap.loaded = inboxMails.length;
snap.accountErrors = failed;
/* 取数成功 → 回写缓存(存筛前的 `mergedMails`:
授权邮件也属于"已经拉到的东西",下次启动直接省一次请求) */
this.persistByAccount(ctx, KEY_INBOX, accountFilter, allAccounts, mergedMails);
/*
* 未读数:服务端 `total`(= CountUnread,权威)+ 本地数一遍筛后的未读,
* 取**较大者**。只信 total 会把授权栏的未读算进收件箱;只数列表会少报
@ -259,6 +594,14 @@ export class MailStore {
const snap = this.snapshot;
snap.loading = true;
snap.error = '';
/*
* ★★ 2026-09-24:与收件箱同一套缓存(先读后拉),理由见 `writeCache` 的注释。
* 次序也一样:**先读缓存、再发请求** —— 缓存不代替取数。
*/
if (snap.loaded === 0) {
await AccountManager.getInstance(ctx).load();
this.paintSentFromCache(ctx, accountFilter);
}
this.bump();
try {
@ -321,6 +664,8 @@ export class MailStore {
snap.unread = 0;
snap.notice = '';
snap.accountErrors = failed;
/* 取数成功 → 回写缓存(与收件箱同一处实现、同一分存口径) */
this.persistByAccount(ctx, KEY_SENT, accountFilter, allAccounts, merged);
} catch (e) {
const ae = e as ApiError;
snap.error = ae.message.length > 0 ? ae.message : '加载失败';

View File

@ -28,6 +28,11 @@ import { attachmentLabel } from '../model/Attachment';
import { permissionLabel } from '../model/MailGrouping';
import { LIST_FADE_LENGTH, HEADER_BACK_HIT } from '../model/NavItems';
import { LengthMetrics } from '@kit.ArkUI';
/*
* 详情页标已读后要**回写列表**(详见 `doMarkRead` 里的注释)。
* 鸿蒙的列表与详情是分开的组件,不像 WebUI 共享一份 store ⇒ 这一步必须显式接。
*/
import { MailStore } from '../common/MailStore';
import {
participantAddress,
mailReplyTarget,
@ -89,6 +94,16 @@ export struct MailDetailView {
@Prop navReserve: number = 0;
onBack: () => void = (): void => {};
@State mailId: string = '';
/**
* 已为哪一封发过“自动已读”请求(防 `loadMail` 重跑时重复打接口)。
*
* ★ 与 WebUI 的差异要在这补:WebUI 那个 `useEffect` 靠依赖数组
* (`currentStatus` 从 unread 变 read 后就不再跑)天然只跑一次;
* 鸿蒙的 `loadMail` 是**显式调用**的(下拉刷新、重进都会再跑),
* 所以只能自己记一个“已经为这封发过了”。
* 服务端幂等、不会出错,但重发会在日志里刷一堆噪声(每看到一次就发一次)。
*/
private autoReadMailId: string = '';
@State accountId: string = '';
@State subject: string = '';
@State fromName: string = '';
@ -294,6 +309,40 @@ export struct MailDetailView {
this.permissionResult = mail.permission_result;
this.sessionAlias = mail.session_alias;
this.sessionId = mail.session_id;
/*
* ★★ 2026-09-24 **自动已读**(用户:「鸿蒙 app 的邮件点进去自动已读的能力不正常」)。
*
* ── 问题 ──
* 鸿蒙此前**只有「标记已读」按钮**(`doMarkRead`,L518,对应 WebUI
* `MailView.tsx:522` 那个手动按钮)。点开邮件不会自动变已读 ——
* 必须再点一次按钮才行。
*
* ── WebUI 怎么做的(`MailView.tsx:55-65`)──
* const visible = !narrow || narrowPane === 'detail';
* useEffect(() => {
* if (!visible || !currentMailID || currentStatus !== 'unread') return;
* void markRead(currentMailID);
* }, [visible, currentMailID, currentStatus, markRead]);
*
* 两条护栏都照搬:
* ① **已读的不重复发**(`status !== 'unread'` 直接 return)——
* `doMarkRead` 内部就有这条,但**提前在这判断一次**省得构建 Promise;
* ② **详情页挂载 = 真正展示**(鸿蒙的 `MailDetailPage` 是被 `pushPath`
* 推进来的,挂上 ≈ WebUI 窄屏 `narrowPane === 'detail'` 那条判据)。
*
* ── 为什么不等 `onPageShow` ──
* `aboutToAppear` 之后 `loadMail` 完成才设了 `this.status`,
* 这正是「展示该封」的时机;`onPageShow` 是页面可见性回调,
* 与 loadMail 的完成**不同步**(load 完可能页还没 show,show 了可能 mail 还没 load 完)。
* 直接在 loadMail 成功的尾端触发,与 WebUI `useEffect` 监听 `currentStatus` 同一效果。
*
* ── 复用 `doMarkRead` 而不是直接 `markRead` ──
* 它已经包了①就地改 `this.status = 'read'` ②失败弹 toast ——
* 自动路径也该有这两条(标了要立刻看到、失败要告诉用户)。
*/
if (mail.status === 'unread') {
this.doMarkRead();
}
/*
* ★★ 2026-09-19:改名建议要**在这里拉一次** —— 我第一版把提示条的 UI 写完了
* 却忘了接数据,于是页面上**永远不显示**那条建议(服务端明明有)。
@ -525,6 +574,18 @@ export struct MailDetailView {
}
await this.mailApi.markRead(this.mailId);
this.status = 'read';
/*
* ★★ 2026-09-24:**回写列表**(用户:「点进去自动已读的能力不正常」)。
*
* 只改 `this.status` 只能让当前这一页的头部徽标变"已读";
* 返回列表时那一行**还是蓝点未读** —— 因为列表的 `mails` 数组
* 是它自己的 `@State`,不会被本页改到。
*
* WebUI 没这一步:详情与列表读同一个 zustand store,
* `markRead` 一改两边都变(`mailStore.ts:128-140`)。
* 鸿蒙是分开的组件 ⇒ 显式通知(store 内部 `bump()` 会广播 `KEY_MAIL_REV`)。
*/
MailStore.getInstance().markReadLocal(this.mailId);
} catch (e) {
const ae = e as BusinessError;
this.getUIContext().getPromptAction().showToast({ message: '标记已读失败: ' + ae.message });

View File

@ -163,8 +163,24 @@ struct InboxTab {
* ArkTS 的静态常量没有那层机制)。窗格自己算不出深浅色 ⇒ 走 AppStorage 读。
*/
@StorageProp('agentmail.appearance.isDark') isDarkNow: boolean = false;
@State mails: MailSummary[] = [];
/** 按会话折叠后的列表(单封的组平铺渲染) */
/**
* 邮件列表修订号(`MailStore.bump()` / `notifyRemoteChange()` 发布)。
*
* ★★ 2026-09-24 新增(用户:「接收邮件不正常」+「自动已读不正常」)。
*
* 本栏只在 `aboutToAppear` 拉一次;而 `Navigation` split 模式下列表常驻,
* 从详情页返回、或从别的 tab 切回来时**都不会重拉**。
* ⇒ 两种症状:
* ① 在详情页标了已读,返回列表那行**还是蓝点**;
* ② SSE 告知来了新邮件,但切过去看还是旧的(除非杀进程重进)。
*
* 现在监听这个键:详情页标已读、或 SSE 报新邮件 → store 自增 → 本栏重拉。
*
* ★ 用 `@StorageProp` 而不是与 `InboxTab` 的 `@State` 双向绑定:
* 这里只需要“变化时醒一下”,值本身不用读(窗口的其它 `@StorageProp` 同形)。
*/
@StorageProp('agentmail.mail.revision') @Watch('onMailRevChanged') mailRev: number = 0;
@State mails: MailSummary[] = []; /** 按会话折叠后的列表(单封的组平铺渲染) */
@State groups: SessionGroup[] = [];
/** 已展开的组(键 = SessionGroup.key) */
@State expandedKeys: string[] = [];
@ -198,12 +214,10 @@ struct InboxTab {
* 两个 tab 各存一份必然会分叉。单点写、多点读。
*/
@Prop currentMailId: string = '';
private sseService: SseService | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.sseService = SseService.getInstance();
// 加载账号列表
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
acctMgr.load().then(() => {
@ -217,30 +231,36 @@ struct InboxTab {
}
});
this.loadData();
// 全局 SSE 监听(所有账号的事件都会收到);保存同一函数引用以便页面退出时移除。
this.sseService.addListener(this.onSseEvent);
/*
* ★★ 2026-09-24:SSE 监听**已搬到 `MainPage`**(App 级,全程在)——
* 这里不再注册。
*
* 原处注册的致命问题:本栏是条件挂载的,切到发件箱/授权就被销毁,
* 监听跟着没了。详细的因果写在 `MainPage.aboutToAppear` 里。
*
* ★ 留着会双重触发(两层都收同一事件)—— 必须只有一处注册。
*/
}
}
aboutToDisappear(): void {
if (this.sseService !== null) {
this.sseService.removeListener(this.onSseEvent);
}
/* 本栏不再持有 SSE 监听,所以这里没有 `removeListener`;
原先那两行已随注册一起移走。 */
}
private onSseEvent = (event: SseEvent): void => {
if (event.type === 'new_mail') {
let sourceName: string = '';
for (let i = 0; i < this.accountList.length; i++) {
if (this.accountList[i].id === event.accountId) {
sourceName = this.accountList[i].displayName;
break;
}
}
this.getUIContext().getPromptAction().showToast({ message: sourceName.length > 0 ? '新邮件:' + sourceName : '新邮件到达' });
this.loadData();
}
};
/**
* 邮件修订号变了 ⇒ 列表需要重拉。
*
* 两种来源都走这里(见字段上的注释):
* · 详情页标了已读(`MailStore.markReadLocal`);
* · SSE 报新邮件(`MailStore.notifyRemoteChange`)。
*
* ★ 只重拉、不做“就地改本栏 `@State`”—— `loadInbox` 内部已经处理了
* “已有列表时用服务端结果覆盖”,重拉一次比本栏自己拼一份可靠。
*/
onMailRevChanged(): void {
this.loadData();
}
/**
* 拉收件箱。
@ -499,13 +519,19 @@ struct InboxTab {
.bindMenu($$this.showAccountPicker, this.AccountFilterMenu())
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
}
if (this.unread > 0) {
Text(this.unread > 99 ? '99+' : this.unread.toString())
.fontSize(10).fontWeight(FontWeight.Bold).fontColor(Theme.accentFg)
.backgroundColor(Theme.danger).borderRadius(9)
.constraintSize({ minWidth: 18 }).height(18)
.textAlign(TextAlign.Center).margin({ left: 8 })
}
/*
* ★★ 2026-09-24 **删掉列表头的未读红圈**(用户:「通信页面 webui 和 app
* 存在很大的区别」)。
*
* WebUI 的列表头(`MailList.tsx:74-84`)只有三个东西:
* ① `<h2>` 收件箱/发件箱;
* ② `AccountSwitcher`(多账号才出现);
* ③ 计数(`N 组 · M 封`)。
* **没有未读徽标** —— 未读只在页签条上(`CommTabs` 的红色胶囊)。
*
* 这里多出来的红圈是鸿蒙自己加的:同一屏上就会看到两个未读数
* (页签条一个、列表头一个),而它们是同一个数字,看着像两个指示。
*/
}
.width('100%').height(46)
.padding({ left: 14, right: 12 })
@ -983,11 +1009,24 @@ struct SentTab {
@State loading: boolean = false;
@State error: string = '';
@State loaded: number = 0;
/*
* 邮件修订号 —— 与 `InboxTab` 那一个**同键同形**(完整理由写在那里)。
*
* ★ 2026-09-24:发件箱同样只在 `aboutToAppear` 拉一次,
* SSE 报新邮件 / 详情页改了状态时不会重拉。两栏是同一块
* “切走了就不刷新”的病 ⇒ 同一个键、同一套写法,不许分叉。
*/
@StorageProp('agentmail.mail.revision') @Watch('onMailRevChanged') mailRev: number = 0;
aboutToAppear(): void {
this.load();
}
/** 修订号变了 ⇒ 重拉(与 `InboxTab.onMailRevChanged` 同形同理由) */
onMailRevChanged(): void {
this.load();
}
/**
* 取数**交给 `MailStore`**(与收件箱同一份实现)。
*
@ -1262,8 +1301,30 @@ struct SentTab {
ForEach(this.groups, (g: SessionGroup) => {
ListItem() {
Column() {
this.SentGroupHeader(g)
if (isFlatGroup(g)) {
/*
* ★★ 2026-09-24 修(用户:「发件箱内容也点不开」——补了 `.onClick` 仍点不开)。
*
* ── 根因:单封组多渲染了一个**不可点的组头** ──
* 这里原来是“先无条件渲染 `SentGroupHeader(g)`,单封组再叠一个 `SentRow`”。
* 于是一张卡的上半部(组头:别名/主题/N 封)**没有任何点击处理**,
* 只有下半部(`SentRow`:致 xxx/正文预览)能点。实测点 y=630
* (组头区)完全无反应 —— 而人点卡片时很难正好落在下半部。
*
* 更矛盾的是:`isFlatGroup` 的注释自己写着
* 「单封不成组:套一个可折叠的组头只是多一次点击」
* —— 而这里恰恰就给它套了组头,**注释与实现直接相反**。
*
* 收件箱(`InboxTab`,L593)一直是正确形状:
* if (isFlatGroup(g)) { this.MailRow(g.mails[0]) }
* else { 组头 + 子行 }
* ⇒ 与它取同形:**单封组只渲染 `SentRow`,不渲染组头**。
*
* ★ 两者取同形之后,上一轮补在 `SentRow` 自己身上的 `.onClick`
* 才真正覆盖整张卡(原来只覆盖了半张)。
*/
if (!isFlatGroup(g)) {
this.SentGroupHeader(g)
} else {
this.SentRow(g.mails[0])
}
}
@ -1590,122 +1651,106 @@ struct CommPage {
}, (key: string) => key)
}
/*
* ★★ 2026-09-20(用户:「顶部的收件箱发件箱和授权为什么不做成悬浮玻璃?」)
* ─────────────────────── 页签条造型:三次反转记在这里 ───────────────────────
*
* 这一行原来是 `backgroundColor(Theme.surface)` —— 系统卡片色,**实体**。
* 于是它是整页唯一一块实心白:上一轮把列表容器、卡片、底部导航条
* 都玻璃化了,唯独漏了这条页签条 ⇒ 壁纸从四周透出来,只有它在顶上白着一横条。
* ① **09-20 之前**:`backgroundColor(Theme.surface)`(系统卡片色,实心白)。
* 当时它是整页唯一一块实心白 —— 别的都玻璃化了、只有它白着一横条。
* 用户:「顶部的收件箱发件箱和授权为什么不做成悬浮玻璃?」
*
* ★★ 2026-09-24 **推翻上述方向**(见下面 `跟 webui 同步` 那段):
* 当时把它做成了“悬浮玻璃条”(材质 + 圆角),依据是“与底部条同一语汇”。
* 但底部条是**圆角浮条**(四周都是边),页签条是**贴顶通栏 44vp** ——
* 同一种系统材质在两种几何下表现不同:贴顶那条把材质自带的
* 方向性明暗暴露成了下缘一条暗带(用户:「顶栏莫名其妙的底部阴影」)。
* ⇒ 现在改成 WebUI 的形状:**无材质的通栏条 + 底边分隔线**。
* ② **09-20 照做**:改成**悬浮玻璃条**(`Theme.navMaterial` + 圆角 + 左右留白),
* 依据记在 `NavItems.ts`:
* 「底部导航条已经确立了这个语汇,顶部再做一个通栏的,
* 会在同一条轴线上出现两种不同的"条"」
* ★ 那个推理**漏了一件事**:底部条是**独立悬浮在内容之上**的(该是浮条),
* 而页签条是**列表栏的一部分** —— 两者不是同一类东西。
* 代价当场就来了:贴顶通栏 44vp 的几何把系统材质**自带的方向性明暗**
* 暴露成下缘一条暗带。用户:「顶栏莫名其妙的底部阴影」。
*
* 为什么"不做成实心白"仍然成立:WebUI 那条也是**透明的**
* (`.comm-pane` 给它 `transparent`,`index.css:1641`)——
* 它只是没有**材质**,不是有实心底。两者别混。
*/
/*
* 宽度:**与窗格齐平**(`100%`)。
* ③ **09-24 改回通栏**(用户并排看 WebUI/App 后:「通信页面 webui 和 app
* 存在很大的区别」+「去吧」)。WebUI 的真实形状(`App.tsx:214-216`):
* <div className="comm-pane relative flex-1 …">
* <CommTabs /> ← 「shrink-0 … px-3 border-b border-gray-200」
* {listBody}
* 页签条与列表**同属一个 `.comm-pane`**,它是那张卡顶上的**一条边**、
* 不是一块浮起来的板。
*
* 原来这里是 `calc(100% - 2×TAB_BAR_SIDE)`(左右各内缩 16vp)。
* 实测 WebUI:`.comm-pane` 与 `tabstrip` 的 `x`/`w` **完全相同**(都是 80/320)
* —— 它靠**窗格的 `overflow:hidden`** 把顶角裁圆,所以页签条自己直角、通栏。
* 我们内缩 + 自己带角 ⇒ 右端出现两道弧(用户:「你又在内部套了一个胶囊」)。
* ⇒ 改成与窗格齐平,右端只剩窗格那一道边。
*/
/*
* ★★ 2026-09-24 改为**照 WebUI 同步**(用户:「跟 webui 同步」,
* 上下文是他刚指出「顶栏莫名其妙的底部阴影」)。
* ── 现在的形状(逐条对齐 WebUI)──
* · **通栏**:`width('100%')`,无左右留白、无圆角
* (`TAB_BAR_SIDE`/`TAB_BAR_RADIUS` 已在 09-21 删除,理由见 `NavItems.ts`);
* · **无材质**:WebUI 的页签条没有 `backdrop-filter`,只有一条底边线;
* · **有底色**:`Theme.surface`(= WebUI 列表栏的 `bg-white`)。
* ★ 这与"不做成悬浮玻璃"**不冲突** —— 那是"材质 vs 实色",
* 这是"有底色 vs 透明"。WebUI `index.css:1648` 写得很明确:
* 「列表/详情的外框在壁纸模式下让位给"每项一张卡",
* 但**工具条与头部**仍要有底色,否则会直接压在壁纸上读不清」
* · **底边线**:`Theme.border`(WebUI 的 `border-b border-gray-200`)。
*
* ── 那条暗带是什么(像素实测,不是猜)──
* 详情页左栏 x=500 逐像素:
* y=142 lum=244 ↓ 单调递减(没有回弹)
* y=262 lum=225 ← 落差 19
* 对照证据:
* · 同一高度的**纯壁纸区**(x=210 / x=3170)恒为 210-212,**没有任何渐变**
* ⇒ 不是“壁纸透出来”;
* · 全仓 `.shadow(` 只有两处(`PaneModifier` 与登录页),页签条自己没有
* ⇒ 不是外部投影(我先前以为是,给它加了底边 —— **渐变照旧**,
* 那个诊断当场被推翻)。
* ⇒ 渐变来自**页签条自身的 `BlurStyle.COMPONENT_THICK`**:这类系统材质自带
* 方向性明暗(顶部亮、底部暗)用来表达“浮起的立体条”。底部导航条因为是
* **圆角浮条**(四周都是边)看不出,而页签条是**贴顶通栏 44vp**,
* 就把这个渐变直接暴露成下缘一条暗带。
*
* ── 为什么“对齐 WebUI”等于**去掉材质** ──
* WebUI 的页签条根本不是浮条,是**无材质的通栏条**:
* className="shrink-0 flex items-stretch gap-1 px-3 **border-b border-gray-200**"
* (`CommTabs.tsx:40`)。它自己的注释写了两条理由:
* ① 「与**列表头**同一套观感(同内边距、同下边框)」;
* ② 用户 2026-09-14:「通信页面的二级页面与其他位置极其割裂」——
* 之前那个白胶囊带 shadow 浮在面板上,看着是硬贴上去的另一套控件。
* ⇒ “悬浮玻璃页签条”是本仓 09-20 自己的发明(当时依据是“与底部条同一语汇”),
* 而底部条是圆角浮条、页签条是贴顶通栏 —— **同材质在两种几何下表现不同**,
* 这一点当时漏了。现在回到 WebUI 的形状。
*
* ── 同时去掉:圆角、以及上一轮为诊断加的底边 ──
* WebUI 里页签条是**唯一不圆角的那一个**(其余面板各自 `radius-card`,
* 见 `index.css:1371` 的 `.comm-pane > *:not([data-testid='comm-tabs'])`)。
* 底边则**保留 WebUI 本来就有**的那条(`border-b border-gray-200`)——
* 它不是为诊断加的,是 WebUI 的分界线,对应 `Theme.border`(同为分隔线语义)。
* ── 不随本次反转回退的那次修复 ──
* 页签条下方曾有一条**渐变暗带**。真凶不是页签条自己,而是 `InboxTab`
* 根容器的投影向上扩散压住了它(几何取证:页签条 y142→271、
* InboxTab y271→2202,观测到的渐变区 y240→268 完全吻合)。
* 修在 `InboxTab` 上(`PaneModifier.plain`),与本次造型反转无关,保留。
*/
.width('100%')
/*
* ★★ 2026-09-24 **必须有底色** —— 这是“顶栏底部阴影”的真正修因。
* ★★★ 2026-09-24 **改回通栏**(用户:「通信页面 webui 和 app 存在很大的区别」+「去吧」)。
*
* 上面那段说的是“不做成**实心白卡片**”,那是**实心 vs 玻璃**的区别;
* 而这里是另一个问题:**有底色 vs 透明**。
* 我把材质去掉之后写成了 `Color.Transparent`,于是页签条**直接压在壁纸上**
* —— 实测那条暗带仍然存在(x 250→1150 贯通、亮度恒 224-227,
* 且页签条内部从上到下 245→227 单调递减)。
* ── 这次只是**几何上的反转**,不是材质上的 ──
*
* ── WebUI 怎么做的(关键那段注释)──
* 它明确区分了“列表容器”与“工具条/头部”(`index.css:1648`):
* 09-20 用户问:「顶部的收件箱发件箱和授权为什么不做成悬浮玻璃?」
* 我把它做成了**带左右留白的浮条**(材质 + 圆角 + `TAB_BAR_SIDE`)。
* 用户 09-21 又连指三次,最后裁定
* 「你右边改成没圆角不就行了」「你又在内部套了一个胶囊」
* ⇒ 那次改的是**几何**:不再内缩、不再自己做满圆角,而与窗格齐平。
*
* 列表/详情的外框在壁纸模式下让位给“每项一张卡”,但**工具条与头部**
* 仍要有底色,否则会直接压在壁纸上读不清 —— 所以只把列表容器那一层放透明。
* ★ 玻璃这一半一直是保留的(用户 09-20 明确要的),判据
* `顶部页签条与底部导航条同一族` 钉的就是它。
* 我这次一度把它一并推翻(改成 `Theme.surface` 实心),那是把**几何反转误当成材质反转** ——
* 把用户当初点名要的东西删掉了,也正是这次「不够通透」的直接原因。
*
* 而页签条的父层级(`.comm-pane`)是 `bg-white`(`MailList.tsx:73`),
* 所以页签条背后**是白的**,不是壁纸。
* ⇒ "不计材质" 不等于 "透明"。我上一版把这二者搞成同一件事了。
* ── WebUI 的真实结构(几何 + 材质的完整解释)──
* 底部导航条是**独立悬浮在内容之上**的(它确实该是浮条);
* 而页签条是**列表栏顶上的那条边** —— WebUI 里 `CommTabs` 与 `MailList`
* 同属 `.comm-pane`(`App.tsx:214-216`):
* <div className="comm-pane relative flex-1 …">
* <CommTabs /> ← className="shrink-0 … px-3 border-b border-gray-200"
* {listBody} ← 自己带 .glass-card 头部
* 它**自己无背景**,透出的是底下那张玻璃卡 —— 这才是它"通透"的来源。
* 所以:几何上它随窗格(通栏、只左上圆角),材质上它仍是玻璃族。
*
* `Theme.surface` = `ohos_id_color_list_card_bg`(浅色白/深色深灰,
* 自动跟随主题)—— 它就是 WebUI `--c-white`(`255 255 255` / 深色 `24 27 33`)
* 的对应物。用令牌而不是手写白:深色主题才改得动。
* ── 按 WebUI 对齐后的形状 ──
* · **通栏**:`width('100%')`,无左右留白;
* · **无实心面**、**吃系统玻璃**:与底部导航条同档(`Theme.navMaterial`);
* · **只给左上圆角**(右上 0):与窗格左上角那道弧重合;
* · **底边线**:`Theme.border`(WebUI 的 `border-b border-gray-200`)。
*
* ── ★★★ 2026-09-24 再纠一次(同一个错我前后犯了两次)──
*
* 我上一版把"无背景"实现成了 `backgroundColor(Theme.surface)`,
* 理由是「WebUI 列表栏是 `bg-white`」—— **那个引用是错的**。
* WebUI 的真实结构:
* .comm-pane ← 一张 `.glass-card`(半透明白,浅色 0.92 / 壁纸下 0.78)
* └─ CommTabs ← `shrink-0 … px-3 border-b border-gray-200`,**自己无背景**
* 页签条**自己不带底色**,透出的是底下那张玻璃卡。
*
* 给它铺 `Theme.surface`(系统实心卡片色)= 把那张玻璃卡换成一横条实心白
* ⇒ **壁纸再也透不过来** —— 这正是用户这次说的
* 「通信页面我觉得没有 webui 那么通透」。
*
* 判据 `harmony-nav.test.mjs:732`「顶部页签条与底部导航条同一族」
* 当场判红(“一块实心白就是割裂”)—— 它盯的形状与我这次犯的**一模一样**。
* 教训:判纪引的出处不能只看“像”,要看那个类的**真实层叠位置**。
*
* ── 顺带保留的那次修复(它是 bug,与本反转无关)──
* 页签条下方曾有一条**渐变暗带**。真凶不是页签条自己,而是
* `InboxTab` 根容器的投影向上扩散压住了它(几何取证:页签条 y142→271、
* InboxTab y271→2202,观测到的渐变区 y240→268 完全吻合)。
* 那条修在 `InboxTab` 上(`PaneModifier.plain`),**不随本次反转回退**。
*/
/*
* ★★ 2026-09-24 回到**悬浮玻璃**(09-20 用户点名要的),只补一条底边。
*
* 我在这一轮中途曾把它改成 `Theme.surface + 无圆角 + 底边`,理由是
* 「跟 WebUI 同步」—— 那是**读错了**:用户当时是配着 WebUI 整体布局截图,
* 指的是页面结构对齐;而他自己 09-20 已经明确定过页签条要做成悬浮玻璃
* (`harmony-nav.test.mjs` 那条判据把原话与理由都记着):
* 「顶部的收件箱发件箱和授权为什么不做成悬浮玻璃?」
* 两者不冲突:玻璃是**观感**选择,而那条阴影是**bug**。
*
* ── 阴影的真凶不是页签条自己 ──
* 几何取证:页签条(y 142→271)与 `InboxTab` 根容器(y 271→2202)是
* `Column` 里的**兄弟**且后者**绘制在后**,它的 `PaneModifier` 投影向上扩散
* ~30px ⇒ 压住页签条(观测到的渐变区 y 240→268,完全吻合)。
* 只把 `InboxTab` 换成 `PaneModifier.plain`(不投投影)实测落差 21→8,
* 而页签条的玻璃观感一字未动。
*
* 底边仍保留:WebUI 的页签条本来就有 `border-b border-gray-200`,
* 而且它让玻璃条与下方可滚内容有一条清晰分界(`Theme.border` = 同一个分隔线语义)。
*/
.backgroundColor(Color.Transparent)
.width('100%')
/* 玻璃:与底部导航条同一族(判据 `顶部页签条与底部导航条同一族` 钉的正是这条)。
壁纸关着(`bgActive=false`)时退成 `BlurStyle.NONE`,与底部条同一取舍。 */
.backgroundBlurStyle(this.bgActive ? Theme.navMaterial : BlurStyle.NONE)
/*
* ★ 只给**左上角**圆角,右上角不要(用户:「鸿蒙布局还略有不同的,
* 你直接改成右边没圆角就行了」)。
*
* 右上角的弧与窗格右上角的弧**重叠成两道**(页签条一道、窗格一道),
* 中间那弯月牙形的空玻璃就是"奇怪"的来源。
* 去掉右边那一角,右端就只剩窗格自己那一道边。
*/
.borderRadius({ topLeft: Theme.glassRadius, topRight: 0 })
.border({ width: { bottom: 1 }, color: Theme.border })
}
@ -2043,6 +2088,18 @@ struct MainPage {
* 而账号列表只有主框架持有(与徽标走同一套"父算子读")。
*/
@State accountLabel: string = '';
/**
* App 级 SSE 监听(`aboutToAppear` 注册 / `aboutToDisappear` 摧掉)。
*
* ★★ 2026-09-24:原来这个监听在 `InboxTab` 里 —— 但那个组件是**条件挂载**的,
* 切到发件箱/授权就被销毁,监听跟着没 ⇒ 在那两栏时收不到新邮件。
* 搬到 `MainPage`(`@Entry`,全程在)。
*
* ★ 保存同一个函数引用(`onGlobalSseEvent`)才能摧掉:
* `SseService.removeListener` 用 `indexOf` 比对引用,
* 传一个新写的箭头函数是摧不掉的(本仓 `ComposeIntent.clearListener` 同类坑)。
*/
private sseService: SseService | null = null;
/** 当前是否深色(侧栏主题按钮的图标);由 `applyAppearance` 算出来 */
@State isDarkNow: boolean = false;
/**
@ -2132,15 +2189,84 @@ struct MainPage {
private gridCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.gridSettings);
aboutToAppear(): void {
// 主界面也要应用外观:只在设置页生效的话"一进主界面就变回去"(WebUI 侧踩过)
this.applyAppearance();
this.watchEnvironment();
/*
* ★★ 2026-09-24:**把 SSE 监听提到 App 级**(用户:「鸿蒙 app 接收邮件的能力也有点不正常」)。
*
* ── 原来错在哪 ──
* 监听原来挂在 `InboxTab.aboutToAppear`(`this.sseService.addListener`),
* 而 `CommPage` 里三个 tab 是 `if/else` **条件挂载**的:
* 用户切到「发件箱」或「授权」时 `InboxTab` 被销毁 → `aboutToDisappear`
* 就把监听摧掉了 ⇒ **在那两栏时收不到任何新邮件通知**,
* 切回收件箱也不会补(它只在挂载时拉一次)。
*
* `MainPage` 才是真正的常驻组件(`@Entry`),所以监听放这里。
*
* ── 与 WebUI 对齐 ──
* WebUI 的 `connectSSE` 注册在 **App 根组件**(全程在),
* 不是某个页面(`client/electron/src/api/sse.ts`);事件到达后叫 store 重拉。
* 同一个形状。
*
* ── 不在这里直接 `loadInbox` ──
* `loadInbox` 需要 `ctx` + 当前 `accountFilter`,那是**窗格**的状态。
* 所以只调 `notifyRemoteChange()` 广播修订号,由挂着的窗格
* (`@Watch('onMailRevChanged')`)自己拿手上的参数去拉。
*/
this.sseService = SseService.getInstance();
this.sseService.addListener(this.onGlobalSseEvent);
}
aboutToDisappear(): void {
this.unwatchEnvironment();
if (this.sseService !== null) {
this.sseService.removeListener(this.onGlobalSseEvent);
}
}
/**
* App 级的 SSE 事件处理(所有账号、所有栏都只有一个入口)。
*
* ★ 事件名与服务端逐字对应(`client/electron/src/api/sse.ts:7-13` 的 `EVENTS`):
* `new_mail` / `permission_decision` / `session_update` /
* `session_archived` / `agent_online`。
*
* 前四个都会影响列表内容 ⇒ 都是"重拉"的信号。
* `agent_online` 只影响在线状态展示(侧栏),不动邮件列表。
*
* ★ 本仓失败的形状之一是“写了一个 handler 但只处理其中一种事件” ——
* 原先 `InboxTab` 那个只看了 `new_mail`,而服务端在权限决策后发的是
* `session_update`(`permission.go:387`)与 `new_mail`(`permission.go:265`),
* 于是"授权栏里处理过的申请,收件箱还是旧的样子"。
*/
private onGlobalSseEvent = (event: SseEvent): void => {
if (event.type === 'new_mail') {
/*
* 新邮件 toast —— 原来在 `InboxTab` 里(会拿 `AccountManager` 查显示名)。
* 搬到 App 级后取不到那个窗格的 `accountList` 了,所以取**活跃账号**的显示名。
* 多账号下“哪个账号来的”确实会不准 —— 但事件里只带 `accountId`,
* 不查库是不知道名字的;宁可只说“新邮件到达”也不拿错的账号名骗人。
*/
let sourceName: string = '';
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
const active: AccountInfo | null = AccountManager.getInstance(ctx).getActiveAccount();
if (active !== null && active.id === event.accountId) {
sourceName = active.displayName;
}
}
this.getUIContext().getPromptAction().showToast({
message: sourceName.length > 0 ? '新邮件:' + sourceName : '新邮件到达'
});
MailStore.getInstance().notifyRemoteChange();
return;
}
if (event.type === 'permission_decision' || event.type === 'session_update'
|| event.type === 'session_archived') {
MailStore.getInstance().notifyRemoteChange();
}
};
/**
* 算一遍「内容末尾要让多少位」——**唯一**的算法,两个触发点共用。
*