diff --git a/client/harmony/entry/src/main/ets/common/MailStore.ets b/client/harmony/entry/src/main/ets/common/MailStore.ets new file mode 100644 index 0000000..c1c6557 --- /dev/null +++ b/client/harmony/entry/src/main/ets/common/MailStore.ets @@ -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 { + 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 { + 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(); + } +} diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 9ca275e..fe1c72d 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -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 { 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 { + 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 的变更不总是能观察到) */