/*
* AgentMail 鸿蒙客户端 — 主框架(底部 Tab 导航)
*
* 底部三项:**通信**(内部三栏:收件箱 / 发件箱 / 授权,见 `CommTabs.ts`)、**日历**、**联系人**。
* 平级的「会话」已并入前两处(见文件末尾的说明)。
* 「日历」原先写着"等 P6 内容做完再上入口",P6 第一步(只读月视图,见 `pages/CalendarPage.ets`)
* 已完成,所以入口现在上;日历是整个客户端里**常驻挂载**的那一个 pane(见 build() 里的说明)。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme';
import { AppHeader, CompositeModifier, GlassCardModifier, PaneModifier, PressEffectModifier, TintModifier } from '../common/Surface';
import { Motion } from '../common/Motion';
import { AmIcon } from '../common/Icons';
import { WideSidebar } from './WideSidebar';
import { MailDetailView } from './MailDetailPage';
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, AccountError, INBOX_PAGE_SIZE } from '../common/MailStore';
import { AppearanceApi } from '../api/AppearanceApi';
import { performLogout } from '../api/Logout';
import { ComposeIntent } from '../common/ComposeIntent';
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';
import { MailSummary, Contact, PermissionRequest, DecideResponse, SentResponse, PendingResponse } from '../model/Models';
import {
MailLike,
PermissionGroup,
groupPermissions,
MailSplit,
SessionGroup,
groupMailsBySession,
isFlatGroup,
partialLoadNotice,
sumUnreadTotals,
splitByPermission,
budgetState,
budgetLabel,
nextContactView,
contactViewTitle,
lastFromIsHuman,
permissionLabel,
permissionChipText,
permissionHint,
enforcementLabel,
shortTimeOf
} from '../model/MailGrouping';
import {
COMM_TABS,
commTabLabel,
commTabIndex,
commTabFromIndex,
normalizeCommTab,
badgeCount,
badgeText,
badgeTone,
emptyTitle,
emptyHint
} from '../model/CommTabs';
import { MailDetailParams, ComposeParams } from '../model/RouteParams';
import { PushService, PushRoute } from '../api/PushService';
/*
* ⚠️ **`import` 必须在这一行的位置**:ArkTS 要求所有 import 都在**任何其它语句之前**
* (`arkts-no-misplaced-imports`),与它们之间隔的是常量、表还是别的语句无关。
*
* 这一笔我犯过:`7647c24` 里我把一张常量表(`NAV_MATERIAL_OF`)插在了这两组 import
* **之前**,于是 `hvigorw assembleHap` 直接报错(`f31bc02`/`7647c24` 上都红)。
* **归属说明**:那条错是我造成的,`git show 7647c24:…/MainPage.ets` 可复核
* (最后一条 import 在第 80 行,而第 63 行已是 `const NAV_MATERIAL_OF`)。
* 此处原先写的是"由 pi 实测" —— 那句话不准确:是本机**某个** pi 会话/别人测的,
* 我无法独立复核"是谁",这类**归属断言**和"锚短哈希"同族(复核方没法复现"谁做的"),
* 所以改成只陈述**可复核的事实**:错误存在、在哪个提交、怎么复核。
*
* 那张表和它的 import 后来都随"导航条改回固定档"一起删了(见下面 `NavBar` 的注释);
* 这条注释留在 **import 区**而不是跟着表走,因为要守的是"import 在最前"这个位置本身。
*/
import { CalendarPage } from './CalendarPage';
import { ComposeView } from './ComposePage';
import { SettingsPane } from './SettingsPage';
import { LengthMetrics, ComponentContent } from '@kit.ArkUI';
import { Insets, KEY_WINDOW_INSETS } from '../model/WindowInsets';
import {
LIST_FADE_LENGTH,
NAV_BAR_BOTTOM,
NAV_BAR_HEIGHT,
NAV_BAR_RADIUS,
NAV_BAR_SIDE,
NAV_CONTENT_RESERVE,
NAV_ITEM_MIN_HIT,
NAV_ITEMS,
HEADER_BACK_HIT,
navBadgeCount,
navBadgeText,
navBadgeTone,
NavItem,
normalizeNavIndex, TAB_BAR_HEIGHT } from '../model/NavItems';
/** 一页取多少封。取满了就要如实提示"可能还有更多"(服务端 total 是未读数,不是总封数)。 */
const MAIL_DETAIL_ROUTE: string = 'mail-detail';
/**
* 写信页签的路由名(宽屏在**右栏**内嵌打开,见 `ComposeDestination`)。
*
* WebUI `App.tsx:179` 宽屏下写信只是把 `main` 那一格换成 ``,
* 侧栏与列表栏都还在。鸿蒙用 `Navigation` 的同一个栈表达这件事。
*/
const COMPOSE_ROUTE: string = 'compose';
/**
* 主题交叉淡出要抓图的那个节点的 id(见 `MainPage` 根 Stack 的 `.id()`)。
*
* 常量而不是字面量:`.id()` 与 `getComponentSnapshot().get(id)` 两处拼错**不报错**,
* 只表现为“抓不到图 ⇒ 动画静默不发生”(与 `AppStorage` 键名同一个坑)。
*/
const THEME_FADE_ROOT_ID: string = 'theme-fade-root';
/** WebUI 邮件列表统一使用 MM/DD HH:mm,避免把 ISO 原文塞进窄列表。 */
function compactMailTime(iso: string): string {
const value: Date = new Date(iso);
if (Number.isNaN(value.getTime())) {
return '';
}
const month: string = (value.getMonth() + 1).toString().padStart(2, '0');
const day: string = value.getDate().toString().padStart(2, '0');
const hour: string = value.getHours().toString().padStart(2, '0');
const minute: string = value.getMinutes().toString().padStart(2, '0');
return month + '/' + day + ' ' + hour + ':' + minute;
}
/**
* Navigation 目标页:按官方示例在 NavDestination.onReady 读取路径参数。
* 系统 Navigation 自动决定 Stack / Split;这里不读窗口宽度、不手搓分栏。
*/
/**
* 右栏占位(`Navigation` **split 模式**下、栈空时显示的那一块)。
*
* ★★ 2026-09-20 补(用户:「你自己看看跟 webui 相比,观感真的差很多」)。
*
* 宽屏下详情是**常驻右栏**;没选邮件时 WebUI 有引导(`MailView.tsx:121-129`):
* 信封图标 + 「选择一封邮件查看,或点击左侧「新建」写邮件」
* 而我们什么都不渲染 ⇒ 两栏并排时右栏是**一大片空白**
* (实测同 1107vp 视口并排:WebUI 那栏中央有图标+文案,我们那栏全空)。
*
* ★ 为什么用 `splitPlaceholder` 而不是"在 `NavDestination` 里加 `else` 分支":
* `NavDestination` **只在 push 之后才挂载**,栈空时它根本不存在 ——
* 我第一版就是写在它的 `else` 里,**一次都不会显示**(已撤销)。
* `splitPlaceholder(ComponentContent)` 是系统给"右栏默认页"的专用入口
* (`navigation.d.ts`,API 20+,我们是 23),由 `Navigation` 在栈空时自己渲染。
*
* ★ 必须是**全局** `@Builder`:`splitPlaceholder` 要的是
* `wrapBuilder<[]>(fn)`,而它只接受全局 @Builder 函数
* (成员方法会报 "The wrapBuilder's parameter should be '@Builder' function")。
* 内容本身是静态的,不需要 `this`,所以全局正好。
*
* 文案**逐字对齐 WebUI**(同一句话,不自己改写)—— 本仓纪律:
* 两端对同一件事说同一句话(见 `harmony-logic` 里「空态主句逐字一致」那条)。
*/
@Builder
function DetailPlaceholder() {
Column() {
AmIcon({ iconName: 'mail', iconSize: 40, iconColor: Theme.textSubtleFor() })
Text('选择一封邮件查看,或点击左侧「新建」写邮件')
.fontSize(14).fontColor(Theme.textMuted)
.margin({ top: 12 })
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
/*
* ★★ 2026-09-21 补:**宽屏右栏默认页也要透明**(用户:「邮件看着没有玻璃效果」)。
*
* `splitPlaceholder` 那块内容由 `Navigation` 直接渲染
* (布局树里的 `SplitPlaceholderContentNode`,实测 x=1152..3154)。
* 不设背景时它用**系统默认底**(近白不透明),于是整片右栏把壁纸挡死 ——
* 实测宽屏 3184px 下:右栏中心 `(242,244,244)`,而屏幕边缘的壁纸是
* `(192,194,192)`。差这么远,说明中间隔着的不透明层就是它。
*
* 这与 2026-09-19 修的 `CommPage` 那个 `Navigation` 是**同一个形状**
* (见那段注释里"那条缝是决定性的证据")—— 系统容器不设背景就不会透。
*
* ★ 全局 `@Builder` 里**拿不到 `this`**(`splitPlaceholder` 只接受全局 Builder),
* 所以不能用 `bgActive ? Transparent : surface`。
* 这里改成**恒透明**:右栏默认页本来就没有"实体面"的语义
* (它是一块空态提示),壁纸关着时底下是 `WallpaperLayer` 之外的
* `pageBg`,观感仍然是浅色底 —— 不会因为透明变成异色。
*/
.backgroundColor(Color.Transparent)
}
@Component
struct MailDetailDestination {
@State mailId: string = '';
@State accountId: string = '';
/** 底部悬浮条高度(窄屏非 0)——透传给详情页,让它的回复球抬过条 */
@Prop navReserve: number = 0;
/** 壁纸是否开启。宽屏下本栏要**自己**有圆角(WebUI `app-shell > *` 的对应物) */
@Prop bgActive: boolean = false;
private pathStack: NavPathStack = new NavPathStack();
handleReady(ctx: NavDestinationContext): void {
const params: MailDetailParams = ctx.pathInfo.param as MailDetailParams;
this.mailId = params?.mail_id ?? '';
this.accountId = params?.account_id ?? '';
this.pathStack = ctx.pathStack;
}
build() {
NavDestination() {
if (this.mailId.length > 0) {
MailDetailView({
initialMailId: this.mailId,
initialAccountId: this.accountId,
embedded: true,
navReserve: this.navReserve,
onBack: (): void => { this.pathStack.pop(); }
})
} else {
Column() {
LoadingProgress().width(32).height(32)
}
.width('100%').height('100%').justifyContent(FlexAlign.Center)
}
}
.hideTitleBar(true)
/*
* ★★ 2026-09-20 修(用户:「邮件展示左侧没有圆角」)。
*
* WebUI 宽屏的真结构是**三个独立圆角面板并排**(`App.tsx:262`):
*
*
← padding/gap = 10px
* {list} {main} ← 每个子元素各自 border-radius:14px
*
*
* 所以列表栏和详情栏**各有自己的四个圆角**,中间的缝里透出壁纸。
*
* 我们这边是一个 `Navigation`,由系统 Split 成 navBar + content 两半,
* 圆角只加在**外壳**(那个 `Row` 里的内容列)上 ⇒ 内部这两半变成直角,
* 实测详情栏白区左缘在 y=145 与 y=2195 都是 `x=1152`(**完全垂直、无圆角**)。
*
* 修法:给**右栏**(`NavDestination`)自己补上圆角 + 裁切,
* 与 WebUI 的 `app-shell > *` 逐条对应;左栏(navBar 内容)在它的根容器上补。
*/
.borderRadius(this.bgActive ? Theme.glassRadius : 0)
/*
* ★ 这里同样**不写** `.clip()` —— 理由见左栏那处(WebUI `index.css:968` 的
* 「不能写 overflow: hidden」那条,我照搬圆角时把裁切也一起搬了,导致列表滚不动)。
*/
/*
* ★★ 2026-09-21 补:**同时去掉 NavDestination 的系统白底**。
*
* 上面那个 `.borderRadius` 一直"看着生效了",其实只裁了内容 ——
* ArkUI 的 `borderRadius` **不裁 `backgroundColor`**。而 `NavDestination`
* 自带一层不透明的 system background,它比外壳**四周各小 2px**
* (实测 `[30,142]` vs 外壳 `[28,140]`)⇒ 圆角内侧露出 2px 直角白边。
*
* 就是用户报的「圆角下方还是有白框(直角框)」。`Color.Transparent`
* 让外壳那层玻璃显出来 —— 与 `ComposeDestination` 同一处修法。
*/
.backgroundColor(Color.Transparent)
.onReady((ctx: NavDestinationContext) => { this.handleReady(ctx); })
}
}
/**
* 写信窗格的内嵌壳(宽屏右栏)。
*
* ★★ 2026-09-20 加(用户:「写邮件 webui 的宽屏样式不是在右侧打开吗」)。
*
* 与 `MailDetailDestination` **完全同构**(那是既有先例):
* 同一个 `NavDestination` 机制,宽屏并排在右栏、窄屏 push 覆盖全屏 ——
* 由 `Navigation.mode(Auto)` 按宽度自动决定,这里不自己判断宽窄。
*
* ★ 为什么是路由而不是一个 `@State showCompose`:
* 路由让"返回"这件事自动正确(系统返回键、手势、头部返回都弹同一个栈),
* 而一个布尔状态要自己接三条返回路径 —— 那正是 2026-09-17 那批
* 「返回直接回到登录页」bug 的来源。
*/
@Component
struct ComposeDestination {
@State to: string = '';
@State replyTo: string = '';
@State sessionAlias: string = '';
@State accountId: string = '';
@Prop navReserve: number = 0;
@Prop bgActive: boolean = false;
private pathStack: NavPathStack = new NavPathStack();
handleReady(ctx: NavDestinationContext): void {
const params: ComposeParams = ctx.pathInfo.param as ComposeParams;
this.to = params?.to ?? '';
this.replyTo = params?.reply_to ?? '';
this.sessionAlias = params?.session_alias ?? '';
this.accountId = params?.account_id ?? '';
this.pathStack = ctx.pathStack;
}
build() {
NavDestination() {
ComposeView({
embedded: true,
initialTo: this.to,
initialReplyTo: this.replyTo,
initialSessionAlias: this.sessionAlias,
initialAccountId: this.accountId,
onBack: (): void => { this.pathStack.pop(); }
})
}
/*
* ★★ 共享元素转场的 **in 端**(与通信页右下那个加号同一个 id)。
*
* 系统按两端各自的 frame 与圆角插值 ⇒ "球长成整页"这件事不需要我算。
* 起点圆角 28(球的半径)→ 终点 0(整幅面板)由两端各自声明。
*/
.geometryTransition('compose-morph')
.hideTitleBar(true)
/*
* ★★ 2026-09-21 修(用户:「写邮件页面和其他多个页面圆角下方还是有白框(直角框)」)。
*
* `MailDetailDestination` 早就补了圆角,这里**漏了** —— 而它的注释还写着
* 「与 `MailDetailDestination` 同一处圆角修法」,说的是"打算照做",
* 实际只搬了圆角、漏了下面那件更要紧的事。
*
* ── 实测(`uitest dumpLayout`,窄屏 1008px,写信页)──
*
* Column(外壳,有圆角) [28,140][980,1957] bg=#C7FFFFFF
* NavDestination [30,142][978,1955] bg=#FFFFFFFF ← 直角、全白
*
* 那个 `#FFFFFFFF` 是 **NavDestination 自己的系统底色**,而它比外壳
* **四周各小 2px**(30 vs 28、142 vs 140 …)。于是外壳那圈 14vp 的圆角
* 内侧露出 2px 的**直角白边** —— 像素实测:y=1946 时圆角已收窄到 x=45,
* 而 x=30..39 仍是纯白;y=1952 时 x=30..48 仍是纯白。
*
* 肉眼就是用户说的「圆角下方一个白框(直角框)」,宽屏在右栏更明显。
*
* ── 修法 ──
*
* 两件事都要做,只做一件都盖不住:
* ① `borderRadius` —— 让**自己**是圆的(原来这里就有);
* ② `backgroundColor(Color.Transparent)` —— 去掉那层系统白底。
* 只给圆角不改底色没用:圆角只裁自己的**内容**,
* 而那块白是**底色**,圆角外照样画得出来。
*
* ★ 为什么 ① 原来没生效:`.borderRadius()` 在 ArkUI 里**不裁背景色**,
* 它裁的是内容与子节点。白底是 `backgroundColor`,不受圆角约束 ⇒
* 必须把底色去掉,让外壳那层(`#C7FFFFFF` 玻璃)显出来。
*/
.borderRadius(this.bgActive ? Theme.glassRadius : 0)
.backgroundColor(Color.Transparent)
.onReady((ctx: NavDestinationContext) => { this.handleReady(ctx); })
}
}
@Component
struct InboxTab {
/** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */
@Prop bgActive: boolean = false;
/**
* 底部悬浮条要占的高度(vp,窄屏才非 0)。
*
* 加在**列表内容的末尾段落**(`contentEndOffset`),让最后一行滚得出来;
* **不是**缩短本窗格 —— 窗格要满高,内容才滑得到条底下,
* 系统材质才有东西可糊(否则玻璃看着像普通的浅色面板)。
*/
@Prop navReserve: number = 0;
/** 选中邮件回调:宽屏内联到 Navigation 右栏,窄屏 push 到目标页 */
onOpenMail: (mailId: string, accountId: string) => void = (): void => {};
/** 打开写信(见 `openCompose()` 的注释:宽屏要在右栏开,不能自己 pushUrl) */
onOpenCompose: (accountId: string) => void = (): void => {};
/*
* 当前是否深色(`MainPage` 发布)—— 用来选品牌浅底的深浅变体。
*
* ★ 2026-09-19:深色主题下「未读」行/选中账号的**浅蓝底**在深色页面上刺眼,
* 根因是 `Theme.accentSoft` 是写死的浅色(WebUI 靠 `.dark` 段反转发解决,
* ArkTS 的静态常量没有那层机制)。窗格自己算不出深浅色 ⇒ 走 AppStorage 读。
*/
@StorageProp('agentmail.appearance.isDark') isDarkNow: boolean = false;
@State mails: MailSummary[] = [];
/** 按会话折叠后的列表(单封的组平铺渲染) */
@State groups: SessionGroup[] = [];
/** 已展开的组(键 = SessionGroup.key) */
@State expandedKeys: string[] = [];
@State loading: boolean = false;
/** 这一页**取回来**的封数 */
@State loaded: number = 0;
/** 未读总数:服务端 total(CountUnread),权威 */
@State unread: number = 0;
/** 本页取满时的"可能还有更多"提示,空串表示不用提示 */
@State notice: string = '';
@State error: string = '';
@State accountName: string = '';
@State accountFilter: string = 'all'; // 'all' 或 accountId
@State showAccountPicker: boolean = false;
@State accountList: AccountInfo[] = [];
/**
* **当前正在看的邮件**(点开的那一封)—— 用来给列表里对应的那张卡加高亮。
*
* ★★ 2026-09-20 补(用户:「邮件展示左侧没有圆角」→ 顺着逐项比对时发现)。
*
* 之前鸿蒙**完全没有"当前选中邮件"这个概念**:点开一封邮件后,左侧列表
* 那一行和旁边几行长得一模一样 —— 你不知道自己正在读哪一封、读完了该往哪回。
* 而 WebUI 一直有(`MailList.tsx:104/116/229` 把 `currentMail.mail_id` 传下去,
* 卡片据此上 `bg-blue-50 border-blue-200`)。
*
* 存 `mail_id`(不是索引/组键):SSE 推送会让列表重排,索引会错位;
* `mail_id` 才是这一封的稳定身份。
*
* ★ 由 `CommPage` 拥有、以 `@Prop` 下发 —— 因为"点开了哪一封"是
* `CommPage.openMail()` 那个动作产生的(收件箱/发件箱都能触发),
* 两个 tab 各存一份必然会分叉。单点写、多点读。
*/
@Prop currentMailId: string = '';
private sseService: SseService | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.sseService = SseService.getInstance();
// 加载账号列表
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
acctMgr.load().then(() => {
const active = acctMgr.getActiveAccount();
if (active !== null) {
this.accountName = active.displayName;
}
this.accountList = acctMgr.getAccounts();
if (this.accountList.length <= 1) {
this.accountFilter = 'all';
}
});
this.loadData();
// 全局 SSE 监听(所有账号的事件都会收到);保存同一函数引用以便页面退出时移除。
this.sseService.addListener(this.onSseEvent);
}
}
aboutToDisappear(): void {
if (this.sseService !== null) {
this.sseService.removeListener(this.onSseEvent);
}
}
private onSseEvent = (event: SseEvent): void => {
if (event.type === 'new_mail') {
let sourceName: string = '';
for (let i = 0; i < this.accountList.length; i++) {
if (this.accountList[i].id === event.accountId) {
sourceName = this.accountList[i].displayName;
break;
}
}
this.getUIContext().getPromptAction().showToast({ message: sourceName.length > 0 ? '新邮件:' + sourceName : '新邮件到达' });
this.loadData();
}
};
/**
* 拉收件箱。
*
* ★★ 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;
}
await this.syncAccountFilter(ctx);
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 (!exists) {
this.accountFilter = 'all';
this.accountName = '全部邮箱';
}
}
/** 把 store 快照接进本组件的 `@State`(触发 ArkUI 刷新)。 */
/**
* 聚合模式下**单个账号**取失败的原因(`MailStore` 已在收集,此前没人读)。
*
* ★★ 2026-09-21 补(逐页对齐 WebUI 时发现的安全网断线):
* 数据一直在 `MailSnapshot.accountErrors` 里、两处 load 都写,
* 但**界面从来没有读过它** ⇒ 某个账号拉不到邮件时列表**静默少一整份**,
* 而界面看起来完全正常 —— 用户会得出错误结论("没人给我发信")。
*/
@State accountErrors: AccountError[] = [];
/** 把聚合失败拼成 WebUI 那句文案:「a(原因);b(原因)」 */
private accountErrorText(): string {
const parts: string[] = [];
for (let i = 0; i < this.accountErrors.length; i++) {
const e: AccountError = this.accountErrors[i];
parts.push(e.reason.length > 0 ? e.account + '(' + e.reason + ')' : e.account);
}
return parts.join(';');
}
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;
/* 聚合失败原因(见 `accountErrors` 的注释:数据一直有,界面前面没接) */
this.accountErrors = snap.accountErrors;
}
/** 展开状态放在数组里(ArkTS 的 @State 对 Map/Set 的变更不总是能观察到) */
isExpanded(key: string): boolean {
for (let i = 0; i < this.expandedKeys.length; i++) {
if (this.expandedKeys[i] === key) {
return true;
}
}
return false;
}
toggleExpanded(key: string): void {
const next: string[] = [];
let found: boolean = false;
for (let i = 0; i < this.expandedKeys.length; i++) {
if (this.expandedKeys[i] === key) {
found = true;
} else {
next.push(this.expandedKeys[i]);
}
}
if (!found) {
next.push(key);
}
this.expandedKeys = next;
}
openMail(mail: MailLike): void {
this.onOpenMail(mail.mail_id, mail.source_account_id);
}
openCompose(): void {
let accountId: string = this.accountFilter === 'all' ? '' : this.accountFilter;
if (accountId.length === 0) {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
accountId = AccountManager.getInstance(ctx).getActiveId();
}
}
const params: ComposeParams = {
to: '',
reply_to: '',
session_alias: '',
account_id: accountId
};
this.getUIContext().getRouter().pushUrl({ url: 'pages/ComposePage', params: params });
}
build() {
Column() {
/*
* WebUI 的列表头只有一张紧凑卡:标题 +「会话组 · 邮件数」+ 账号切换。
* 不再重复堆「通信 / 收件箱 / 全部邮箱 / 已加载 / 未读」五层说明文字。
*/
/*
* ★ 卡片要真缩到 10vp,不能靠 `width('100%') + margin`:
* ArkUI 的 margin 加在宽度**外面**,100% 宽度再配左右 margin 会顶出父容器
* (实测卡片左缘只留 ~1px,列表卡却有 10vp 缩进)。改成外层带 padding 的容器。
*/
Column() {
Row() {
Text('收件箱').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Text(this.groups.length + ' 组 · ' + this.loaded + ' 封')
.fontSize(12).fontColor(Theme.textMuted).margin({ left: 10 })
Blank()
if (this.accountList.length > 1) {
Row() {
Text(this.accountFilter === 'all' ? '全部邮箱' : this.accountName)
.fontSize(11).fontColor(Theme.textMuted)
AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.textSubtleFor() })
.rotate({ angle: 90 })
.animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft })
.margin({ left: 3 })
}
.height(32)
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
}
if (this.unread > 0) {
Text(this.unread > 99 ? '99+' : this.unread.toString())
.fontSize(10).fontWeight(FontWeight.Bold).fontColor(Theme.accentFg)
.backgroundColor(Theme.danger).borderRadius(9)
.constraintSize({ minWidth: 18 }).height(18)
.textAlign(TextAlign.Center).margin({ left: 8 })
}
}
.width('100%').height(46)
.padding({ left: 14, right: 12 })
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
}
.width('100%')
.padding({ left: 10, right: 10 })
.margin({ top: 10 })
/*
* 聚合失败横幅(对齐 WebUI `MailList.tsx:85-96` 的 `account-errors`)。
*
* 文案/形状照 WebUI:
* 「有 {n} 个账号没取到:{账号}({原因});…」
* 琥珀底 + 琥珀边 + 11 号字(`bg-amber-50 border-amber-200 text-amber-800`)。
*
* ★★ 为什么值得单独占一块地方(WebUI 原注释):
* 「聚合时**某个账号取不到**必须说出来:静默丢掉它,列表会少一整份邮件,
* 而界面看起来完全正常 —— 这正是"聚合"最容易骗人的失败方式」
*
* 本仓的情况更糟一点:`MailStore` **一直在收集**这些失败
* (`snap.accountErrors`,两处 load 都写),但**界面从来没有读过它** ⇒
* 这个安全网当前是断的。是逐页对齐时比对出来的,不是猜的。
*/
if (this.accountErrors.length > 0) {
Text('有 ' + this.accountErrors.length + ' 个账号没取到:' + this.accountErrorText())
.fontSize(11).fontColor(Theme.warnFgDark)
.width('100%')
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.backgroundColor(Theme.warnBg)
.borderRadius(6)
.margin({ top: 6 })
}
// 账号选择器下拉
if (this.showAccountPicker && this.accountList.length > 1) {
Column() {
Text('全部邮箱')
.fontSize(14).fontColor(this.accountFilter === 'all' ? Theme.accentFor() : Theme.textPrimary)
.fontWeight(this.accountFilter === 'all' ? FontWeight.Bold : FontWeight.Normal)
.width('100%').height(40).padding({ left: 16 })
.backgroundColor(this.accountFilter === 'all' ? Theme.accentSoftFor(this.isDarkNow) : Theme.surface)
.onClick(() => {
this.accountFilter = 'all';
this.accountName = '全部邮箱';
this.showAccountPicker = false;
this.loadData();
})
ForEach(this.accountList, (acct: AccountInfo) => {
Row() {
Column() {
Text(acct.displayName)
.fontSize(14)
.fontColor(this.accountFilter === acct.id ? Theme.accentFor() : Theme.textPrimary)
.fontWeight(this.accountFilter === acct.id ? FontWeight.Bold : FontWeight.Normal)
Text(acct.username)
.fontSize(11).fontColor(Theme.textSubtleFor())
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%').height(48).padding({ left: 16 })
.backgroundColor(this.accountFilter === acct.id ? Theme.accentSoftFor(this.isDarkNow) : Theme.surface)
.attributeModifier(PressEffectModifier.of())
.onClick(() => {
this.accountFilter = acct.id;
this.accountName = acct.displayName;
this.showAccountPicker = false;
this.loadData();
})
}, (acct: AccountInfo) => acct.id)
}
.width('100%')
.attributeModifier(GlassCardModifier.of(this.bgActive))
.border({ width: { bottom: 1 }, color: Theme.border })
/*
* ★ 2026-09-19 补(用户:「元素的出现消失动画呢?」)。
*
* 这个下拉原来是**硬弹**出来的:它挂在 `if (this.showAccountPicker)` 上,
* 条件一变就整块出现/消失,中间一帧过渡都没有 —— 而它正是
* "出现/消失"最典型的一处(用户点一下按钮,东西凭空冒出来)。
*
* 用 `menuIn()`:下移 4vp + 缩到 0.985 + 淡入,与 WebUI
* `@keyframes menu-in` 逐值对齐(`Theme.menuIn` 的注释里有出处)。
* 方向是**从上往下**(`translateY(-4px)` → 0)—— 下拉从触发点下方展开,
* 从上方"落"下来;与 `paneRiseIn` 的**上浮**方向相反,别混。
*
* `.transition()` 在这里**会播**(与日历那个常驻窗格不同):
* 它是 `if` 包着的,条件为真时才挂载 —— 正是 transition 的触发条件。
*/
.transition(Theme.menuIn())
}
if (this.loading) {
Column() {
LoadingProgress().width(40).height(40)
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.error.length > 0) {
Column() {
Text(this.error).fontSize(14).fontColor(Theme.dangerFor())
Button('重试').margin({ top: 12 }).onClick(() => { this.loadData(); })
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.mails.length === 0) {
Column() {
Text('收件箱为空').fontSize(16).fontColor(Theme.textSubtleFor())
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
List({ space: 6 }) {
ForEach(this.groups, (g: SessionGroup) => {
ListItem() {
if (isFlatGroup(g)) {
// 单封不成组:套一个可折叠的组头只是多一次点击(与 WebUI isFlatGroup 同结论)
this.MailRow(g.mails[0])
} else {
/*
* ── 会话组容器 ──
*
* ★★ 2026-09-21 修「多个邮件为什么没有堆叠效果」(用户)。
*
* 原来组头与子邮件是**平铺的兄弟**、各自一张完整的卡、间距一样 ——
* 于是一组三封看起来就是"三张互不相干的卡",看不出
* "这是同一条会话里的三封"。
*
* WebUI 的形状(`MailList.tsx:173-232`):
* ← **组容器**(一层外框)
*