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

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

① 「邮件点进去自动已读的能力不正常」
   根因:鸿蒙只有「标记已读」按钮(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 报过两次)——钉的是一整套东西的两半: