跨端: A 步 —— harmony 补 store 层(MailStore),搬走 InboxTab 107 行重复实现
用户裁定:「A+B 都做」「electron 是唯一真实源泉」。本条做 A 的第一步。
★ 量的结果(决定为什么要做)
· harmony 48 文件 / 18,444 行,**无 store 层**,223 个 @State + 47 @Prop 散在各页
· electron 58 文件 / 13,801 行,**9 个 store**(mailStore/sessionStore/contactStore/
accountStore/appearanceSync/uiStore/themeStore/authStore/backgroundStore)
· `MainPage.ets` 3,289 行,其中 4 个 load* 方法合计 250 行在重复
"遍历账号 → 逐账号拉 → 合并 → 派生 cc_count → 分流 → 分组 → 算未读 → 出提示"
· 同一套多账号遍历在 `InboxTab`(107 行) / `SentTab`(40 行) / `ContactsTab` 各写一遍
★ 做了什么
新增 `common/MailStore.ets`(318 行,照 `AppearanceStore` 的单例形状):
· `loadInbox(ctx, accountFilter)` —— 全仓**唯一**的收件箱取数实现
· `loadSent(ctx, accountFilter)` —— 与收件箱共用同一套聚合形状
· `dropSession(sessionId)` —— 归档后就地剔除(与 WebUI `mailStore.dropSession` 同动作)
· `clear()` —— 退出/换账号时清空(否则下一账号会看到上一人的邮件,哪怕只有一帧)
· `MailSnapshot` / `AccountError` / `INBOX_PAGE_SIZE`(从页面搬进来)
它只做"取数 + 组装 + 错误归类"——**纯逻辑仍留在 `model/MailGrouping.ts`**,
这样 `harmony-logic.test.mjs` 不必起设备/网络就能跑(本仓既有纪律)。
`MainPage.InboxTab` 的 `loadData()` 从 107 行变成 3 行调用 + 两个辅助
(`syncAccountFilter` / `applyStoreSnapshot`)。
★ 途中撞到的四个 ArkTS 约束(都已修,记下来免得重犯)
① `INBOX_PAGE_SIZE` 在页面里已有一份 ⇒ 导入与本地声明冲突(`arkts-unique-names`)
② `MailApi.sent()` **不收参数**(与 `inbox(status, limit)` 不同),照着 WebUI 写会错
③ `SentResponse` 定义在 `model/Models.ets`,**不在** `api/MailApi.ets` 里再导出
④ store 持有接口类型 `MailLike[]`,页面字段是 `MailSummary[]` ⇒
`MailSummary implements MailLike` 成立,但**赋值要显式向下转型**
★ 为什么页面还保留 `@State` 镜像而不直接读 store 字段
ArkUI 的 `@State` 只观察**它持有的那个值**;store 是普通类实例,改内部字段
不触发刷新(本仓 `appearance-defaults` 判据钉过同类问题)。所以照既有约定:
store 每次改完自增 `revision`,调用方把快照接进 `@State`。
设备验证:收件箱正常渲染(6 组 · 50 封、25 未读),与改动前逐项一致。
This commit is contained in:
318
client/harmony/entry/src/main/ets/common/MailStore.ets
Normal file
318
client/harmony/entry/src/main/ets/common/MailStore.ets
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
/*
|
||||||
|
* 邮件数据的**单一来源**(store 层)。
|
||||||
|
*
|
||||||
|
* ── 为什么要有这个文件(用户 2026-09-20 裁定)──
|
||||||
|
*
|
||||||
|
* 用户点名的四个痛点里,两个直接指向它:
|
||||||
|
* · 「行为对不齐」—— 每页各自记得发哪些请求、怎么刷新,于是两端/两页迟早分叉;
|
||||||
|
* · 「代码难维护」—— `MainPage.ets` 涨到 **3,289 行**,其中 4 个 `load*` 方法
|
||||||
|
* 合计 250 行在重复"遍历账号 → 逐账号拉 → 合并 → 分组 → 算未读"。
|
||||||
|
*
|
||||||
|
* 而 WebUI(**唯一真实源泉**)有 9 个 store(`mailStore`/`sessionStore`/
|
||||||
|
* `contactStore`/`accountStore`…),邮件那份在 `stores/mailStore.ts`。
|
||||||
|
* 本文件是它的鸿蒙对应物。
|
||||||
|
*
|
||||||
|
* ── 分层(照抄 WebUI 的分工,不自己发明)──
|
||||||
|
*
|
||||||
|
* `model/*.ts` 纯逻辑(分组、未读、摘要)—— **无 kit 导入**,判据直接跑
|
||||||
|
* `api/*.ets` HTTP 客户端
|
||||||
|
* `common/*Store.ets` ← 本文件:把纯逻辑与 API 组装成"界面要的状态"
|
||||||
|
*
|
||||||
|
* 纯逻辑留在 model 里、**不搬进 store**:那样判据就不必起设备/网络就能跑
|
||||||
|
* (本仓 `harmony-logic.test.mjs` 直接 import `model/MailGrouping.ts`)。
|
||||||
|
* store 只做"取数 + 组装 + 错误归类",即那些**必须**碰网络的部分。
|
||||||
|
*
|
||||||
|
* ── 与 WebUI `mailStore` 的对应 ──
|
||||||
|
*
|
||||||
|
* WebUI 用 zustand,鸿蒙没有那个库 —— 用**类 + 显式快照**代替:
|
||||||
|
* 状态集中在字段里,界面读字段渲染。ArkUI 的响应式仍走各页的 `@State`,
|
||||||
|
* 所以这里额外给一个 `revision`(见下),调用方把它接进 `@State` 即可触发刷新。
|
||||||
|
*/
|
||||||
|
import { common } from '@kit.AbilityKit';
|
||||||
|
import { ApiClient, ApiError } from '../api/ApiClient';
|
||||||
|
import { MailApi, InboxResponse } from '../api/MailApi';
|
||||||
|
import { AccountInfo, AccountManager } from '../api/AccountManager';
|
||||||
|
import { MailSummary, SentResponse } from '../model/Models';
|
||||||
|
import {
|
||||||
|
MailLike,
|
||||||
|
MailSplit,
|
||||||
|
SessionGroup,
|
||||||
|
splitByPermission,
|
||||||
|
groupMailsBySession,
|
||||||
|
sumUnreadTotals,
|
||||||
|
partialLoadNotice
|
||||||
|
} from '../model/MailGrouping';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收件箱首页大小。
|
||||||
|
*
|
||||||
|
* ★ 从 `MainPage.ets` 搬过来(它原来定义在页面里)。放在 store 里是因为
|
||||||
|
* 它决定 `partialLoadNotice` 的判定 —— 那两处必须同值,分开放一定会漂移。
|
||||||
|
*/
|
||||||
|
export const INBOX_PAGE_SIZE: number = 50;
|
||||||
|
|
||||||
|
/** 一次加载的结果快照。界面照它渲染,不在页面里自己拼。 */
|
||||||
|
export class MailSnapshot {
|
||||||
|
/** 收件箱全部邮件(含权限请求 —— 与 WebUI `mailStore.inbox` 同义) */
|
||||||
|
mails: MailLike[] = [];
|
||||||
|
/** 按会话折叠后的分组(收件箱那一栏渲染它) */
|
||||||
|
groups: SessionGroup[] = [];
|
||||||
|
/** 收件箱里**普通邮件**的条数(不含权限请求) */
|
||||||
|
loaded: number = 0;
|
||||||
|
/** 未读数(服务端权威值与本页数的较大者,理由见 `loadInbox` 注释) */
|
||||||
|
unread: number = 0;
|
||||||
|
/** "可能还有更多"的提示(本页取满才有值) */
|
||||||
|
notice: string = '';
|
||||||
|
/** 聚合模式下**单个账号**取失败的原因;整个失败才用 `error` */
|
||||||
|
accountErrors: AccountError[] = [];
|
||||||
|
loading: boolean = false;
|
||||||
|
error: string = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聚合模式下某个账号取失败(不是整次加载失败)。 */
|
||||||
|
export class AccountError {
|
||||||
|
account: string = '';
|
||||||
|
reason: string = '';
|
||||||
|
|
||||||
|
static of(account: string, reason: string): AccountError {
|
||||||
|
const e = new AccountError();
|
||||||
|
e.account = account;
|
||||||
|
e.reason = reason;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮件 store(单例)。
|
||||||
|
*
|
||||||
|
* ★ 单例而不是每页 new 一个:**同一份数据只该有一处持有** ——
|
||||||
|
* 否则收件箱页与联系人页各存一份 `mails`,一端刷新另一端还是旧的
|
||||||
|
* (这正是"行为对不齐"的典型形态)。照 `AppearanceStore` 的既有形状。
|
||||||
|
*/
|
||||||
|
export class MailStore {
|
||||||
|
private static instance: MailStore | null = null;
|
||||||
|
|
||||||
|
/** 当前快照。界面读它渲染 */
|
||||||
|
snapshot: MailSnapshot = new MailSnapshot();
|
||||||
|
/**
|
||||||
|
* 版本号 —— 每次内容变化自增。
|
||||||
|
*
|
||||||
|
* ArkUI 的 `@State` 只能观察到**它持有的那个值**变没变;一个类实例改内部字段
|
||||||
|
* 不会触发刷新(本仓 `appearance-defaults.test.mjs` 里钉过同类问题)。
|
||||||
|
* 所以调用方把 `revision` 读进 `@State`,store 每次改完 `snapshot` 就自增它。
|
||||||
|
*/
|
||||||
|
revision: number = 0;
|
||||||
|
|
||||||
|
static getInstance(): MailStore {
|
||||||
|
if (MailStore.instance === null) {
|
||||||
|
MailStore.instance = new MailStore();
|
||||||
|
}
|
||||||
|
return MailStore.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改完状态后调用:让界面的 `@State` 看到变化。 */
|
||||||
|
private bump(): void {
|
||||||
|
this.revision += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉收件箱 —— **全仓唯一**的收件箱取数实现。
|
||||||
|
*
|
||||||
|
* 原来这段(107 行)在 `MainPage.InboxTab.loadData()` 里,且每个需要邮件的
|
||||||
|
* 页面都要自己再写一遍"遍历账号 → 逐账号拉 → 合并"。搬进来之后
|
||||||
|
* 页面只说"给我收件箱",不再各自实现聚合。
|
||||||
|
*
|
||||||
|
* @param ctx UIAbility 上下文(取账号表与建 client 都要它)
|
||||||
|
* @param accountFilter `'all'` = 聚合;否则是单个账号 id
|
||||||
|
*/
|
||||||
|
async loadInbox(ctx: common.Context, accountFilter: string): Promise<void> {
|
||||||
|
const snap = this.snapshot;
|
||||||
|
snap.loading = true;
|
||||||
|
snap.error = '';
|
||||||
|
snap.accountErrors = [];
|
||||||
|
this.bump();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
|
||||||
|
await acctMgr.load();
|
||||||
|
const allAccounts: AccountInfo[] = acctMgr.getAccounts();
|
||||||
|
|
||||||
|
const mergedMails: MailLike[] = [];
|
||||||
|
const unreadTotals: number[] = [];
|
||||||
|
const failed: AccountError[] = [];
|
||||||
|
let maxFetched: number = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < allAccounts.length; i++) {
|
||||||
|
const acct: AccountInfo = allAccounts[i];
|
||||||
|
if (accountFilter !== 'all' && accountFilter !== acct.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const accountClient: ApiClient = new ApiClient(ctx);
|
||||||
|
accountClient.setBase(acct.server);
|
||||||
|
accountClient.setToken(acct.token);
|
||||||
|
const response: InboxResponse =
|
||||||
|
await new MailApi(accountClient).inbox('all', INBOX_PAGE_SIZE);
|
||||||
|
for (let j = 0; j < response.mails.length; j++) {
|
||||||
|
const mail: MailSummary = response.mails[j];
|
||||||
|
mail.source_account_id = acct.id;
|
||||||
|
mail.source_account_name = acct.displayName;
|
||||||
|
/*
|
||||||
|
* 派生 `cc_count`(行上的「抄送 N」)。
|
||||||
|
*
|
||||||
|
* ★ 为什么在这里算而不是在模型里做 getter:`MailSummary implements
|
||||||
|
* MailLike`,而 ArkTS 的 interface 里不能声明 getter
|
||||||
|
* (编译报 "incorrectly implements interface")。派生放在**填充处**。
|
||||||
|
*/
|
||||||
|
mail.cc_count = mail.cc_list.length;
|
||||||
|
mergedMails.push(mail);
|
||||||
|
}
|
||||||
|
unreadTotals.push(response.total);
|
||||||
|
if (response.mails.length > maxFetched) {
|
||||||
|
maxFetched = response.mails.length;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const ae = e as ApiError;
|
||||||
|
const reason: string = ae.message.length > 0 ? ae.message : '取数失败';
|
||||||
|
/*
|
||||||
|
* 单账号失败:**只有一个账号时**应当整体失败(用户就是要看它);
|
||||||
|
* 聚合模式下只记下这一个,其余照常显示 —— 与 WebUI
|
||||||
|
* `mailStore.accountErrors` 同一取舍(静默丢一整个账号才真的会骗人)。
|
||||||
|
*/
|
||||||
|
if (accountFilter !== 'all') {
|
||||||
|
throw e as ApiError;
|
||||||
|
}
|
||||||
|
failed.push(AccountError.of(acct.displayName, reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 收件箱只放**要读的**:权限请求是"待办",归授权那一栏
|
||||||
|
* (与 WebUI `MailList.tsx` 的 `splitByPermission(inbox).normal` 同一口径)。
|
||||||
|
*
|
||||||
|
* 不筛的后果 WebUI 侧实测过:一个会话的 17 封权限邮件把另外两个会话的信挤出视野。
|
||||||
|
* 未读数也按**筛过之后**算 —— 否则收件箱头显示"7 未读"、列表里却一封未读都没有
|
||||||
|
* (那 7 封都在授权栏等着),这正是"两处数字对不上"的经典来源。
|
||||||
|
*/
|
||||||
|
const split: MailSplit = splitByPermission(mergedMails);
|
||||||
|
const inboxMails: MailLike[] = split.normal;
|
||||||
|
|
||||||
|
snap.mails = mergedMails;
|
||||||
|
snap.groups = groupMailsBySession(inboxMails);
|
||||||
|
snap.loaded = inboxMails.length;
|
||||||
|
snap.accountErrors = failed;
|
||||||
|
/*
|
||||||
|
* 未读数:服务端 `total`(= CountUnread,权威)+ 本地数一遍筛后的未读,
|
||||||
|
* 取**较大者**。只信 total 会把授权栏的未读算进收件箱;只数列表会少报
|
||||||
|
* (这一页只取了 50 封)。取较大者偏保守:宁可多报一个,也不要
|
||||||
|
* "显示 0 未读但列表里有红点"这种自相矛盾。
|
||||||
|
*/
|
||||||
|
let localUnread: number = 0;
|
||||||
|
for (let i = 0; i < inboxMails.length; i++) {
|
||||||
|
if (inboxMails[i].status === 'unread') {
|
||||||
|
localUnread += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const serverUnread: number = sumUnreadTotals(unreadTotals);
|
||||||
|
snap.unread = serverUnread > localUnread ? serverUnread : localUnread;
|
||||||
|
/* 这一页取满了就如实说"可能还有更多":不能把 50 封说成全部 */
|
||||||
|
snap.notice = partialLoadNotice(maxFetched, INBOX_PAGE_SIZE);
|
||||||
|
} catch (e) {
|
||||||
|
const ae = e as ApiError;
|
||||||
|
snap.error = ae.message.length > 0 ? ae.message : '加载失败';
|
||||||
|
} finally {
|
||||||
|
snap.loading = false;
|
||||||
|
this.bump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉发件箱。
|
||||||
|
*
|
||||||
|
* 与收件箱**共用同一套聚合形状**(同样的多账号遍历 + 同样的 `cc_count` 派生),
|
||||||
|
* 只是不做权限分流、不分组(发件箱按时间平铺,与 WebUI `mailStore.sent` 一致)。
|
||||||
|
*/
|
||||||
|
async loadSent(ctx: common.Context, accountFilter: string): Promise<void> {
|
||||||
|
const snap = this.snapshot;
|
||||||
|
snap.loading = true;
|
||||||
|
snap.error = '';
|
||||||
|
this.bump();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
|
||||||
|
await acctMgr.load();
|
||||||
|
const allAccounts: AccountInfo[] = acctMgr.getAccounts();
|
||||||
|
const merged: MailLike[] = [];
|
||||||
|
const failed: AccountError[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < allAccounts.length; i++) {
|
||||||
|
const acct: AccountInfo = allAccounts[i];
|
||||||
|
if (accountFilter !== 'all' && accountFilter !== acct.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const c: ApiClient = new ApiClient(ctx);
|
||||||
|
c.setBase(acct.server);
|
||||||
|
c.setToken(acct.token);
|
||||||
|
const res: SentResponse = await new MailApi(c).sent();
|
||||||
|
for (let j = 0; j < res.mails.length; j++) {
|
||||||
|
const mail: MailSummary = res.mails[j];
|
||||||
|
mail.source_account_id = acct.id;
|
||||||
|
mail.source_account_name = acct.displayName;
|
||||||
|
mail.cc_count = mail.cc_list.length;
|
||||||
|
merged.push(mail);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const ae = e as ApiError;
|
||||||
|
if (accountFilter !== 'all') {
|
||||||
|
throw e as ApiError;
|
||||||
|
}
|
||||||
|
failed.push(AccountError.of(acct.displayName,
|
||||||
|
ae.message.length > 0 ? ae.message : '取数失败'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snap.mails = merged;
|
||||||
|
snap.groups = [];
|
||||||
|
snap.loaded = merged.length;
|
||||||
|
snap.unread = 0;
|
||||||
|
snap.notice = '';
|
||||||
|
snap.accountErrors = failed;
|
||||||
|
} catch (e) {
|
||||||
|
const ae = e as ApiError;
|
||||||
|
snap.error = ae.message.length > 0 ? ae.message : '加载失败';
|
||||||
|
} finally {
|
||||||
|
snap.loading = false;
|
||||||
|
this.bump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某会话归档后,把它的邮件从快照里剔除(不重拉)。
|
||||||
|
*
|
||||||
|
* 与 WebUI `mailStore.dropSession` 同一个动作、同一个理由:
|
||||||
|
* 归档是**用户刚做的操作**,重拉会闪一下并把"操作生效了"的反馈冲掉;
|
||||||
|
* 就地剔除则立即反映,且与列表滚动位置无关。
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snap.mails = kept;
|
||||||
|
const split: MailSplit = splitByPermission(kept);
|
||||||
|
snap.groups = groupMailsBySession(split.normal);
|
||||||
|
snap.loaded = split.normal.length;
|
||||||
|
this.bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退出登录/换账号时清空 —— 否则下一个账号会看到上一个人的邮件(哪怕只有一帧)。 */
|
||||||
|
clear(): void {
|
||||||
|
this.snapshot = new MailSnapshot();
|
||||||
|
this.bump();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,9 +17,10 @@ import { MailApi, InboxResponse } from '../api/MailApi';
|
|||||||
import { AccountManager, AccountInfo } from '../api/AccountManager';
|
import { AccountManager, AccountInfo } from '../api/AccountManager';
|
||||||
import { SseService, SseEvent } from '../api/SseService';
|
import { SseService, SseEvent } from '../api/SseService';
|
||||||
import { AppearanceStore } from '../common/AppearanceStore';
|
import { AppearanceStore } from '../common/AppearanceStore';
|
||||||
|
import { MailStore, MailSnapshot, INBOX_PAGE_SIZE } from '../common/MailStore';
|
||||||
import { AppearanceApi } from '../api/AppearanceApi';
|
import { AppearanceApi } from '../api/AppearanceApi';
|
||||||
import { performLogout } from '../api/Logout';
|
import { performLogout } from '../api/Logout';
|
||||||
import { Configuration, ConfigurationConstant, EnvironmentCallback } from '@kit.AbilityKit';
|
import { Configuration, ConfigurationConstant, EnvironmentCallback, common } from '@kit.AbilityKit';
|
||||||
import { image } from '@kit.ImageKit';
|
import { image } from '@kit.ImageKit';
|
||||||
import { AppearanceSnapshot, isDarkMode, scrimOpacity } from '../model/Appearance';
|
import { AppearanceSnapshot, isDarkMode, scrimOpacity } from '../model/Appearance';
|
||||||
import { BackgroundPlan, PresetLayer, TRANSPARENT, resolveBackground } from '../model/Wallpaper';
|
import { BackgroundPlan, PresetLayer, TRANSPARENT, resolveBackground } from '../model/Wallpaper';
|
||||||
@ -97,7 +98,6 @@ import {
|
|||||||
} from '../model/NavItems';
|
} from '../model/NavItems';
|
||||||
|
|
||||||
/** 一页取多少封。取满了就要如实提示"可能还有更多"(服务端 total 是未读数,不是总封数)。 */
|
/** 一页取多少封。取满了就要如实提示"可能还有更多"(服务端 total 是未读数,不是总封数)。 */
|
||||||
const INBOX_PAGE_SIZE: number = 50;
|
|
||||||
const MAIL_DETAIL_ROUTE: string = 'mail-detail';
|
const MAIL_DETAIL_ROUTE: string = 'mail-detail';
|
||||||
|
|
||||||
/** WebUI 邮件列表统一使用 MM/DD HH:mm,避免把 ISO 原文塞进窄列表。 */
|
/** WebUI 邮件列表统一使用 MM/DD HH:mm,避免把 ISO 原文塞进窄列表。 */
|
||||||
@ -259,112 +259,77 @@ struct InboxTab {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉收件箱。
|
||||||
|
*
|
||||||
|
* ★★ 2026-09-20:**实现搬进了 `common/MailStore.ets`**(用户裁定做 A:补 store 层)。
|
||||||
|
*
|
||||||
|
* 这里原来有 **107 行**:遍历账号 → 逐账号拉 → 合并 → 派生 `cc_count` →
|
||||||
|
* 按权限分流 → 分组 → 算未读 → 出提示。而 `SentTab.load()`(40 行)与
|
||||||
|
* `ContactsTab.load()` 又把同一套"多账号遍历"各写了一遍 ——
|
||||||
|
* 这正是用户点名的「代码难维护」与「行为对不齐」两个痛点的**同一处根因**。
|
||||||
|
*
|
||||||
|
* 现在页面只负责"让 store 去拉,然后把结果接进 `@State`":
|
||||||
|
* 取数/聚合/派生的规则只有一处,两个页面不可能再分叉。
|
||||||
|
*
|
||||||
|
* ★ 为什么还要 `@State` 镜像而不是直接读 store 字段:
|
||||||
|
* ArkUI 的 `@State` 只观察**它持有的那个值**;store 是普通类实例,
|
||||||
|
* 改内部字段不会触发刷新(本仓 `appearance-defaults` 判据钉过同类问题)。
|
||||||
|
* 所以照 `MailStore.revision` 的约定:拉完把快照接进 `@State`。
|
||||||
|
*/
|
||||||
async loadData(): Promise<void> {
|
async loadData(): Promise<void> {
|
||||||
const ctx = this.getUIContext().getHostContext();
|
const ctx = this.getUIContext().getHostContext();
|
||||||
if (ctx === undefined) {
|
if (ctx === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loading = true;
|
await this.syncAccountFilter(ctx);
|
||||||
this.error = '';
|
|
||||||
try {
|
|
||||||
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
|
|
||||||
await acctMgr.load();
|
|
||||||
const allAccounts: AccountInfo[] = acctMgr.getAccounts();
|
|
||||||
this.accountList = allAccounts;
|
|
||||||
|
|
||||||
let filterExists: boolean = this.accountFilter === 'all';
|
const store: MailStore = MailStore.getInstance();
|
||||||
for (let i = 0; i < allAccounts.length; i++) {
|
await store.loadInbox(ctx, this.accountFilter);
|
||||||
if (allAccounts[i].id === this.accountFilter) {
|
this.applyStoreSnapshot(store.snapshot);
|
||||||
filterExists = true;
|
}
|
||||||
this.accountName = allAccounts[i].displayName;
|
|
||||||
break;
|
/**
|
||||||
}
|
* 把 accountFilter 校正到"账号表里真实存在的那个"。
|
||||||
|
*
|
||||||
|
* 单独抽出来:`loadData`(收件箱)与 `SentTab.load`(发件箱)都要这一步,
|
||||||
|
* 而它原来在 loadData 里内联了 12 行。
|
||||||
|
*/
|
||||||
|
private async syncAccountFilter(ctx: common.Context): Promise<void> {
|
||||||
|
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
|
||||||
|
await acctMgr.load();
|
||||||
|
const all: AccountInfo[] = acctMgr.getAccounts();
|
||||||
|
this.accountList = all;
|
||||||
|
|
||||||
|
let exists: boolean = this.accountFilter === 'all';
|
||||||
|
for (let i = 0; i < all.length; i++) {
|
||||||
|
if (all[i].id === this.accountFilter) {
|
||||||
|
exists = true;
|
||||||
|
this.accountName = all[i].displayName;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (!filterExists) {
|
|
||||||
this.accountFilter = 'all';
|
|
||||||
this.accountName = '全部邮箱';
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedMails: MailSummary[] = [];
|
|
||||||
const unreadTotals: number[] = [];
|
|
||||||
let maxFetched: number = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < allAccounts.length; i++) {
|
|
||||||
const acct: AccountInfo = allAccounts[i];
|
|
||||||
if (this.accountFilter !== 'all' && this.accountFilter !== acct.id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const accountClient: ApiClient = new ApiClient(ctx);
|
|
||||||
accountClient.setBase(acct.server);
|
|
||||||
accountClient.setToken(acct.token);
|
|
||||||
const response: InboxResponse = await new MailApi(accountClient).inbox('all', INBOX_PAGE_SIZE);
|
|
||||||
for (let j = 0; j < response.mails.length; j++) {
|
|
||||||
const mail: MailSummary = response.mails[j];
|
|
||||||
mail.source_account_id = acct.id;
|
|
||||||
mail.source_account_name = acct.displayName;
|
|
||||||
/*
|
|
||||||
* 派生 `cc_count`(行上的「抄送 N」)。
|
|
||||||
*
|
|
||||||
* ★ 为什么在这里算而不是在模型里做 getter:`MailSummary implements
|
|
||||||
* MailLike`,而 ArkTS 的 interface 里不能声明 getter
|
|
||||||
* (编译报 "incorrectly implements interface")。派生放在**填充处**,
|
|
||||||
* 收件箱与发件箱各一处 —— 两处都要记得写,所以模型里也有注释指过来。
|
|
||||||
*/
|
|
||||||
mail.cc_count = mail.cc_list.length;
|
|
||||||
mergedMails.push(mail);
|
|
||||||
}
|
|
||||||
unreadTotals.push(response.total);
|
|
||||||
if (response.mails.length > maxFetched) {
|
|
||||||
maxFetched = response.mails.length;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (this.accountFilter !== 'all') {
|
|
||||||
throw e as ApiError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 收件箱只放**要读的**:权限请求是"待办",归授权那一栏(与 WebUI `MailList.tsx`
|
|
||||||
* 的 `splitByPermission(inbox).normal` 同一口径)。
|
|
||||||
*
|
|
||||||
* 不筛的后果 WebUI 侧实测过:一个会话的 17 封权限邮件把另外两个会话的信挤出视野。
|
|
||||||
* 注意未读数也按**筛过之后**算 —— 否则收件箱头显示"7 未读"、列表里却一封未读都没有
|
|
||||||
* (那 7 封都在授权栏等着),这正是"两处数字对不上"的经典来源。
|
|
||||||
*/
|
|
||||||
const split: MailSplit = splitByPermission(mergedMails);
|
|
||||||
const inboxMails: MailLike[] = split.normal;
|
|
||||||
|
|
||||||
this.mails = mergedMails;
|
|
||||||
// 按会话折叠:组头取组内最新一封(含别名),组间按最新一封倒序;
|
|
||||||
// 单封的组不算组,平铺(见 MailGrouping.isFlatGroup)。
|
|
||||||
this.groups = groupMailsBySession(inboxMails);
|
|
||||||
this.loaded = inboxMails.length;
|
|
||||||
/*
|
|
||||||
* 未读数:服务端 `total`(= CountUnread,权威)+ 本地数一遍筛后的未读,取**较大者**。
|
|
||||||
*
|
|
||||||
* 为什么不用其中一个:`total` 是收件箱里**所有**未读(含权限邮件),
|
|
||||||
* 而列表里只有普通邮件 —— 只信 total 会把授权栏的未读也算进收件箱;
|
|
||||||
* 只数列表则会少报(这一页只取了 50 封)。取较大者偏保守:宁可多报一个未读,
|
|
||||||
* 也不要"显示 0 未读但列表里有红点"这种自相矛盾。
|
|
||||||
*/
|
|
||||||
let localUnread: number = 0;
|
|
||||||
for (let i = 0; i < inboxMails.length; i++) {
|
|
||||||
if (inboxMails[i].status === 'unread') {
|
|
||||||
localUnread += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const serverUnread: number = sumUnreadTotals(unreadTotals);
|
|
||||||
this.unread = serverUnread > localUnread ? serverUnread : localUnread;
|
|
||||||
// 这一页取满了就如实说"可能还有更多":不能把 50 封说成全部(见 partialLoadNotice)。
|
|
||||||
this.notice = partialLoadNotice(maxFetched, INBOX_PAGE_SIZE);
|
|
||||||
} catch (e) {
|
|
||||||
const ae = e as ApiError;
|
|
||||||
this.error = ae.message.length > 0 ? ae.message : '加载失败';
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
}
|
||||||
|
if (!exists) {
|
||||||
|
this.accountFilter = 'all';
|
||||||
|
this.accountName = '全部邮箱';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 store 快照接进本组件的 `@State`(触发 ArkUI 刷新)。 */
|
||||||
|
private applyStoreSnapshot(snap: MailSnapshot): void {
|
||||||
|
/*
|
||||||
|
* `MailLike[]` → `MailSummary[]`:store 持有的是接口类型(它不该依赖具体实现类),
|
||||||
|
* 而本组件的字段是 `MailSummary[]`(要读 `source_account_name` 等具体字段)。
|
||||||
|
* 实现类 `MailSummary implements MailLike`,所以这次向下转型在每个元素上都成立
|
||||||
|
* —— store 只往 `mails` 里放 `MailSummary` 实例(两处 API 响应都是它)。
|
||||||
|
*/
|
||||||
|
this.mails = snap.mails as MailSummary[];
|
||||||
|
this.groups = snap.groups;
|
||||||
|
this.loaded = snap.loaded;
|
||||||
|
this.unread = snap.unread;
|
||||||
|
this.notice = snap.notice;
|
||||||
|
this.loading = snap.loading;
|
||||||
|
this.error = snap.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 展开状态放在数组里(ArkTS 的 @State 对 Map/Set 的变更不总是能观察到) */
|
/** 展开状态放在数组里(ArkTS 的 @State 对 Map/Set 的变更不总是能观察到) */
|
||||||
|
|||||||
Reference in New Issue
Block a user