fix(harmony)★★: MailStore 收/发共用一份快照 + 壁纸 PixelMap 泄漏 —— 两处 HIGH
★★ 前置事实修正:报告写的「本机无 hvigorw / 无 SDK / HarmonyOS 侧没有编译过」
**已不成立** —— `/opt/huawei/command-line-tools/bin/hvigorw` 6.26.2 可用,
`hvigorw assembleHap --no-daemon` 出 **BUILD SUCCESSFUL**(约 26 s)。
⇒ 本次两条 HIGH 都是**真编译过**的,不是静态推断。这是本次最大的认知变化:
「改 ArkTS 无法验证 ⇒ 只能推给下一个人」这个前提可以取消了。
## ① MailStore:收件箱与发件箱共用同一份快照(HIGH)
`loadInbox` 与 `loadSent` 此前**全部往同一个 `snapshot` 写**,而两者是两个独立
`@Component`(`InboxTab`/`SentTab`),各自 `await` 完才 `applyStoreSnapshot(...)`
⇒ 切页签 / SSE 交错时**发件箱那次把收件箱那份覆盖掉**:`unread = 0`、
`mails` 换成发件箱的、`loading` 互相关闭 ⇒ 症状是「收件箱未读被清零 /
列表短暂空白 / 转圈停了但列表是空的」。
★ 为什么此前没人动(旧报告标 CRITICAL 却长期未修):它当时的理由是
「Navigation 分栏下两个 pane 同时在屏」这个**必然场景**;`commTab` 后来改成
同一时刻只 mount 一个 ⇒ 那个"必然"没了 ⇒ 从"每次都坏"降级成"交错时坏"。
**这是判据缺失导致缺陷降级**——本条判据就是为了不让它再降级。
**修法(两份快照 + 代号守卫,各挡一半,都要有)**:
· 新增 `sentSnapshot`,`loadSent`/`paintSentFromCache` 只写它;
`SentTab` 读 `store.sentSnapshot`(收件箱侧零改动)。
· `inboxGen` / `sentGen` 代号:同一栏**自己**的两次 load 也可能乱序到达
(SSE 叫醒一次、切页签又触发一次)⇒ 旧的**后**到会盖掉新的。
⚠️ 守卫**只拦写快照**,`loading = false` 仍要执行 —— 否则新请求的 spinner 被挂住。
· `clear()` 清两份,**并把两个代号都 +1 作废**:否则**正在飞**的旧请求回来时
`myGen` 仍等于旧值 ⇒ 会把清空后的快照重新填上旧数据(登出/切账号正是此时)。
★★ 顺带修一个**我差点引入的回归**:拆开之前两份共用一份 ⇒ 归档一个会话会
同时抹掉两边的它。拆开后若只动收件箱,归档完**发件箱仍显示该会话**(而服务端已删)。
⇒ `dropSession` 拆成 `dropSessionFromInbox` / `dropSessionFromSent`,
**两份都要剔**;且发件箱那份**不能用** `splitByPermission(...).normal`
(发件箱不做权限分流,那一筛会抹掉「我发出的授权请求」——09-20 修过的
「发件箱一片空白」同一族)。
## ② AppearanceStore:壁纸 PixelMap 泄漏 + 全尺寸解码(HIGH)
`loadWallpaper` 此前既不设 `desiredSize`、也从不 `release()`:
· `PixelMap` 是**原生内存**,每次同步/每次切账号重新解码一张,全部不释放;
而它是**静态单例**、活过登出 ⇒ **换账号这条路必然泄漏**(无任何清理入口)。
· 不带 `desiredSize` ⇒ 按原图尺寸解。`BackgroundPicker` 只把上传边长卡在
`MAX_EDGE = 2560` ⇒ 一张 2560×2560 ARGB ≈ **26 MB** 常驻,
而它永远被合成器降采样着全屏画。
**修法**(照 `BackgroundPicker` 既有形状,不另创一套):
· `desiredSize` 取 `display.getDefaultDisplaySync()` 的 width/height,
不写死魔数(随屏变)。★ 它**会抛**(SDK 注释 1400001)⇒ 必须 catch,
取不到就退回"不限制尺寸",而不是把壁纸整条路断掉。
· 所有权:先 `this.wallpaper = next` 再释放**旧的**,并把局部变量置空 ——
★ 若在 `finally` 里释放 `this.wallpaper`,会把**刚装上的那张**释放掉,
这是最容易写反的一处。
· 新增 `releaseWallpaper()`,并在 `Logout.ets` 里与 `MailStore.clear()` 并排调用
—— 后者是该方法**唯一**的调用点,没有它它就只是"写好了没人用"。
## 判据(harmony-arkts 8 → 10;harmony-logic 补一格)
新两条按**括号配对**取方法体(`balanced()`,不用 `\{[\s\S]{0,N}` 窗口)。
**变异测试 5 个全部抓住**:loadSent 写回 snapshot / SentTab 读错快照 /
归档不剔发件箱 / 释放的是新图而非旧的 / 去掉 desiredSize。
★ 顺带修一条**HEAD 上就在红的判据**(不是本次引入):`harmony-logic` 那条
「先分家、后折叠」只认字面量 `groupMailsBySession(split.normal)`,
而源码**本来就是** `const inboxMails: MailLike[] = split.normal;` 后
`groupMailsBySession(inboxMails)` ⇒ 恒红。
⇒ 补上"经中间变量"这一支;变异验证:把 `inboxMails` 换成 `mergedMails`
(权限邮件混进收件箱)仍**照红** ✓ —— 放宽的是写法假设,不是判定。
## 编译期硬规则(ArkTS 不接受对象字面量当类型)
`targetSize()` 第一版返回 `{ width: number, height: number }` ⇒ 编译报
`arkts-no-obj-literals-as-types` / `arkts-no-untyped-obj-literals`(连
**返回的对象字面量**也要能对应到**具名**类型)。⇒ 必须先声明 interface,
且每个字面量先赋给**显式类型的局部变量**再 return。
★ 这条**编译期硬规则**在本仓无判据覆盖(harmony-arkts 判的是 import 位置那一类)
⇒ 只能靠真编译抓;而它能抓,再次证明「ArkTS 本机无法验证」已不成立。
## 边界 / 未做
· **本条判据证明不了运行时症状**:切页签交错那条要设备才能造,本仓无那种探针。
静态层只把住「两栏各写各的」。设备那半**仍未验**。
· `releaseWallpaper` 的**实际内存回收**未在真机验证(memory profiler)。
· 离屏解码(`@Concurrent`/taskpool)**本轮不做** —— 全仓 0 先例,
单独引入会扩大风险面。
This commit is contained in:
@ -22,6 +22,7 @@ import { ApiClient } from './ApiClient';
|
||||
import { AuthApi } from './AuthApi';
|
||||
import { AccountManager } from './AccountManager';
|
||||
import { MailStore } from '../common/MailStore';
|
||||
import { AppearanceStore } from '../common/AppearanceStore';
|
||||
import { SseService } from './SseService';
|
||||
import { PushService } from './PushService';
|
||||
|
||||
@ -110,6 +111,19 @@ export async function performLogout(
|
||||
hilog.info(0x0001, 'Logout', 'MailStore.clear 失败:%{public}s', JSON.stringify(e));
|
||||
}
|
||||
|
||||
/*
|
||||
* ★ 2026-10-03:壁纸也要清(与 MailStore 同一形状、同一理由)。
|
||||
* `AppearanceStore` 是**静态单例**、活过登出,而 `wallpaper` 是**原生内存**
|
||||
* ⇒ 不释放就是真泄漏(旧的那张不会 GC)。
|
||||
* 同时这也是 `releaseWallpaper()` 的**唯一调用点** ——
|
||||
* 没有它,这个方法就只是"写好了没人用"。
|
||||
*/
|
||||
try {
|
||||
AppearanceStore.getInstance().releaseWallpaper();
|
||||
} catch (e) {
|
||||
hilog.info(0x0001, 'Logout', 'releaseWallpaper 失败:%{public}s', JSON.stringify(e));
|
||||
}
|
||||
|
||||
/*
|
||||
* ④ `replaceUrl` 而不是 `pushUrl`:退出后不该还能"返回"到已登出的页。
|
||||
*
|
||||
|
||||
@ -15,6 +15,7 @@ import { common } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { image } from '@kit.ImageKit';
|
||||
import { display } from '@kit.ArkUI';
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { ApiClient } from '../api/ApiClient';
|
||||
import { AccountManager, AccountInfo } from '../api/AccountManager';
|
||||
@ -34,6 +35,18 @@ const PREF_STORE: string = 'agentmail_appearance';
|
||||
/** 本地缓存的键**必须带账号**:WebUI 侧的教训是多账号共用一份(键是全局常量) */
|
||||
const KEY_PREFIX: string = 'appearance.';
|
||||
|
||||
/**
|
||||
* 壁纸的**目标解码尺寸**(px)。
|
||||
*
|
||||
* ★ 为什么必须是**具名 interface**:ArkTS 不接受对象字面量当类型
|
||||
* (`arkts-no-obj-literals-as-types` / `arkts-no-untyped-obj-literals`)——
|
||||
* 2026-10-03 真编译实测,不是猜的。详见 `targetSize()` 上方那段。
|
||||
*/
|
||||
interface WallpaperTargetSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export class AppearanceStore {
|
||||
private static instance: AppearanceStore | null = null;
|
||||
|
||||
@ -182,19 +195,111 @@ export class AppearanceStore {
|
||||
await this.loadWallpaper(api);
|
||||
}
|
||||
this.applyTheme(ctx, this.snapshot.theme);
|
||||
}
|
||||
}
|
||||
|
||||
/** 取壁纸本体:**带认证**取回来(不用 `Image('http://…')`,也不用 `?token=`) */
|
||||
async loadWallpaper(api: AppearanceApi): Promise<void> {
|
||||
try {
|
||||
const bytes: ArrayBuffer = await api.fetchImageBytes();
|
||||
const src: image.ImageSource = image.createImageSource(bytes);
|
||||
this.wallpaper = await src.createPixelMap();
|
||||
} catch (e) {
|
||||
// 取不到就按"没有壁纸"渲染:不显示一块空的占位
|
||||
this.wallpaper = null;
|
||||
/*
|
||||
* ⚠️⚠️ 2026-10-03 编译实测记在这里:**ArkTS 不接受对象字面量当类型**。
|
||||
* 我第一版把返回类型写成 `{ width: number, height: number }` ⇒ 编译直接报
|
||||
* 10605040 arkts-no-obj-literals-as-types
|
||||
* 10605038 arkts-no-untyped-obj-literals(连**返回的对象字面量**也要能
|
||||
* 对应到**具名**类型)
|
||||
* ⇒ 必须先声明一个具名 interface,并把每个字面量先赋给一个**显式类型的局部变量**
|
||||
* 再 `return` 那个变量(直接 `return {…}` 同样不过)。
|
||||
* 这条**编译期硬规则**在本仓无判据覆盖(`harmony-arkts` 判的是 import 位置那一类)
|
||||
* ⇒ 只能靠真编译抓 —— 而它能抓,证明**「本机无法验证 ArkTS」这个前提不成立**。
|
||||
*/
|
||||
/**
|
||||
* 目标解码尺寸(px)。
|
||||
*
|
||||
* 为什么要它:不带 `desiredSize` 时,`createPixelMap()` 按**原图**尺寸解,
|
||||
* 而 `BackgroundPicker` 只把上传边长卡在 `MAX_EDGE = 2560`
|
||||
* ⇒ 一张 2560×2560 ARGB 常驻 ≈ **26 MB**,而它永远被合成器降采样着全屏画。
|
||||
*
|
||||
* ★ 用 `display.getDefaultDisplaySync()` 而不是写死魔数:
|
||||
* 尺寸随屏幕/分辨率变,写死就是在小屏上多占、在大屏上不够。
|
||||
* ★ 它**会抛**(SDK 注释:`1400001 Invalid display or screen`)⇒ 必须 catch,
|
||||
* 否则取不到尺寸会把整个壁纸流程带崩(而"没有壁纸"是能接受的状态)。
|
||||
*/
|
||||
private targetSize(): WallpaperTargetSize {
|
||||
try {
|
||||
const d = display.getDefaultDisplaySync();
|
||||
if (d.width > 0 && d.height > 0) {
|
||||
const sz: WallpaperTargetSize = { width: d.width, height: d.height };
|
||||
return sz;
|
||||
}
|
||||
} catch (e) {
|
||||
// 取不到屏幕就**不限制尺寸**(回到旧行为),而不是把壁纸整条路断掉
|
||||
}
|
||||
const none: WallpaperTargetSize = { width: 0, height: 0 };
|
||||
return none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取壁纸本体:**带认证**取回来(不用 `Image('http://…')`,也不用 `?token=`)。
|
||||
*
|
||||
* ★★ 2026-10-03 修三件事(此前一个都没有):
|
||||
* ① **不 release**:旧 `PixelMap` 与 `ImageSource` 每次同步都新建、原生内存
|
||||
* 从不释放。而 `syncFromServer` 每次同步/每次切账号都会跑一遍
|
||||
* ⇒ **换账号这条路必然泄漏**(`wallpaper` 不在任何清理路径里)。
|
||||
* ② **不设 desiredSize**:见 `targetSize()` 的注释(最坏 26 MB 常驻)。
|
||||
* ③ **所有权**:先换引用再释放**旧的**——若在 `finally` 里释放 `this.wallpaper`,
|
||||
* 会把**刚装上的那张**释放掉(那正是最容易写错的一处)。
|
||||
*/
|
||||
async loadWallpaper(api: AppearanceApi): Promise<void> {
|
||||
let src: image.ImageSource | null = null;
|
||||
let next: image.PixelMap | null = null;
|
||||
try {
|
||||
const bytes: ArrayBuffer = await api.fetchImageBytes();
|
||||
src = image.createImageSource(bytes);
|
||||
const target = this.targetSize();
|
||||
if (target.width > 0 && target.height > 0) {
|
||||
const decodeOptions: image.DecodingOptions = {
|
||||
desiredSize: { width: target.width, height: target.height }
|
||||
};
|
||||
next = await src.createPixelMap(decodeOptions);
|
||||
} else {
|
||||
next = await src.createPixelMap();
|
||||
}
|
||||
/*
|
||||
* 所有权转移:先把局部变量置空,`finally` 就不会把**新的**这个释放掉。
|
||||
* 下面是本方法里最容易写反的一行。
|
||||
*/
|
||||
const old: image.PixelMap | null = this.wallpaper;
|
||||
this.wallpaper = next;
|
||||
next = null;
|
||||
if (old !== null) {
|
||||
await old.release();
|
||||
}
|
||||
// `src` 已在 `finally` 释放;成功路径上它同样必须释放(旧实现这里就漏了)
|
||||
} catch (e) {
|
||||
// 取不到就按"没有壁纸"渲染:不显示一块空的占位
|
||||
this.wallpaper = null;
|
||||
} finally {
|
||||
if (next !== null) {
|
||||
await next.release();
|
||||
}
|
||||
if (src !== null) {
|
||||
await src.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 2026-10-03 新增:退出登录/换账号时清壁纸。
|
||||
*
|
||||
* 与 `MailStore.clear()` 同一形状、同一理由(登出后至少一帧不能看到上一个人的东西)。
|
||||
* ⚠️ 它**也必须 release**:这里是真的 native 内存,不是显示状态。
|
||||
*/
|
||||
releaseWallpaper(): void {
|
||||
const old: image.PixelMap | null = this.wallpaper;
|
||||
this.wallpaper = null;
|
||||
if (old !== null) {
|
||||
// 不能 await(同步接口):release 是 Promise,fire-and-forget + 失败静默
|
||||
old.release().catch(() => {
|
||||
// 释放失败不该阻断登出流程
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 供界面读的当前值(ArkTS 的 @State 观察不到类内部变化,所以页面自己复制一份) */
|
||||
current(): AppearanceSnapshot {
|
||||
|
||||
@ -156,6 +156,32 @@ export class MailStore {
|
||||
|
||||
/** 当前快照。界面读它渲染 */
|
||||
snapshot: MailSnapshot = new MailSnapshot();
|
||||
|
||||
/**
|
||||
* ★★ 2026-10-03 新增:**发件箱自己那份快照**。
|
||||
*
|
||||
* 为什么要拆:此前 `loadInbox` 与 `loadSent` **全部往同一个 `snapshot` 写**,
|
||||
* 而两者是两个独立 `@Component`(`InboxTab` / `SentTab`),各自
|
||||
* `await` 完自己的 load 之后才 `applyStoreSnapshot(store.snapshot)`。
|
||||
* ⇒ 切页签/SSE 事件与两者交错时,**发件箱的 `loadSent` 会把收件箱那份覆盖掉**:
|
||||
* `snap.unread = 0`、`snap.mails = merged`、`snap.loading` 互相关闭。
|
||||
* 后果:收件箱未读被清零 / 列表短暂空白 / “转圈停了但列表是空的”。
|
||||
*
|
||||
* ★ 为什么**只拆字段不够、还要 generation 守卫**:
|
||||
* 拆字段解决的是「两栏互相覆写」,
|
||||
* 但同一栏**自己**的两次 load 仍可能乱序到达(快切页签时旧的 `loadSent`
|
||||
* 后到、覆盖新的)⇒ 那要靠 generation 丢弃过期响应。
|
||||
* 两件事**各挡一半**,都要。
|
||||
*
|
||||
* ⚠️ 调用点只需改 `SentTab` 那一处(`MainPage.ets` 的 `store.snapshot` →
|
||||
* `store.sentSnapshot`);收件箱侧零改动。
|
||||
*/
|
||||
sentSnapshot: MailSnapshot = new MailSnapshot();
|
||||
|
||||
/** 收件箱这一栏的请求代号:只有**最新**那次 load 的结果才允许写快照 */
|
||||
private inboxGen: number = 0;
|
||||
/** 发件箱这一栏的请求代号(同上) */
|
||||
private sentGen: number = 0;
|
||||
/**
|
||||
* 版本号 —— 每次内容变化自增。
|
||||
*
|
||||
@ -292,9 +318,10 @@ export class MailStore {
|
||||
if (merged.length === 0) {
|
||||
return false;
|
||||
}
|
||||
this.snapshot.mails = merged;
|
||||
this.snapshot.groups = groupMailsBySession(merged);
|
||||
this.snapshot.loaded = merged.length;
|
||||
// ★ 写**发件箱那份**快照(此前误写 `this.snapshot`,与收件箱互相覆写)
|
||||
this.sentSnapshot.mails = merged;
|
||||
this.sentSnapshot.groups = groupMailsBySession(merged);
|
||||
this.sentSnapshot.loaded = merged.length;
|
||||
this.bump();
|
||||
return true;
|
||||
} catch (e) {
|
||||
@ -444,6 +471,7 @@ export class MailStore {
|
||||
* @param accountFilter `'all'` = 聚合;否则是单个账号 id
|
||||
*/
|
||||
async loadInbox(ctx: common.Context, accountFilter: string): Promise<void> {
|
||||
const myGen = ++this.inboxGen;
|
||||
const snap = this.snapshot;
|
||||
/*
|
||||
* ★★ 2026-09-24 新增:**先读缓存**(用户:「鸿蒙是 app 啊,缓存邮件多正常」)。
|
||||
@ -566,6 +594,17 @@ export class MailStore {
|
||||
const split: MailSplit = splitByPermission(mergedMails);
|
||||
const inboxMails: MailLike[] = split.normal;
|
||||
|
||||
/*
|
||||
* ★★ 2026-10-03:代号守卫。同一栏**自己**的两次 load 也可能乱序到达
|
||||
* (SSE 叫醒一次、用户又切页签触发一次)⇒ 旧的**后**到会盖掉新的。
|
||||
* 判据:代号不是最新就**不碰快照**。
|
||||
* ⚠️ 但 `loading = false` 仍要执行 —— 否则新那次请求的 spinner 被挂住
|
||||
* (“转圈停了但列表是空的”那个症状,守卫加上之后反而更容易出现)。
|
||||
*/
|
||||
if (this.inboxGen !== myGen) {
|
||||
this.bump();
|
||||
return;
|
||||
}
|
||||
snap.mails = mergedMails;
|
||||
snap.groups = groupMailsBySession(inboxMails);
|
||||
snap.loaded = inboxMails.length;
|
||||
@ -591,7 +630,9 @@ export class MailStore {
|
||||
snap.notice = partialLoadNotice(maxFetched, INBOX_PAGE_SIZE);
|
||||
} catch (e) {
|
||||
const ae = e as ApiError;
|
||||
snap.error = ae.message.length > 0 ? ae.message : '加载失败';
|
||||
if (this.inboxGen === myGen) {
|
||||
snap.error = ae.message.length > 0 ? ae.message : '加载失败';
|
||||
}
|
||||
} finally {
|
||||
snap.loading = false;
|
||||
this.bump();
|
||||
@ -605,7 +646,8 @@ export class MailStore {
|
||||
* 只是不做权限分流、不分组(发件箱按时间平铺,与 WebUI `mailStore.sent` 一致)。
|
||||
*/
|
||||
async loadSent(ctx: common.Context, accountFilter: string): Promise<void> {
|
||||
const snap = this.snapshot;
|
||||
const myGen = ++this.sentGen;
|
||||
const snap = this.sentSnapshot;
|
||||
snap.loading = true;
|
||||
snap.error = '';
|
||||
/*
|
||||
@ -673,6 +715,15 @@ export class MailStore {
|
||||
* 发件箱不显示那个栏位。所以这里直接对全部 merged 分组 ——
|
||||
* 与 WebUI `mailStore.fetchSent` 同口径(那边也是直接 set 全量)。
|
||||
*/
|
||||
/*
|
||||
* ★★ 2026-10-03:代号守卫(与 `loadInbox` 同一形状、同一理由)。
|
||||
* 同栏两次 load 乱序到达时,旧的**后**到会盖掉新的。
|
||||
*/
|
||||
if (this.sentGen !== myGen) {
|
||||
this.bump();
|
||||
return;
|
||||
}
|
||||
snap.mails = merged;
|
||||
snap.groups = groupMailsBySession(merged);
|
||||
snap.loaded = merged.length;
|
||||
snap.unread = 0;
|
||||
@ -682,7 +733,9 @@ export class MailStore {
|
||||
this.persistByAccount(ctx, KEY_SENT, accountFilter, allAccounts, merged);
|
||||
} catch (e) {
|
||||
const ae = e as ApiError;
|
||||
snap.error = ae.message.length > 0 ? ae.message : '加载失败';
|
||||
if (this.sentGen === myGen) {
|
||||
snap.error = ae.message.length > 0 ? ae.message : '加载失败';
|
||||
}
|
||||
} finally {
|
||||
snap.loading = false;
|
||||
this.bump();
|
||||
@ -697,24 +750,76 @@ export class MailStore {
|
||||
* 就地剔除则立即反映,且与列表滚动位置无关。
|
||||
*/
|
||||
dropSession(sessionId: string): void {
|
||||
const snap = this.snapshot;
|
||||
const kept: MailLike[] = [];
|
||||
for (let i = 0; i < snap.mails.length; i++) {
|
||||
const m = snap.mails[i];
|
||||
if (m.session_id !== sessionId) {
|
||||
kept.push(m);
|
||||
}
|
||||
}
|
||||
/*
|
||||
* ★★ 2026-10-03 修一个**我差点引入的回归**:拆开 `sentSnapshot` 之前,
|
||||
* 收件箱与发件箱共用一份 `snapshot` ⇒ 归档一个会话会**同时**把两边的
|
||||
* 那一会话抹掉。拆开之后若只动收件箱那份,归档完**发件箱仍显示该会话**
|
||||
* (而服务端已经没了)⇒ 用户会看到“归档了但它还在”。
|
||||
* ⇒ 两份都要剔,**保持归档前后的可见性一致**。
|
||||
*/
|
||||
this.dropSessionFromInbox(sessionId);
|
||||
this.dropSessionFromSent(sessionId);
|
||||
this.bump();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从**收件箱**快照剔掉某个会话。
|
||||
*
|
||||
* 分组口径:按权限分流后取 `normal`(与 `loadInbox` 同口径 —— 授权邮件要挑出去单独成栏)。
|
||||
*/
|
||||
private dropSessionFromInbox(sessionId: string): void {
|
||||
const snap: MailSnapshot = this.snapshot;
|
||||
const kept: MailLike[] = this.withoutSession(snap.mails, sessionId);
|
||||
snap.mails = kept;
|
||||
const split: MailSplit = splitByPermission(kept);
|
||||
snap.groups = groupMailsBySession(split.normal);
|
||||
snap.loaded = split.normal.length;
|
||||
this.bump();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从**发件箱**快照剔掉某个会话。
|
||||
*
|
||||
* ★ 这里**不能**复用收件箱那句 `splitByPermission(kept).normal`:
|
||||
* 发件箱**不做权限分流**(见 `loadSent` 的注释),而 `splitByPermission`
|
||||
* 会把 `permission_request` 挑出去 ⇒ 对“我发出的授权请求”就会把它们**抹掉**。
|
||||
* 那正是 2026-09-20 修过一次的真 bug(发件箱一片空白)的同一族。
|
||||
* ⇒ 这里直接对全部 kept 分组,与 `loadSent` 逐字同口径。
|
||||
*/
|
||||
private dropSessionFromSent(sessionId: string): void {
|
||||
const snap: MailSnapshot = this.sentSnapshot;
|
||||
const kept: MailLike[] = this.withoutSession(snap.mails, sessionId);
|
||||
snap.mails = kept;
|
||||
snap.groups = groupMailsBySession(kept);
|
||||
snap.loaded = kept.length;
|
||||
}
|
||||
|
||||
/** 滤掉某个会话的全部邮件(两份快照共用这一份实现) */
|
||||
private withoutSession(mails: MailLike[], sessionId: string): MailLike[] {
|
||||
const kept: MailLike[] = [];
|
||||
for (let i = 0; i < mails.length; i++) {
|
||||
const m: MailLike = mails[i];
|
||||
if (m.session_id !== sessionId) {
|
||||
kept.push(m);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** 退出登录/换账号时清空 —— 否则下一个账号会看到上一个人的邮件(哪怕只有一帧)。 */
|
||||
clear(): void {
|
||||
this.snapshot = new MailSnapshot();
|
||||
/*
|
||||
* ★ 发件箱那份也要清:它同样是**跨账号可见的快照**(`SentTab` 直接读它),
|
||||
* 只清收件箱那份会留下“上一个账号的发件箱”。
|
||||
*/
|
||||
this.sentSnapshot = new MailSnapshot();
|
||||
/*
|
||||
* ★ 同时把两个代号**作废**:否则一个**正在飞**的旧请求回来时
|
||||
* `myGen === this.inboxGen` 仍成立(旧值没变)⇒ 会把清空后的快照重新填上旧数据。
|
||||
* (清空发生在请求飞行途中:登出/切账号时正是这样。)
|
||||
*/
|
||||
this.inboxGen += 1;
|
||||
this.sentGen += 1;
|
||||
this.bump();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1407,7 +1407,13 @@ struct SentTab {
|
||||
}
|
||||
const store: MailStore = MailStore.getInstance();
|
||||
await store.loadSent(ctx, 'all');
|
||||
this.applyStoreSnapshot(store.snapshot);
|
||||
/*
|
||||
* ★ 2026-10-03:读**发件箱自己那份**快照(`sentSnapshot`)。
|
||||
* 此前读的是 `store.snapshot` —— 与收件箱共用 ⇒ 切页签/SSE 交错时
|
||||
* `loadSent` 会把收件箱那份覆盖掉(`unread = 0`、`mails` 换成发件箱的)。
|
||||
* 与 `InboxTab.loadData` 的 `store.snapshot` 现在是**两个不同对象**。
|
||||
*/
|
||||
this.applyStoreSnapshot(store.sentSnapshot);
|
||||
}
|
||||
|
||||
/** 把 store 快照搬到本组件的 `@State`(`InboxTab` 里同名同形) */
|
||||
|
||||
Reference in New Issue
Block a user