/* * 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`): *
← **组容器**(一层外框) *