跨端: 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:
@ -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 的变更不总是能观察到) */
|
||||
|
||||
Reference in New Issue
Block a user