From 187609480f19ffa6adc9599dad7859269f71c8dc Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 24 Sep 2026 16:34:44 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E9=B8=BF=E8=92=99?= =?UTF-8?q?=E4=BF=AE=E6=94=B6=E4=BF=A1/=E8=87=AA=E5=8A=A8=E5=B7=B2?= =?UTF-8?q?=E8=AF=BB/=E5=8F=91=E4=BB=B6=E7=AE=B1=E7=82=B9=E5=BC=80=20+=20?= =?UTF-8?q?=E9=A1=B5=E7=AD=BE=E6=9D=A1=E9=80=9A=E9=80=8F=EF=BC=88=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=8A=A5=E7=9A=84=E5=9B=9B=E4=B8=AA=E9=97=AE=E9=A2=98?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报了四个问题,逐个实测复现 → 定位根因 → 修 → 设备复验: ① 「邮件点进去自动已读的能力不正常」 根因:鸿蒙只有「标记已读」按钮(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 封)。 - 发件箱点开:点原先点不动的卡片上半区 ⇒ 右栏出正文 + 蓝色选中态。 - 页签条:截图确认为玻璃通透(不再是实心白横条)。 --- client/electron/test/harmony-logic.test.mjs | 172 +++++++- client/electron/test/run-all.mjs | 6 +- .../entry/src/main/ets/api/SseService.ets | 44 +- .../entry/src/main/ets/common/MailStore.ets | 345 +++++++++++++++ .../src/main/ets/pages/MailDetailPage.ets | 61 +++ .../entry/src/main/ets/pages/MainPage.ets | 392 ++++++++++++------ 6 files changed, 873 insertions(+), 147 deletions(-) diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index 929c1c5..71b438f 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -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} 要自己吞异常 —— 缓存坏了不能把取数一起拖红`); + } +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 860bf66..fcdd680 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -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 报过两次)——钉的是一整套东西的两半: diff --git a/client/harmony/entry/src/main/ets/api/SseService.ets b/client/harmony/entry/src/main/ets/api/SseService.ets index 3290062..6396f9e 100644 --- a/client/harmony/entry/src/main/ets/api/SseService.ets +++ b/client/harmony/entry/src/main/ets/api/SseService.ets @@ -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 = {}; @@ -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 }); } } \ No newline at end of file diff --git a/client/harmony/entry/src/main/ets/common/MailStore.ets b/client/harmony/entry/src/main/ets/common/MailStore.ets index e6a3964..5b74669 100644 --- a/client/harmony/entry/src/main/ets/common/MailStore.ets +++ b/client/harmony/entry/src/main/ets/common/MailStore.ets @@ -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(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; + 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 { 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 : '加载失败'; diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index 8d2f2f1..aeea636 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -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 }); diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 03a7b2f..099a581 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -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`)只有三个东西: + * ① `

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