跨端: 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:
2026-09-20 11:28:48 +08:00
parent ed1ae02441
commit 97037c0424
2 changed files with 383 additions and 100 deletions

View File

@ -17,9 +17,10 @@ import { MailApi, InboxResponse } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SseService, SseEvent } from '../api/SseService';
import { AppearanceStore } from '../common/AppearanceStore';
import { MailStore, MailSnapshot, INBOX_PAGE_SIZE } from '../common/MailStore';
import { AppearanceApi } from '../api/AppearanceApi';
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 { AppearanceSnapshot, isDarkMode, scrimOpacity } from '../model/Appearance';
import { BackgroundPlan, PresetLayer, TRANSPARENT, resolveBackground } from '../model/Wallpaper';
@ -97,7 +98,6 @@ import {
} from '../model/NavItems';
/** 一页取多少封。取满了就要如实提示"可能还有更多"(服务端 total 是未读数,不是总封数)。 */
const INBOX_PAGE_SIZE: number = 50;
const MAIL_DETAIL_ROUTE: string = 'mail-detail';
/** 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> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.loading = true;
this.error = '';
try {
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
await acctMgr.load();
const allAccounts: AccountInfo[] = acctMgr.getAccounts();
this.accountList = allAccounts;
await this.syncAccountFilter(ctx);
let filterExists: boolean = this.accountFilter === 'all';
for (let i = 0; i < allAccounts.length; i++) {
if (allAccounts[i].id === this.accountFilter) {
filterExists = true;
this.accountName = allAccounts[i].displayName;
break;
}
const store: MailStore = MailStore.getInstance();
await store.loadInbox(ctx, this.accountFilter);
this.applyStoreSnapshot(store.snapshot);
}
/**
* 把 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 的变更不总是能观察到) */