跨端: 鸿蒙修收信/自动已读/发件箱点开 + 页签条通透(用户报的四个问题)
用户报了四个问题,逐个实测复现 → 定位根因 → 修 → 设备复验:
① 「邮件点进去自动已读的能力不正常」
根因:鸿蒙只有「标记已读」按钮(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:
@ -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} 要自己吞异常 —— 缓存坏了不能把取数一起拖红`);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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 报过两次)——钉的是一整套东西的两半:
|
||||
|
||||
Reference in New Issue
Block a user