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

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

① 「邮件点进去自动已读的能力不正常」
   根因:鸿蒙只有「标记已读」按钮(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

@ -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();
}
};
/**
* 算一遍「内容末尾要让多少位」——**唯一**的算法,两个触发点共用。
*