/* * 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'; /* * 授权(通信页第三栏)—— ★★ 2026-09-23 从本文件抽出为独立组件。 * * 理由见 `pages/PermissionTab.ets` 抬头:不是为了"文件太长", * 而是为了让它与 WebUI 的 `components/PermissionList.tsx` **一一对应**, * 这样"两端对齐"才有一个可对比的单元(原来它是 4592 行文件里的一段内联 Builder, * 对齐只能退化成逐行找渲染字段)。 * * 本文件里同类的先例:`WideSidebar`(也是 `pages/` 下的独立组件)。 */ import { PermissionTab } from './PermissionTab'; /* * Navigation 目标页包装(详情 / 写信)—— ★★ 2026-09-23 从本文件抽出。 * * 它们是**壳**:按 `onReady` 读路径参数,再把真正的页面挂进去。 * 抽出的理由与 `PermissionTab` 相同 —— 这两个壳被多个栏共用 * (`InboxTab` 与 `ContactsTab` 都用 `MailDetailDestination`), * 埋在 4000+ 行里时没人能确认"只此一处"。 */ import { MailDetailDestination, ComposeDestination } from './NavDestinations'; import { ContactsTab } from './ContactsTab'; import { MAIL_DETAIL_ROUTE, DetailPlaceholder, compactMailTime } from './NavShared'; 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'; /* * 顶栏文案(摘要 / 一言 / 签名)—— 用户 2026-09-24: * 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」。 */ import { TopbarStore, TopbarContent } from '../common/TopbarStore'; import { AppearanceApi } from '../api/AppearanceApi'; import { performLogout } from '../api/Logout'; import { ComposeIntent, ReplyIntent, PopIntent } from '../common/ComposeIntent'; /* 2in1 键盘快捷键的判定规则(与详情页同一套,见 `model/KeyboardShortcuts.ts`) */ import { isKeyDown, isEnterKey, isEscapeKey, KEY_OPEN_MAIL_ID, KEY_COMM_STACK_DEPTH } from '../model/KeyboardShortcuts'; import { Configuration, ConfigurationConstant, EnvironmentCallback, common } from '@kit.AbilityKit'; import { pasteboard } from '@kit.BasicServicesKit'; 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 是未读数,不是总封数)。 */ /** * 写信页签的路由名(宽屏在**右栏**内嵌打开,见 `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'; /* * 顶栏文案的轮播节奏(2026-09-24 用户:「自动轮播,要有消失出现动画」)。 * * 停顿 5.5s、淡入淡出各 260ms:停顿要**明显长于**动画,否则观感是 * "一直在闪"而不是"隔一会儿换一句"。5.5s 是按"一行短句读完"给的。 */ const TOPBAR_ROTATE_MS: number = 5500; const TOPBAR_FADE_MS: number = 260; /* * 顶栏文案的**绝对定位**参数 —— 与三键**同一行、靠左**。 * * 实测(3120×2080 窗口,dumpLayout): * 装饰带 y 281→351(高 70px ≈ 24vp),三键在同一行、靠右(x 2340→2605)。 * ⇒ 文案 y 取装饰带内的居中偏移;x 从窗口左缘起 —— * 左边一句话说当前状态、右边三个系统按钮,一条线上左右相称。 * * ★★ 2026-09-25 这里**改过一次**,记下来免得再走回头路: * 第一版我按"贴住三键左缘"做(右对齐 + padding 三键宽 92), * 用户纠正:「我要求的是与三键同行,但是在左边啊」。 * ⇒ 靠左才是对的。三键宽度那个常量随之**删除**(它只为那个错版存在)。 */ /* 文案与窗口左缘的距离(vp)—— 与内容区起始留白同档 */ const TOPBAR_STRIP_LEFT: number = 16; /* * 顶栏文案字号(vp)。 * * ★ 实测定的,不是随手取:三键图标 38px、按钮 53px 高(1px ≈ 1.91vp), * 12vp 的文案只有 27px ⇒ 比旁边的按钮小一档,看着"没长齐"(用户:「大小也偏小」)。 * 14vp 的文本行高约 33px,与三键图标同量级。 */ const TOPBAR_FONT_SIZE: number = 14; @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; /** * 邮件列表修订号(`MailStore.bump()` / `notifyRemoteChange()` 发布)。 * * ★★ 2026-09-24 新增(用户:「接收邮件不正常」+「自动已读不正常」)。 * * 本栏只在 `aboutToAppear` 拉一次;而 `Navigation` split 模式下列表常驻, * 从详情页返回、或从别的 tab 切回来时**都不会重拉**。 * ⇒ 两种症状: * ① 在详情页标了已读,返回列表那行**还是蓝点**; * ② SSE 告知来了新邮件,但切过去看还是旧的(除非杀进程重进)。 * * 现在监听这个键:详情页标已读、或 SSE 报新邮件 → store 自增 → 本栏重拉。 * * ★ 用 `@StorageProp` 而不是与 `InboxTab` 的 `@State` 双向绑定: * 这里只需要“变化时醒一下”,值本身不用读(窗口的其它 `@StorageProp` 同形)。 */ @StorageProp('agentmail.mail.revision') @Watch('onMailRevChanged') mailRev: number = 0; @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 = ''; /** * 鼠标悬停在哪一封(2in1/PC 有鼠标时才非空)。 * * ★★ 2026-09-24 新增(用户选「悬停反馈」)。 * * ── 为何不用 `.hoverEffect()` ── * `.hoverEffect(HoverEffect.Highlight)` 只画系统自带的灰色叠层, * 而 WebUI 的悬停是 `hover:bg-gray-50`(`MailList.tsx:181/280`)。 * 更重要的是:本仓的选中态 / 未读态已占用底色(`accentSoft`), * 系统叠层会与它们叠成**第三个颜色**。 * 所以自己算三目,与选中/未读共用同一套令牌。 * * ── 为何存 `mail_id` 而不是布尔 ── * 行本体(`MailRow` / `SentRow`)是 `@Builder`,**没有自己的状态** —— * 布尔会变成"悬停一行、同栏所有行全亮"。状态只能在**栏** * (`InboxTab` / `SentTab`)上,所以要存"是哪一封"。 */ @State hoveredMailId: string = ''; aboutToAppear(): void { const ctx = this.getUIContext().getHostContext(); if (ctx !== undefined) { // 加载账号列表 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(); /* * ★★ 2026-09-24:SSE 监听**已搬到 `MainPage`**(App 级,全程在)—— * 这里不再注册。 * * 原处注册的致命问题:本栏是条件挂载的,切到发件箱/授权就被销毁, * 监听跟着没了。详细的因果写在 `MainPage.aboutToAppear` 里。 * * ★ 留着会双重触发(两层都收同一事件)—— 必须只有一处注册。 */ } } aboutToDisappear(): void { /* 本栏不再持有 SSE 监听,所以这里没有 `removeListener`; 原先那两行已随注册一起移走。 */ } /** * 邮件修订号变了 ⇒ 列表需要重拉。 * * 两种来源都走这里(见字段上的注释): * · 详情页标了已读(`MailStore.markReadLocal`); * · SSE 报新邮件(`MailStore.notifyRemoteChange`)。 * * ★ 只重拉、不做“就地改本栏 `@State`”—— `loadInbox` 内部已经处理了 * “已有列表时用服务端结果覆盖”,重拉一次比本栏自己拼一份可靠。 */ onMailRevChanged(): void { 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 }); } /** * 账号筛选菜单(`bindMenu` 的内容)—— 原来自绘在列表头下方的那块。 * * ★★ 2026-09-21 新增(与 `ComposePage.AccountMenu` 同一改法)。 * * 逐项不再挂 `Theme.menuIn()`:那是为了掩盖"下拉推进布局流把列表顶下去", * 而官方菜单不占布局 —— 再挂一层会与系统菜单动画叠在一起。 * * ★ 宽度从原来的 `'100%'`(横铺整栏)收成固定 240:菜单应该贴着锚点, * 而不是占满一行。这正是"看起来不像系统菜单"的主因之一。 */ @Builder AccountFilterMenu() { Column() { Row() { Text('全部邮箱') .fontSize(14).fontColor(this.accountFilter === 'all' ? Theme.accentFor() : Theme.textPrimary) .fontWeight(this.accountFilter === 'all' ? FontWeight.Bold : FontWeight.Normal) .layoutWeight(1) if (this.accountFilter === 'all') { AmIcon({ iconName: 'check', iconSize: 14, iconColor: Theme.accentFor() }) } } .width(240).height(44).padding({ left: 12, right: 12 }) .backgroundColor(this.accountFilter === 'all' ? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent) .attributeModifier(PressEffectModifier.of()) .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) if (this.accountFilter === acct.id) { AmIcon({ iconName: 'check', iconSize: 14, iconColor: Theme.accentFor() }) } } .width(240).height(48).padding({ left: 12, right: 12 }) .backgroundColor(this.accountFilter === acct.id ? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent) .attributeModifier(PressEffectModifier.of()) .onClick(() => { this.accountFilter = acct.id; this.accountName = acct.displayName; this.showAccountPicker = false; this.loadData(); }) }, (acct: AccountInfo) => acct.id) } .width(240) .padding({ top: 4, bottom: 4 }) .alignItems(HorizontalAlign.Start) } 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: this.showAccountPicker ? 90 : 0 }) .animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft }) .margin({ left: 3 }) } .height(32) .attributeModifier(PressEffectModifier.of()) /* * ★★ 2026-09-21 改用 `bindMenu(isShow, content, options)`(**API 11** 的重载)。 * * 我第一版写的是 `bindMenu(this.showAccountPicker ? this.X() : undefined)` —— * 那是 `bindMenu(content, options)`(API 7)那个重载,靠"内容为 undefined * 就不弹"来模拟开关。它能跑,但不是这个场景的官方形状: * · 菜单的显隐**由内容是否为 undefined 隐式决定**(读代码时会疑惑); * · 系统无法把"关闭"回写给状态(要靠 `onDisappear` 手动补)。 * * `isShow` 版直接把"显示与否"当第一个参数,与 `bindSheet($$isShow)` * 同一套语义 —— 两处弹层用同一种写法,不是两种 * ("同一交互不该有两种形状")。 * * ★ 它**支持 `$$` 两向绑定**(@since 11 的注释里写了 `$` 两向), * 所以用户点遮罩/按 Esc 关掉时状态会自动回写 —— 不再依赖 `onDisappear` * 去补(那是我第一版的绕法)。 */ .bindMenu($$this.showAccountPicker, this.AccountFilterMenu()) .onClick(() => { this.showAccountPicker = !this.showAccountPicker; }) } /* * ★★ 2026-09-24 **删掉列表头的未读红圈**(用户:「通信页面 webui 和 app * 存在很大的区别」)。 * * WebUI 的列表头(`MailList.tsx:74-84`)只有三个东西: * ① `

` 收件箱/发件箱; * ② `AccountSwitcher`(多账号才出现); * ③ 计数(`N 组 · M 封`)。 * **没有未读徽标** —— 未读只在页签条上(`CommTabs` 的红色胶囊)。 * * 这里多出来的红圈是鸿蒙自己加的:同一屏上就会看到两个未读数 * (页签条一个、列表头一个),而它们是同一个数字,看着像两个指示。 */ } .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.warnBgFor()) .borderRadius(6) .margin({ top: 6 }) } 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`): *
← **组容器**(一层外框) *