Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/MainPage.ets
JianFeeeee 9d50352e7e 跨端: 鸿蒙顶栏文案(摘要/一言/签名轮播)在三键左边 —— 位置与字号按实测校准
用户四条需求,逐条落地:
  · 「可以在服务器集成一言与签名,同时 app 本地缓存一部分」
  · 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」
  · 「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」
  · 「我要的效果是在退出,最大化,最小化三个按键的左边」

客户端(服务端那半见 31939f2)
  · 新增 `common/TopbarStore.ets`:一言 + 签名的取数与**账号级**缓存
    (键 = 前缀 + accountId,本仓纪律;共用一份会让多账号串台)。
    本地缓存先出(秒开、离线可用),再后台拉一次更新;
    拉失败**保留缓存**、不抛异常 —— 装饰性内容不该成为失败点。
  · 轮播:摘要 / 一言 / 签名三选一轮着显示,5.5s 一条、
    淡出淡入各 260ms(停顿明显长于动画,否则观感是"一直在闪")。
  · 纯文字:不设 background、不加玻璃(用户点名「不要加底」),
    `hitTestBehavior(None)` 不吃事件。

★ 位置:为什么自绘而不 `setWindowTitle`
  官方 `setWindowTitle` **实测确实**能在那一行显示文字(截图验过),
  但它三条硬伤:① 必须保持窗口装饰可见 ⇒ 标题栏横带回来,与刚修好的
  「顶栏沉浸」冲突;② 瞬时替换,做不了用户要的消失出现动画;
  ③ 字号颜色跟随系统。⇒ 装饰仍隐藏,文字自绘在装饰带原位。

★★ 两个"按实测校准"的修正(都是用户看出来的)
  1. **位置**:我先做成"右对齐、贴住三键左缘"——用户纠正
     「我要求的是与三键同行,但是在左边啊」。改成靠左(FlexAlign.Start)。
  2. **对齐与字号**(用户:「行没有对齐,大小也偏小」):
     实测(1px ≈ 1.91vp):
       三键 y 290..343 高 53px、中心 316.5
       我原来 y 296..323 高 27px、中心 309.5  ⇒ **中心差 7px、字号小一档**
     改法:字号 12 → 14vp;垂直对齐从写死的 `y: 8` 改成
     `height(windowInsets.windowDecor)` + `VerticalAlign.Center`。
     改后实测:**中心差 0.0px**、文案高 31px(与三键图标同量级)。

★ 顺带修掉一个逻辑漏洞
  摘要三项计数都是 0 时我返回了空串 ⇒ 整块**不渲染**,顶栏右上什么都没有。
  而"没有未读"恰恰是常态(收件箱清干净了)。加兜底文案「暂无待办」。
  那句"三项都是 0 就不显示"是我按"有信息才显示"想当然写的,
  没考虑"零"本身也是信息。

判据(harmony-2in1 新增 4 条 → 19→23,全部变异验证过)
  · 在三键左边且不破坏沉浸 —— 钉的是**两个约束同时成立**
    (只看一件会放过错误的那版:为了三键左边而恢复标题栏)
  · 纯文字:不许 backgroundColor / backgroundBlurStyle,且不吃事件
  · 摘要为零也要有文案(把兜底改回空串即判红)
  · 一言/签名缓存要账号级、失败要吞掉

★ 判据自己的两个坑(都在注释里记了)
  ① 切片锚点不能用常量的**名字**:`TOPBAR_STRIP_VPAD` 先在文件顶部常量区
     出现一次,从那里往后切会一路包进 `InboxTab`(那里有 backgroundColor),
     于是报"文案加了底色"——报的其实是**别人的代码**。改成锚**使用点**。
  ② 位置断言跟着事实改过一轮:第一版给"贴三键"那个错版背书,
     用户纠正后改成断言靠左。

实测凭据(2in1 模拟器 3120×2080)
  · 文案 x 545..652、y 301..332;三键 x 2362..2567、y 290..343
  · 垂直中心差 0.0px
  · 沉浸保留(装饰仍隐藏)
2026-09-25 18:24:08 +08:00

4202 lines
212 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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` 那一格换成 `<ComposePage/>`,
* 侧栏与列表栏都还在。鸿蒙用 `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<void> {
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<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 (!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`)只有三个东西:
* ① `<h2>` 收件箱/发件箱;
* ② `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`):
* <div className="rounded-lg border"> ← **组容器**(一层外框)
* <button className="glass-card …"> ← 组头
* <div className="pl-5 pr-1 pb-1.5 space-y-0.5"> ← **缩进** + 紧凑间距
* {g.mails.map(m => <MailItem compact />)}
*
* 三个要点逐条对齐:
* ① **缩进** `pl-5`(20px):子邮件明确"在组头之下";
* ② **紧凑间距** `space-y-0.5`(2px):一组之内挤在一起,
* 而**组与组之间**是列表的 6vp —— 疏密本身就是分组信息;
* ③ 组内含选中邮件时给外框上色(`hasActive`),
* 否则展开一个组再滚下去会找不到自己在看哪封。
*/
Column({ space: 2 }) {
this.GroupHeader(g)
if (this.isExpanded(g.key)) {
/* 子邮件整体缩进 20vp(WebUI `pl-5`)—— 缩进是分组的视觉主语 */
Column({ space: 2 }) {
ForEach(g.mails, (m: MailLike) => {
this.MailRow(m)
}, (m: MailLike) => m.source_account_id + ':' + m.mail_id)
}
.width('100%')
.padding({ left: 20, right: 1, bottom: 4 })
}
}
.width('100%')
.borderRadius(Theme.radiusCard)
/* 组内含正在读的那封:给外框上色(WebUI 的 `hasActive`) */
.border({
width: 1,
color: this.groupHasActive(g) ? Theme.accentEdgeFor(this.isDarkNow) : Color.Transparent
})
.backgroundColor(this.groupHasActive(g)
? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent)
}
}
.width('100%')
}, (g: SessionGroup) => g.key)
}
.width('100%').layoutWeight(1)
/* WebUI `.overflow-y-auto` 的上下渐隐(mask-image):卡片滑到边缘不硬截断 */
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
.contentEndOffset(this.navReserve)
.padding({ left: 10, right: 10, top: 10, bottom: 10 })
}
}
.width('100%').height('100%')
.attributeModifier(PaneModifier.plain(this.bgActive))
}
/*
* 这里原本还有一个「写邮件」的 bindSheet:`composeVisible` 从来没被置为 true,
* 也就是说**谁也打不开它** —— 死代码。既然新建入口按用户要求搬成了通信页的悬浮加号,
* 就顺手删掉,免得以后有人以为它是可用的第二条路径。
*/
/**
* 列表里的一封(列表项本体):底色按已读/未读,点它进详情。
*
* 参数类型是 `MailLike`(`MailGrouping.ts` 的接口)而不是 `MailSummary`:
* 折叠后的组里装的是接口类型,ArkTS 不做结构类型匹配,写死具体类就传不进来。
*/
/**
* 这一组里有没有**正在读**的那封。
*
* 对齐 WebUI `MailList.tsx:171`:
* const hasActive = currentMailID ? g.mails.some(m => m.mail_id === currentMailID) : false;
* 它的用途写在那边注释里:「组内含选中邮件时给个边框,否则展开一个组再滚下去
* 会找不到自己在看哪封」—— 组头离得远,光靠子项自己的高亮不够。
*/
private groupHasActive(g: SessionGroup): boolean {
if (this.currentMailId.length === 0) {
return false;
}
for (let i = 0; i < g.mails.length; i++) {
if (g.mails[i].mail_id === this.currentMailId) {
return true;
}
}
return false;
}
/**
* 邮件行的右键菜单内容(`MailRow` 的 `bindContextMenu` 挂它)。
*
* ★★ 2026-09-24 新增(用户选「右键菜单」)。完整理由见 `MailRow` 上那段。
*
* ★ 用 `Menu`/`MenuItem` 官方组件而不是自绘:
* 右键菜单的分寸(贴点击位置、自动避让屏幕边缘、Esc 关闭、
* 点外部关闭、键盘上下选)都是系统的活。自绘要一项项补,
* 而补漏的那一项就是"用起来怪"的来源(本仓 `bindSheet` 那次同一条理由)。
*/
@Builder
MailContextMenu(mail: MailLike) {
Menu() {
/*
* 标记已读 —— 只在未读时给。
* ★ 已读的再标一次是白跑一趟(服务端幂等,但会让接口日志噪声不断);
* 而"菜单里有项点了没反应"比"没这项"更让人困惑。
* 与详情页 `doMarkRead` 里那道 `status !== 'unread'` 同一个口径。
*/
if (mail.status === 'unread') {
MenuItem({ content: '标记已读', labelInfo: '' })
.onClick(() => { this.markRowRead(mail); })
}
MenuItem({ content: '归档会话', labelInfo: '' })
.onClick(() => { this.archiveRow(mail); })
MenuItem({ content: '复制主题', labelInfo: '' })
.onClick(() => { this.copySubject(mail); })
}
}
/**
* 从列表行标记已读(右键菜单项)。
*
* ★ 与详情页 `doMarkRead` **共用同一套两半**:发请求 → 就地改 store → 失败要说出来。
* 不共用的话,两处会慢慢分叉(本仓反复出现的形状)。
* 但这里拿不到详情页那个 `mailApi`(它按账号构造),
* 所以就地建一个同账号的客户端 —— 与 `loadInbox` 遍历账号时同一做法。
*/
private markRowRead(mail: MailLike): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
const acct: AccountInfo | null = acctMgr.getAccount(mail.source_account_id);
if (acct === null) {
/* 账号没了(退出登录中)⇒ 什么都不做,但也别装作成功 */
return;
}
const client: ApiClient = new ApiClient(ctx);
client.setBase(acct.server);
client.setToken(acct.token);
new MailApi(client).markRead(mail.mail_id).then(() => {
/*
* 成功:就地改 store(它内部会 `publishChange()` 广播修订号,
* 本栏与发件箱都会重拉 —— 与详情页标已读走的是同一条路)。
*/
MailStore.getInstance().markReadLocal(mail.mail_id);
}).catch((e: Error) => {
this.getUIContext().getPromptAction().showToast({ message: '标记已读失败: ' + e.message });
});
}
/**
* 归档会话(右键菜单项)。
*
* ★ 与联系人页 `confirmArchive` 同一个服务端入口(`MailApi.archiveContact`)。
* ★ **破坏性操作先确认** —— 与联系人页同一分寸(那里是 `ArchiveConfirm` 弹层)。
* 这里用系统确认框(`AlertDialog`):菜单点一下就直接归档掉一整条会话,
* 误触的代价太大。
*/
private archiveRow(mail: MailLike): void {
/* 会话标题:优先别名,退回主题 —— 与列表上显示的那行一致 */
const title: string = mail.session_alias.length > 0 ? mail.session_alias : mail.subject;
this.getUIContext().showAlertDialog({
title: '归档会话',
message: `确定归档「${title}」?\n归档后这条会话会从列表移除。`,
primaryButton: {
value: '取消',
action: () => {}
},
secondaryButton: {
value: '归档',
fontColor: Theme.danger,
action: () => { this.doArchiveRow(mail); }
}
});
}
/** 真正发归档请求(确认之后) */
private doArchiveRow(mail: MailLike): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
const acct: AccountInfo | null = acctMgr.getAccount(mail.source_account_id);
if (acct === null) {
return;
}
const client: ApiClient = new ApiClient(ctx);
client.setBase(acct.server);
client.setToken(acct.token);
new MailApi(client).archiveContact(mail.session_id).then(() => {
/*
* 成功:就地移除该会话(不等 SSE)—— 与联系人页 `confirmArchive` 同一做法。
* `dropSession` 是 store 上已有的入口,语义就是"这条会话没了"。
*/
MailStore.getInstance().dropSession(mail.session_id);
this.getUIContext().getPromptAction().showToast({ message: '已归档' });
}).catch((e: Error) => {
this.getUIContext().getPromptAction().showToast({ message: '归档失败: ' + e.message });
});
}
/**
* 复制主题到剪贴板(右键菜单项)。
*
* 桌面端高频动作:把邮件主题贴到别处(issue、聊天、搜索)。
* 用 `pasteboard`(系统剪贴板),不自己存变量 —— 那个只在 App 内有效。
*/
private copySubject(mail: MailLike): void {
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, mail.subject);
pasteboard.getSystemPasteboard().setData(data).then(() => {
this.getUIContext().getPromptAction().showToast({ message: '主题已复制' });
}).catch((e: Error) => {
this.getUIContext().getPromptAction().showToast({ message: '复制失败: ' + e.message });
});
}
@Builder
MailRow(mail: MailLike) {
Row() {
this.MailItem(mail)
}
.width('100%').height(64)
/*
* ★★ 2026-09-24 新增:**邮件行的右键菜单**(用户选「右键菜单」)。
*
* ── 这不是对齐项 ──
* WebUI **没有**右键菜单(`grep onContextMenu` 全仓为空)——
* 所以这是 2in1/PC 场景的**新增桌面能力**,不是"WebUI 有而鸿蒙没有"。
* 写明这一点,免得日后审计把它当成缺失项去"补"。
*
* ── 为什么是右键而不是长按 ──
* `ResponseType.RightClick`:鼠标右键 / 触控板双指点按。
* 长按(`LongPress`)留给触屏——两者同时绑会互相抢事件,
* 而列表行的左键单击已经占用了(打开邮件),只剩右键可用。
*
* ── 菜单里放什么(只放**列表层能独立完成**的)──
* · 标记已读 —— `MailApi.markRead`(不依赖详情页的表单上下文)
* · 归档会话 —— 与联系人页同一个 `MailApi.archiveContact` 入口
* · 复制主题 —— 纯本地
* ★ **不放**回复/转发:那两个要详情页的表单(收件人、正文、附件),
* 在列表行上做只能"先跳详情再操作"——那还不如单击。菜单项应当
* **当场就能完成**,凑不可用的入口比没有更坏。
*/
.bindContextMenu(this.MailContextMenu(mail), ResponseType.RightClick, {
/* 右键菜单贴着点击位置出现(官方:RightClick 时在点击位置显示) */
enableArrow: false,
preview: MenuPreviewMode.NONE,
backgroundColor: Theme.surface,
borderRadius: Theme.radiusCard
})
/*
* 底 + 边**成对**决定这张卡的身份,口径逐项对齐 WebUI(`MailList.tsx:280`):
*
* active ? 'bg-blue-50 border-blue-200'
* : 'border-transparent hover:bg-gray-50'
*
* 三级优先:**选中 > 未读 > 普通**。
* · 选中:`accentSoft` + `accentEdge`(`#EFF6FF` / `#BFDBFE`,与 blue-50/200 同值)
* · 未读:`accentSoft`,边仍是普通边框(未读只是"没看过",不是"正在看")
* · 普通:`Theme.surface` + 普通边框
*
* ★ 为什么选中要压过未读:两者底色相同(都是 accentSoft),
* 若只靠底色区分,选中一封未读邮件时**看不出任何变化**。
* 所以选中额外加那圈 `accentEdge` 边 —— 这正是 WebUI 两个 token 并存的原因。
*/
/*
* ★★ 2026-09-21 修「有一个完全不透明的邮件项」(用户原话)。
*
* 原来这里写的是:
* `.backgroundColor(... ? accentSoft : Theme.surface)`
* 而 `Theme.surface` = `$r('sys.color.ohos_id_color_list_card_bg')`
* —— **系统卡片色,α = 1**。于是壁纸开着时,**已读**的邮件行是一块纯白实心板
* (实测像素 `(255,255,255)`,壁纸完全透不出来),
* 而未读/选中那些(`accentSoft`)有半透明感 ——
* 一排里就"单出一张不透明的",正是用户看到的那样。
*
* ── 为什么 `GroupHeader` 没这个毛病 ──
* 它挂的是 `GlassCardModifier.of(this.bgActive)`(玻璃卡基础件),
* 走 `backgroundEffect({radius, saturation})`,**不写实心色**。
* `MailRow` 当时漏了这一步 —— 我手写了颜色,没用那件基础件。
*
* ── 修法 ──
* 与 `GroupHeader` 同一件 `GlassCardModifier`(对齐 WebUI:两者的行都是
* `.glass-card`,`MailList.tsx:280` 与 `:185`)。
*
* ★ 但「未读/选中」的淡蓝底**不能丢** —— WebUI 是
* `glass-card` + `bg-blue-50` 两层叠着(CSS 里后者只改 background-color,
* 玻璃的 border/阴影仍在)。
* ArkUI 的 `.attributeModifier` 是**单一插槽**(`.backgroundColor` 链在 modifier
* 之外时会被 modifier 覆盖),所以这里用 `CompositeModifier` 显式合成:
* ① 有态色(未读/选中)时铺 `accentSoft` —— 它本身就是带 alpha 的令牌;
* ② 无态色时走玻璃卡(透明 + backgroundEffect)。
*/
.attributeModifier(this.currentMailId === mail.mail_id || mail.status === 'unread'
? CompositeModifier.of([
GlassCardModifier.of(this.bgActive, false),
TintModifier.of(Theme.accentSoftFor(this.isDarkNow))
])
: GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.border({
width: 1,
color: this.currentMailId === mail.mail_id ? Theme.accentEdgeFor(this.isDarkNow) : Theme.border
})
.clip(true)
.onHover((isHover: boolean) => {
this.hoveredMailId = isHover ? mail.mail_id : '';
})
.backgroundColor(this.hoveredMailId === mail.mail_id
&& this.currentMailId !== mail.mail_id
&& mail.status !== 'unread'
? Theme.surfaceMuted : Color.Transparent)
.onClick(() => {
this.openMail(mail);
})
}
/**
* 会话组头:一行说清"这是哪条线索、几封、几封没读",点它展开/收起。
*
* 组头取组内**最新一封**的别名与主题(与 WebUI `mailGroups.ts` 同口径):
* 会话主题会随任务推进被改写,最新的那个最贴切。
*/
@Builder
GroupHeader(g: SessionGroup) {
Row() {
/*
* 展开箭头:`.width(20)` 是**给它一个 20vp 的槽位**(与下面的标题对齐),
* 而图标只有 12vp —— 尺寸加在**外层 Stack** 上才会居中;
* 直接链在 `AmIcon` 上会让箭头贴在槽位左侧(2026-09-18 与另外两处一起修)。
* `rotate` 留在图标上(转的是箭头本身,不是那 20vp 的槽位)。
*/
Stack({ alignContent: Alignment.Center }) {
AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.textSubtleFor() })
.rotate({ angle: this.isExpanded(g.key) ? 90 : 0 })
/*
* ★★ 2026-09-21 补:**属性级动画**(用户:「其他界面涉及运动的页面
* webui 也是有类似效果的」)。
*
* 原来这里只写了 `.rotate(...)` —— 角度是**瞬变**的:
* 展开/收起时箭头"啪"地跳 90°,感觉是硬切。
* WebUI 那边是 `transition-transform`(`MailList.tsx:186` 的
* `className="… transition-transform ${open ? 'rotate-90' : ''}"`),
* 角度变化会**平滑补间**。
*
* ArkUI 对应能力就是属性级 `.animation()`:它盯住**绑在它之上**的可动画
* 属性,值一变就自动补间(文档 `arkts-attribute-animation-apis.md`)。
* 用它而不是 `animateTo`:这里角度由 `isExpanded()` 驱动,
* 触发点在 `toggleExpanded()` 里、离得很远,包 `animateTo` 要改三处;
* 属性级动画不需要调用方配合。
*/
.animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft })
}
.width(20)
Column() {
Row() {
Text(g.latest?.from_name ?? '')
.fontSize(12).fontWeight(g.unreadCount > 0 ? FontWeight.Bold : FontWeight.Normal)
.fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
Text(compactMailTime(g.latest?.created_at ?? ''))
.fontSize(10).fontColor(Theme.textSubtleFor()).margin({ left: 8 })
}
.width('100%')
Row() {
if (g.unreadCount > 0) {
Text(g.unreadCount > 99 ? '99+' : g.unreadCount.toString())
.fontSize(9).fontWeight(FontWeight.Bold).fontColor(Theme.accentFg)
.backgroundColor(Theme.accent).borderRadius(8)
.constraintSize({ minWidth: 16 }).height(16).textAlign(TextAlign.Center)
.margin({ right: 6 })
}
Text(g.subject.length > 0 ? g.subject : '(无主题)')
.fontSize(12).fontWeight(g.unreadCount > 0 ? FontWeight.Bold : FontWeight.Normal)
.fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
}
.width('100%').margin({ top: 3 })
Row() {
Text(g.alias.length > 0 ? '.' + g.alias : '(未命名会话)')
.fontSize(10).fontColor(Theme.accentFor())
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
Text(g.mails.length + ' 封').fontSize(10).fontColor(Theme.textSubtleFor()).margin({ left: 8 })
}
.width('100%').margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%').height(78)
.padding({ left: 10, right: 12 })
.alignItems(VerticalAlign.Center)
/*
* ★★ 2026-09-20 简:这里原来写的是
* CompositeModifier.of([GlassCardModifier.of(...), PressFeedbackModifier.of()])
* —— 那是"两个 modifier 要叠"的通用解法,当时**只有这一处**记得叠。
* 现在按压反馈已经**内联进基础卡**(见 `GlassCardModifier.pressable`),
* 单独再挂一遍会挂两个 `onTouch`(第二个覆盖第一个,行为上等价但白挂)。
* 这一处是全仓唯一"以前就叠对了"的,现在反而成了唯一的例外写法。
*/
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
.clip(true)
.attributeModifier(PressEffectModifier.of())
.onClick(() => {
this.toggleExpanded(g.key);
})
}
@Builder
MailItem(mail: MailLike) {
Row() {
Column() {
if (mail.status === 'unread') {
/*
* ★★ 2026-09-21 修:`Circle().fill()` 是 SDK 26 的 API
* (见 `InboxPage.MailItem` 那段完整说明)。换成通用容器画点。
*/
Column()
.width(8).height(8)
.borderRadius(4)
.backgroundColor(Theme.accent)
}
}
.width(20).height('100%').justifyContent(FlexAlign.Center)
Column() {
Row() {
Text(mail.from_name).fontSize(14).fontWeight(mail.status === 'unread' ? FontWeight.Bold : FontWeight.Normal).fontColor(Theme.textPrimary)
if (this.accountFilter === 'all' && mail.source_account_name.length > 0) {
/*
* 账号徽标(聚合模式下才显示)—— **逐项对齐 WebUI**(`MailList.tsx:303-310`):
*
* className="text-[10px] leading-4 px-1.5 rounded-full
* bg-gray-100 text-gray-600 shrink-0 max-w-[80px] truncate"
*
* 即:**中性灰底 + 灰字 + 全圆胶囊 + 最多 80px 截断**。
*
* ★ 原来写的是 `accentSoftFor`(品牌浅蓝底)+ `accentFor`(品牌蓝字)
* + `borderRadius(4)`(圆角方)。三处都不一样:
* · 底色:品牌浅蓝 vs 中性灰 —— 徽标被染成了"我们家的"颜色,
* 而它表达的是"这条来自哪个账号",是**中性的元信息**,
* 不该与主操作抢注意力(WebUI 选 gray-100 就是这个意思);
* · 形状:圆角方 vs 全圆胶囊;
* · 截断:原来没有 maxWidth —— 账号名一长就把主题挤没了。
*/
Text(mail.source_account_name)
.fontSize(10).fontColor(Theme.textMuted)
.backgroundColor(Theme.surfaceMuted).borderRadius(8)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
.constraintSize({ maxWidth: 80 })
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ left: 6 })
}
Blank()
Text(compactMailTime(mail.created_at)).fontSize(10).fontColor(Theme.textSubtleFor())
}
.width('100%')
Text(mail.subject).fontSize(13).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
Text(mail.body_preview).fontSize(12).fontColor(Theme.textSubtleFor())
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
/*
* 「抄送 N」(WebUI `MailList.tsx:350`)。
*
* ★ 2026-09-19 审计补上:此前鸿蒙行上完全没有它 —— 一封抄送给好几个人的邮件
* 在列表里看不出任何区别,得点进去才知道。而 `cc_list` 服务端一直有返回
* (实测回包字段列表里有),只是 `MailLike` 接口漏了这个字段。
*
* ── 附件标记(WebUI `MailList.tsx:351` 那个回形针 + 数字)──
*
* ★★ 2026-09-20 **更正我 2026-09-19 的一个错误结论**。
*
* 那天我在这里写的是「**有意不抄**附件标记」,理由是我"实测"列表接口
* 既没有 `attachments` 也没有 `has_attachments`,所以 WebUI 那个 📎
* 在列表里恒为 0、是死代码。
*
* **那个结论是错的**,错在取证:我只看了一封**没有附件**的邮件,
* 看到 key 不在就断言服务端从不返回它。而
* · 服务端 `GetInbox`(`mail.go:586`)**明确调了 `fillAttachments`**,
* 注释还写着理由:「Agent 靠收件箱列表得知有哪些附件可下载,
* 否则它不知道该调 attachment_id」;
* · `Mail.Attachments` 的 tag 带 **`omitempty`** ⇒
* **没有附件的邮件根本不输出这个 key**。
* 实测 `limit=200`(96 封):带 `attachments` 的 2 封,正是真有附件那两封。
*
* ⇒ WebUI 那个 📎 **不是死代码**,鸿蒙这次补上它。
*
* 有条件才画(两端同口径):没有附件/抄送时不占位,否则每行都多一片空白。
*/
Row({ space: 8 }) {
if (mail.attach_count > 0) {
Row({ space: 2 }) {
AmIcon({ iconName: 'paperclip', iconSize: 10, iconColor: Theme.textSubtleFor() })
Text(mail.attach_count.toString())
.fontSize(10).fontColor(Theme.textSubtleFor())
}
}
if (mail.cc_count > 0) {
Text('抄送 ' + mail.cc_count)
.fontSize(10).fontColor(Theme.textSubtleFor())
}
}
.margin({ top: 3 })
}
.layoutWeight(1).height('100%')
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
/*
* 收件箱行里的档位徽标:只写**中文档位**(`permissionLabel`)。
*
* 为什么不带强制力标记:收件箱列表接口(`/me/mail/inbox`)的每条邮件里
* **没有** `permission_enforcement`(那是会话级的)—— 这里画一个标记就等于
* 编一个"平台做到了什么"出来。强制力标记只出现在拿得到该字段的地方(卡片视图)。
*/
if (permissionLabel(mail.permission_mode).length > 0) {
Text(permissionLabel(mail.permission_mode))
.fontSize(10).fontColor(Theme.permFg(mail.permission_mode))
.backgroundColor(Theme.permBg(mail.permission_mode)).borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
}
}
.width('100%').height('100%')
.padding({ left: 12, right: 12 })
.alignItems(VerticalAlign.Center)
}
}
/*
* 平级的「会话」tab 已撤(2026-09-14,P2a 收尾)。
*
* 撤掉的依据是"信息没有丢":那张会话列表独有的一列 ——
* 会话别名 / 主题、对方 from_agent、邮件数、权限档、往返预算(used/max)、status ——
* 现在落在两处:
* · 收件箱那栏**按会话折叠**,组头就是会话(别名/主题/未读/封数),点开是那几封;
* · 联系人那栏的**卡片视图**是会话的进度视角(主题主角、最新摘要谁说的、
* 权限档 + 强制力、往返预算条)。
* 其中 `status`(active/archived) 与 `from_agent` 参考实现(WebUI `WorkCard.tsx`)
* **也不显示** —— 判据 `卡片字段两边一致` 钉住这一点:哪天 WebUI 补上了,
* 那条判据会红,提醒鸿蒙跟着补,而不是悄悄地少一块。
*/
/*
* ─────────────────────── 发件箱(通信页第二栏) ───────────────────────
*
* 与收件箱**同构**(同一套折叠、同一套行),差别只有三处:
* ① 数据来自 `GET /me/mail/sent`;② 行上显示的是**收件人**(`to_name`);
* ③ **不筛权限邮件** —— 人发不出权限请求(那是 Agent 发的),筛也筛不掉什么,
* 而"不假设数据一定干净"是 WebUI 的原话(`MailList.tsx` 里就这么写的)。
*/
@Component
struct SentTab {
/** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */
@Prop bgActive: boolean = false;
/**
* 正在看的那一封(`CommPage` 下发)—— 见 `InboxTab.currentMailId` 的注释。
* 发件箱的每一行也要高亮,否则"从发件箱点开一封"就断了同一条线索。
*/
@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 = '';
/** 深浅色(`InboxTab` 同名属性同一口径:`Theme` 是静态类,算不出主题,走 AppStorage) */
@StorageProp('agentmail.appearance.isDark') isDarkNow: boolean = false;
/** 底部悬浮条高度(窄屏非 0)——理由见 `InboxTab` 的 `navReserve` */
@Prop navReserve: number = 0;
/** 选中邮件回调 */
onOpenMail: (mailId: string, accountId: string) => void = (): void => {};
@State groups: SessionGroup[] = [];
@State expandedKeys: string[] = [];
@State loading: boolean = false;
@State error: string = '';
@State loaded: number = 0;
/*
* 邮件修订号 —— 与 `InboxTab` 那一个**同键同形**(完整理由写在那里)。
*
* ★ 2026-09-24:发件箱同样只在 `aboutToAppear` 拉一次,
* SSE 报新邮件 / 详情页改了状态时不会重拉。两栏是同一块
* “切走了就不刷新”的病 ⇒ 同一个键、同一套写法,不许分叉。
*/
@StorageProp('agentmail.mail.revision') @Watch('onMailRevChanged') mailRev: number = 0;
aboutToAppear(): void {
this.load();
}
/** 修订号变了 ⇒ 重拉(与 `InboxTab.onMailRevChanged` 同形同理由) */
onMailRevChanged(): void {
this.load();
}
/**
* 取数**交给 `MailStore`**(与收件箱同一份实现)。
*
* ★★ 2026-09-20 改。这一段原来是**抄了一遍**多账号聚合:
* 遍历账号 → 每个建 `ApiClient` → `sent()` → 补 `source_account_*` 与
* `cc_count` → 最后 `groupMailsBySession`。与收件箱里那 107 行是同源复制品
* (注释里还写着"同收件箱"—— 说明抄的时候就知道是重复)。
*
* 用户要做的 **A** 就是这件事:把散在各处的取数收到 store。
* 收件箱那半已经收了(`InboxTab.loadData` 现在 3 行),发件箱这片是剩下的。
*
* ★ 顺带修掉 `MailStore.loadSent` 里一个真 bug(`snap.groups = []`)——
* 见那个方法里的注释:接上 store 之前它是死代码,接上之后
* 发件箱会**一片空白**。这就是"写完没收口"的代价,也是为什么要
* 先接一个调用点再算完 —— 死代码不会自己暴露错误。
*/
async load(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const store: MailStore = MailStore.getInstance();
await store.loadSent(ctx, 'all');
this.applyStoreSnapshot(store.snapshot);
}
/** 把 store 快照搬到本组件的 `@State`(`InboxTab` 里同名同形) */
private applyStoreSnapshot(snap: MailSnapshot): void {
this.groups = snap.groups;
this.loaded = snap.loaded;
this.loading = snap.loading;
this.error = snap.error;
}
isExpanded(key: string): boolean {
return this.expandedKeys.indexOf(key) >= 0;
}
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);
}
@Builder
SentRow(mail: MailLike) {
Column() {
Row() {
// 发件箱里想知道的是"发给谁了" —— 收件人是这一栏的主角
Text('致 ' + (mail.to_name.length > 0 ? mail.to_name : '(未记录收件人)'))
.fontSize(13).fontWeight(FontWeight.Medium).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
/*
* ★★ 2026-09-20 修:这里原来直接渲染 `mail.created_at` ——
* 屏上印的是原始 ISO,实测:`2026-09-19T02:55:33.10099Z`
* (23 个字符,把「致 homeagent」那一行挤到换行)。
*
* WebUI 三种卡片**全部都格式化**(`MailList.tsx:262/151`、
* `PermissionList.tsx:251/143` 都是 `toLocaleString('zh-CN', {month,day,hour,minute})`
* ⇒ `MM/DD HH:mm`)。鸿蒙的 `compactMailTime` 就是那个格式的实现,
* 收件箱行(L813)一直在用,这三处漏了。
*/
Text(compactMailTime(mail.created_at)).fontSize(10).fontColor(Theme.textSubtleFor())
}
.width('100%')
Text(mail.subject.length > 0 ? mail.subject : '(无主题)')
.fontSize(13).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
/*
* `?? ''` 不能省:服务端 `BodyPreview` 带 `omitempty`
* (`models.go:183`),而它的值就是 `Body`(≤200 字截断)——
* **空正文的邮件 → 预览是空串 → 服务端整个 key 都不输出**
* ⇒ `undefined.length` 抛 TypeError。
* 实测那批 96 封恰好都有正文,所以"看起来没问题"—— 那正是这个坑的形态:
* 拿一批恰好非空的数据是验不出它的(见 Models.ets 顶部那段教训)。
*/
if ((mail.body_preview ?? '').length > 0) {
Text(mail.body_preview)
.fontSize(11).fontColor(Theme.textMuted)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
}
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
/*
* ★★ 2026-09-21 修(真 bug,用户:「发件箱存在严重问题」)——
*
* `SentRow` **一个修饰符都没挂**。而同文件里所有别的列表行都挂着:
* `MailRow` L898/902 CompositeModifier / GlassCardModifier
* `GroupHeader` L996 GlassCardModifier
* `PermissionTab` L1373/1726 …
*
* 后果**不是"少一层好看",是读不出来**。设备实测(宽屏 3184、发件箱):
* 行标题 `rgb(209,210,212)` 压底 `rgb(241,242,244)` ⇒ **1.35:1**
* 正文预览 `rgb(224,224,224)` 压底 `rgb(254,254,254)` ⇒ **1.31:1**
* 时间 `rgb(234,235,237)` 压底 `rgb(241,242,244)` ⇒ **1.06:1**
* 三行都远低于 WCAG AA(4.5:1)。
*
* ── 为什么"少个修饰符"会变成"字看不见" ──
* 没有卡底 ⇒ 行底就是**壁纸自己**。而这个壁纸是用户头像图(浅色、
* 大面积近似白)⇒ `Theme.textPrimary`(浅色下是深字)本该正常,
* 但实测拿到的是**浅灰字**:因为行里那几行用的是 `textPrimary`/
* `textMuted`/`textSubtle`,而**壁纸本身**在那些位置恰好是浅灰
* (`241,242,244`)—— 字色与底色**同向**,就掉到 1.3:1。
* 对齐 WebUI:邮件列表的每一行都是 `.glass-card`
* (`MailList.tsx:280` 收件箱 / WebUI 发件箱同一件),
* **玻璃卡的作用正是把"壁纸不可预知"变成"基材恒为白"** ——
* 没有这层,文字就得跟用户的壁纸赌运气。
*
* ── 为什么不只补一句 `backgroundColor` ──
* 收件箱那条路径(上面那段长注释)已经踩过同一个坑:
* 手写实心色 ⇒ 壁纸透不出来 ⇒ 一行里"单出一张不透明的"。
* 所以这里走**同一件基础件**,不手写颜色。
*
* ── 选中态 ──
* WebUI 发件箱的行也能被选中(`MailList.tsx:279` 的 `active`),
* 而本页 `currentMailId` 就是"正在读的那一封"。用与 `MailRow`
* **同一个三目**(选中 > 未读 > 普通)—— 发件箱没有未读概念,
* 所以只有两档。
*/
.attributeModifier(this.currentMailId === mail.mail_id
? CompositeModifier.of([
GlassCardModifier.of(this.bgActive, false),
TintModifier.of(Theme.accentSoftFor(this.isDarkNow))
])
: GlassCardModifier.of(this.bgActive))
.border({
width: 1,
color: this.currentMailId === mail.mail_id ? Theme.accentEdgeFor(this.isDarkNow) : Theme.border
})
.borderRadius(Theme.radiusCard)
.clip(true)
/*
* ★★ 2026-09-24 补 `.onClick`(用户:「发件箱内容也点不开」)。
*
* ── 实测复现 ──
* 点发件箱里「致 pi」那一行:
* · 日志里**没有** `GET /mail/{id}`(收件箱同样操作是会发的);
* · 右栏仍是占位(「选择一封邮件查看…」)。
* ⇒ 点击事件根本到不了处理函数。
*
* ── 根因 ──
* 点击原来挂在**外层** `Column` 上(`SentTab` 的 `ListItem` 里):
* Column() { this.SentRow(m) }
* .width('100%').margin({ bottom: 6 })
* .onClick(() => { this.openMail(m); })
* 而 `SentRow` **自己一个 `.onClick` 都没有** —— 收件箱的 `MailRow`
* 是挂在行自己身上的(同位置 `.clip(true)` 之后直接 `.onClick`)。
* 本仓反复出现的形状:**同一件事两处各写一遍,然后慢慢分叉**
* (这段注释上方才记过 `SentRow` 漏挂卡片修饰符的那次,同一处、同一类错)。
*
* ⇒ 与 `MailRow` 取同形:卡在行自己身上,点击也挂在行自己身上。
*/
.onHover((isHover: boolean) => {
this.hoveredMailId = isHover ? mail.mail_id : '';
})
.backgroundColor(this.hoveredMailId === mail.mail_id
&& this.currentMailId !== mail.mail_id
? Theme.surfaceMuted : Color.Transparent)
.onClick(() => {
this.openMail(mail);
})
}
@Builder
SentGroupHeader(g: SessionGroup) {
Row() {
Text(this.isExpanded(g.key) ? '▾' : '▸')
.fontSize(12).fontColor(Theme.textMuted).width(18)
Column() {
Text(g.alias.length > 0 ? g.alias : '(未命名会话)')
.fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text(g.subject.length > 0 ? g.subject : '(无主题)')
.fontSize(12).fontColor(Theme.textMuted)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Blank()
Text(g.mails.length + ' 封').fontSize(11).fontColor(Theme.textSubtleFor())
}
.width('100%').margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%').height(60)
.padding({ left: 12, right: 12 })
.alignItems(VerticalAlign.Center)
/*
* ★★ 2026-09-21 修(真 bug,用户:「发件箱存在严重问题」)——
* 会话头原来铺的是 `Theme.surfaceMuted`
* (`$r('sys.color.ohos_id_color_sub_background')`,**不透明**)。
*
* 与收件箱 `GroupHeader` 是**同一个错误的两个副本**:
* 收件箱那个已经改成 `GlassCardModifier`(L996),发件箱这个漏了。
*
* 不透明底的代价:壁纸开着时每一条组头都是一块**实心灰板**,
* 与本仓反复强调的「玻璃 = 白 + alpha、**不是**不透明材质」正好相反。
*
* ⇒ 与 `GroupHeader` 同一件基础件(不手写颜色)。
*/
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
.margin({ bottom: 6 })
.clip(true)
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.toggleExpanded(g.key); })
}
/** 顶栏右侧:已加载封数 */
@Builder
SentCountTrailing() {
if (this.loaded > 0) {
Text(this.loaded + ' 封').fontSize(12).fontColor(Theme.textSubtleFor())
.margin({ right: 8 })
}
}
build() {
Column() {
/*
* ★ 2026-09-20 改用**统一顶栏**(用户:「所有的顶栏都应该应用玻璃圆框效果」)。
*
* 这里此前有过一段反复:先写死 `Theme.surface`(实心白条 → 用户
* 「一个横着过去的白条,我真的服了」),再改成"与页面底同口径"的透明通栏。
* 两次都还是**通栏**,而底栏是悬浮圆框 —— 同一根竖轴上两种风格。
* 现在统一走 `AppHeader`:圆框 + 与底栏同值的高度/圆角/侧留白。
*/
AppHeader({
title: '发件箱',
showBack: false,
active: this.bgActive,
topInsetPx: 0
}) {
this.SentCountTrailing()
}
if (this.loading) {
Column() { LoadingProgress().width(32).height(32) }
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.error.length > 0) {
Column() { Text(this.error).fontSize(13).fontColor(Theme.dangerFor()) }
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.groups.length === 0) {
/*
* 空态要**有说明**(P2 的验收条件):只说「暂无邮件」说不清是"没有信"
* 还是"这一栏本来就不放东西"。主句与 WebUI 的 `MailList.tsx` 逐字一致,
* 副句说清这一栏是干什么的。
*/
Column() {
AmIcon({ iconName: 'sent', iconSize: 36, iconColor: Theme.textSubtleFor() }).margin({ bottom: 8 })
Text(emptyTitle('sent')).fontSize(15).fontColor(Theme.textMuted)
Text(emptyHint('sent')).fontSize(12).fontColor(Theme.textSubtleFor()).margin({ top: 6 })
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
List({ space: 6 }) {
ForEach(this.groups, (g: SessionGroup) => {
ListItem() {
Column() {
/*
* ★★ 2026-09-24 修(用户:「发件箱内容也点不开」——补了 `.onClick` 仍点不开)。
*
* ── 根因:单封组多渲染了一个**不可点的组头** ──
* 这里原来是“先无条件渲染 `SentGroupHeader(g)`,单封组再叠一个 `SentRow`”。
* 于是一张卡的上半部(组头:别名/主题/N 封)**没有任何点击处理**,
* 只有下半部(`SentRow`:致 xxx/正文预览)能点。实测点 y=630
* (组头区)完全无反应 —— 而人点卡片时很难正好落在下半部。
*
* 更矛盾的是:`isFlatGroup` 的注释自己写着
* 「单封不成组:套一个可折叠的组头只是多一次点击」
* —— 而这里恰恰就给它套了组头,**注释与实现直接相反**。
*
* 收件箱(`InboxTab`,L593)一直是正确形状:
* if (isFlatGroup(g)) { this.MailRow(g.mails[0]) }
* else { 组头 + 子行 }
* ⇒ 与它取同形:**单封组只渲染 `SentRow`,不渲染组头**。
*
* ★ 两者取同形之后,上一轮补在 `SentRow` 自己身上的 `.onClick`
* 才真正覆盖整张卡(原来只覆盖了半张)。
*/
if (!isFlatGroup(g)) {
this.SentGroupHeader(g)
} else {
this.SentRow(g.mails[0])
}
}
.width('100%')
}
.width('100%')
if (!isFlatGroup(g) && this.isExpanded(g.key)) {
ForEach(g.mails, (m: MailLike) => {
ListItem() {
/*
* ★★ 2026-09-21 修:这里原本再包一层
* `.attributeModifier(GlassCardModifier.of(this.bgActive))`
* —— 而 `SentRow` 自己也挂了一份(刚补的)。
* 两层玻璃卡叠加会让 `backgroundEffect` 走两次,
* 且外层的 `border`/`borderRadius` 与内层重复。
* 收件箱那边(`MailRow`)就是"卡在行自己身上、
* 调用点只给缩进和间距",这里改成同一形状。
*/
Column() {
this.SentRow(m)
}
.width('100%')
.margin({ bottom: 6 })
/*
* ★★ 2026-09-24 删掉这里的 `.onClick`(用户:「发件箱内容也点不开」)。
*
* 点击现在挂在 `SentRow` **自己身上**(与收件箱的 `MailRow` 同形)——
* 原来挂在这一层时,**扁平组那条路根本不经过它**:
* `isFlatGroup(g)` 分支直接 `this.SentRow(g.mails[0])`,外层没有点击。
* 而用户的数据恰好就是 1 封不成组(发件箱「测试-4」)⇒ 完全点不动。
*
* 留着它还会变成"同一行两处都能点"—— 本仓反复出现的那个形状。
*/
}
.width('100%')
}, (m: MailLike) => m.mail_id)
}
}, (g: SessionGroup) => g.key)
}
.width('100%').layoutWeight(1)
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
.contentEndOffset(this.navReserve)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
}
}
.width('100%').height('100%')
.attributeModifier(PaneModifier.plain(this.bgActive))
}
}
/*
* ─────────────────────── 通信页(底部第一项) ───────────────────────
*
* 用户(2026-09-14):「收件发件授权改为一个导航项,通过内部导航区分,
* 然后新建作为他们内部的一个悬浮的圆形加号」。
*
* 三件事都在这一层:
* ① **内部页签**(下划线式,不是浮动的白胶囊 —— WebUI 侧用户原话是
* 「通信页面的二级页面与其他位置极其割裂」,白胶囊看起来是硬贴上去的另一套控件);
* ② **徽标**:收件箱红(未读)、授权橙(待决策)、发件箱无。合并成一个导航项之后,
* 底部导航上看不到"授权有 3 个在等我"了,这个信息不能丢 —— 它比未读更急;
* ③ **悬浮圆形加号**:三个栏都要能新建,所以它挂在这一层,而不是某个栏里。
*
* 徽标数字**由本层自己拉**(`inbox` + `pending` 各一次),不依赖某个 pane 的加载:
* 否则切到发件箱时,收件箱的未读徽标就没了 —— 而它恰恰是"别处有东西等你看"的提示。
* 多一次请求换"徽标始终是对的",这个交换是划算的。
*/
@Component
struct CommPage {
/** 背景开启时,本页与其三个 pane 的页面底都要让出(否则壁纸全被盖住) */
@Prop bgActive: boolean = false;
/** 底部悬浮条高度(窄屏非 0,宽屏 0)——透传给三个 pane 作为列表末尾让位 */
@Prop navReserve: number = 0;
@State commTab: string = 'inbox';
@State unreadCount: number = 0;
@State pendingCount: number = 0;
/**
* 当前栏**取回来**的封数(摘要文案用)。
*
* ★ 为什么不读各 pane 的 `loaded`:三个 pane 是**条件挂载**的,
* 切走就销毁 ⇒ 它们的 `loaded` 在别的栏里读不到(也读不准)。
* 而摘要是"这一栏有多少东西"的展示,必须在栏切换时都有值。
* 所以由 `refreshCounts()`(那一层拉徽标数据时)顺手记下来。
*/
@State loaded: number = 0;
/*
* 徽标数的 AppStorage 键 —— 由刚才那个窗格写、由导航栏读(见发布处注释)。
* 常量而不是字面量:拼错 `AppStorage.get('xxx')` 不报错、只会恒为 undefined,
* 而那正好是"徽标永远不出现"这个症状 —— 与 windowInsets 同一个坑。
*/
private readonly KEY_NAV_BADGE_UNREAD: string = 'agentmail.nav.unread';
private readonly KEY_NAV_BADGE_PENDING: string = 'agentmail.nav.pending';
private readonly KEY_NAV_BADGE_CONTACTS: string = 'agentmail.nav.contacts';
/** Navigation 路由栈:宽屏 Split 时列表+详情并排,窄屏 Stack 时详情 push 覆盖 */
private navPathStack: NavPathStack = new NavPathStack();
/**
* 正在看的那一封(本页拥有,下发给 `InboxTab`/`SentTab`)—— 见
* `InboxTab.currentMailId` 的注释。
*/
@State currentMailId: string = '';
aboutToAppear(): void {
/*
* ★ 2in1 快捷键的**接手方**(用户 2026-09-21:「快捷键打开发信页面」)。
*
* 本页是**条件挂载**的(`if (this.currentIndex === 0)`)⇒ 用户按键时它
* 可能还没实例化。所以两半都要有:
* · 登记监听 —— 用户此刻就在通信页时,当场响应;
* · `consume()` —— 用户此刻在别的页时,请求存在格子里,
* 本页挂载后立刻取走(否则按了快捷键一切换页面就该弹写信,
* 却因为"没人在听"而丢掉)。
*/
ComposeIntent.setListener(() => { this.openCompose(); });
/*
* Esc = 返回上一级(用户:「esc返回上一级」)。
*
* 在**根**上收到键、在这里执行 —— 因为 `navPathStack` 只有本组件持有。
* 弹完要重发层数,否则第二下 Esc 会以为还有层(或反之)。
*/
PopIntent.setListener(() => {
if (this.navPathStack.size() > 0) {
this.navPathStack.pop();
AppStorage.setOrCreate<string>(KEY_OPEN_MAIL_ID, '');
}
this.publishStackDepth();
});
if (ComposeIntent.consume()) {
this.openCompose();
}
this.refreshCounts();
/*
* ★ 注册"点通知要跳转"的回调(2026-09-17 真机实测补的**另一半**)。
*
* 冷启靠下面的 consumePendingRoute 就够了;但**应用已在运行时点通知**时,
* 本页早就挂载完了、`aboutToAppear` 不会重跑 ⇒ 只写 `pendingRoute` 是**没人读**的。
* 实测(模拟器,`aa start --ps data '{…open_mail…}'` 两次):冷启进了详情页,
* 热启**一次都没进** —— 用户看到的是"点了通知,App 弹到前台,停在列表"。
* 所以这里登记回调,由 `EntryAbility.onNewWant` → `PushService.deliverRoute` 直接叫醒。
*/
PushService.setRouteListener((route: PushRoute): void => {
this.navigateToRoute(route);
});
this.consumePendingRoute();
}
aboutToDisappear(): void {
/* 摘掉快捷键监听 —— 否则会唤醒一个已销毁的组件 */
ComposeIntent.clearListener();
PopIntent.clearListener();
// 必须摘掉:留着会叫醒一个已销毁的页面(跳转落空,还容易误判成"推送坏了")
PushService.clearRouteListener();
}
/** 跳转到通知指定的那封信(冷启与热启**共用这一处落点**,两条路不许各写一遍) */
private navigateToRoute(route: PushRoute): void {
/*
* 账号:通知的 `data` 里只有 mail_id/session_id(服务端契约如此),没有 account_id。
* 所以用**当前活跃账号** —— 点通知的人就是本机正在用的那个人。
*/
const ctx = this.getUIContext().getHostContext();
let accountId: string = '';
if (ctx !== undefined) {
accountId = AccountManager.getInstance(ctx).getActiveId();
}
// 通知落的就是收件箱里那封信 ⇒ 先把栏切回收件箱,再压详情页
this.commTab = 'inbox';
this.openMail(route.mailId, accountId);
}
/**
* 消费「点通知**冷启**」留下的待处理跳转(收尾项,pi 邮件 `66bbd929` §三)。
*
* 断链在哪:`EntryAbility` 已经把通知的 `data` 解析成 `PushService.pendingRoute`
* (冷启在 `onCreate`、热启在 `onNewWant`),**但没有任何东西读它** ——
* 于是"点通知打开那封信"这三件的最后一件是空的:通知会拉起 App,
* 然后停在列表页,用户还得自己找那封信。
*
* 为什么在这里消费:跳转的落点是**邮件详情**,而详情页是这个
* `Navigation` 的 `navPathStack` 上的路由 —— 这个栈只有 `CommPage` 持有
* (`openMail` 就是往它上面 push)。在别处消费就得跨组件去够这个栈。
*
* 三条边界:
* ① 没有待处理目标(正常启动)⇒ 什么都不做,**不清栈、不跳转**;
* ② 解析不出目标的 want 在 `EntryAbility` 那层就已经被丢掉了(静默),
* 所以到这里一定有 mail_id;
* ③ 消费后**立刻清空**那个静态格子:它是"待处理"而不是"当前页" ——
* 不清的话,用户返回列表再进这一页时会被再跳一次(永远回不到列表)。
*/
private consumePendingRoute(): void {
const route: PushRoute | undefined = PushService.pendingRoute;
if (route === undefined) {
return;
}
PushService.pendingRoute = undefined;
this.navigateToRoute(route);
}
/** 徽标数字:未读(收件箱里**要读的**那些)+ 待决策(授权栏) */
async refreshCounts(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
try {
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
await acctMgr.load();
const accounts: AccountInfo[] = acctMgr.getAccounts();
let unread: number = 0;
let pending: number = 0;
/* 摘要文案要的"这一栏有多少封"(同一次遍历里就有数,不另发请求) */
let loadedHere: number = 0;
for (let i = 0; i < accounts.length; i++) {
const acct: AccountInfo = accounts[i];
try {
const c: ApiClient = new ApiClient(ctx);
c.setBase(acct.server);
c.setToken(acct.token);
const api: MailApi = new MailApi(c);
const inbox: InboxResponse = await api.inbox('all', INBOX_PAGE_SIZE);
// 与收件箱那一栏同口径:权限邮件不算"要读的",它们归授权栏
const split: MailSplit = splitByPermission(inbox.mails);
for (let j = 0; j < split.normal.length; j++) {
if (split.normal[j].status === 'unread') {
unread += 1;
}
}
loadedHere += split.normal.length;
const pend: PendingResponse = await api.pendingPermissions();
pending += pend.requests.length;
} catch (e) {
// 单个账号失败不影响其他账号的徽标
}
}
this.unreadCount = unread;
this.pendingCount = pending;
/* 摘要文案要的"这一栏有多少封" —— 顺手记下(同一个循环里已经有数) */
this.loaded = loadedHere;
/*
* ★ 同时**发布到 AppStorage** —— 底栏与侧栏(MainPage 的兄弟分支)要读它。
*
* 为什么不把这两个数提到 MainPage:它们是"通信"这个窗格的数据,
* 由这个窗格自己拉(每账号一次 inbox + pendingPermissions)。
* 提到父级会让 MainPage 在**从没进过通信页**时也去发请求。
* 而徽标必须在导航栏上可见(那是它的意义),所以走 AppStorage 单向发布:
* 窗格算 → 写 → 导航栏读。这与 `KEY_WINDOW_INSETS` 同一套机制。
*/
AppStorage.setOrCreate(this.KEY_NAV_BADGE_UNREAD, unread);
AppStorage.setOrCreate(this.KEY_NAV_BADGE_PENDING, pending);
} catch (e) {
// 徽标拉不到就不显示(比显示一个错的数字好)
}
}
/** 写一封全新的(`accountId` 为空时用当前活跃账号)。 */
openCompose(): void {
const ctx = this.getUIContext().getHostContext();
let accountId: string = '';
if (ctx !== undefined) {
accountId = AccountManager.getInstance(ctx).getActiveId();
}
this.openComposeWith(accountId);
}
/**
* 按指定发信账号打开写信。
*
* ★★ 2026-09-20 改(用户:「写邮件 webui 的宽屏样式不是在右侧打开吗」)。
*
* 原来是 `router.pushUrl('pages/ComposePage')` —— 一个盖住**全屏**的独立页,
* 左侧的列表栏整片消失。而 WebUI 宽屏下写信只是把**右栏**换掉
* (`App.tsx:179`:`const main = composing ? <ComposePage/> : ...`),
* 侧栏与列表栏都还在。
*
* 改走本页自己的 `Navigation` 栈:`mode(Auto)` 按宽度自动决定
* **并排(宽屏 ⇒ 写信在右栏)** 还是 **覆盖(窄屏 ⇒ 整页铺开)** ——
* 两条路径同一套代码,不自己判断宽窄(这是 `Navigation` 本来就该干的事)。
*
* 为什么收一个 `accountId`:收件箱里有"按当前筛选账号写信"(`InboxTab`),
* 收件箱外有"用活跃账号写信"(`CommPage` 的 FAB)。两条入口共用这个方法。
*/
/**
* 打开写信 —— **带共享元素转场**(球 → 整页)。
*
* 官方 FAQ `faqs-arkui-991` 的步骤 3 原文:
* 「在页面跳转时增加显示动画效果:
* `this.getUIContext().animateTo({ duration }, () => {
* this.navPathStack.pushPath({ name: 'nextB' }, false); })`」
*
* ⇒ `pushPath` **必须在 `animateTo` 的闭包内**。这是 `geometryTransition`
* 生效的硬条件(官方文档:「必须配合 `animateTo` 使用才有动画效果…
* 不支持 `animation` 动画」)。
*
* 时长/曲线由 `Motion.morph` 里的 `Theme.springResponsive` 决定
* (官方 `responsiveSpringMotion`,定位就是"跟手/衔接"场景)。
* ★ 2026-09-21 改:原来这里写「时长取 `Theme.durMorph`(220) —— WebUI FLIP 的原值」,
* 而弹簧曲线下 **duration 不生效**(SDK 原文),那个令牌已删。
*/
openComposeWithMorph(): void {
const ctx = this.getUIContext().getHostContext();
let accountId: string = '';
if (ctx !== undefined) {
accountId = AccountManager.getInstance(ctx).getActiveId();
}
Motion.morph(this.getUIContext(), () => {
this.openComposeWith(accountId);
});
}
openComposeWith(accountId: string): void {
const params: ComposeParams = { to: '', reply_to: '', session_alias: '', account_id: accountId };
this.navPathStack.pushPath({ name: COMPOSE_ROUTE, param: params });
/* 写信也占一层 ⇒ Esc 要能退它(见 publishStackDepth 的注释) */
this.publishStackDepth();
}
/**
* 顶栏那块文案(页签条右侧)。
*
* ★★ 用户三条硬要求,逐条对应:
* · 「纯文字不要加底」→ **不设** backgroundColor / 不加玻璃材质。
* 它就在页签条那行上,页签条本身是玻璃族 —— 再叠一层底会变成
* "条里又一块小条"(本仓 `bindSheet` 那次同一条教训:自绘会多一层)。
* · 「显示摘要不显示一言,显示一言不显示摘要」→ 一次只渲染一条
* (`topbarTexts()[topIndex % len]`),不是并排两个。
* · 「自动轮播,要有消失出现动画」→ `topOpacity` 由定时器驱动。
*
* ★ 宽度用 `layoutWeight(1)` + 右对齐,而不是固定宽:
* 文案长短差很多(「已发 3 封」vs 一句一言),固定宽会截断或留大片空。
*/
@Builder
CommTabBar() {
Row() {
ForEach(COMM_TABS, (key: string) => {
Row() {
Text(commTabLabel(key))
.fontSize(13)
.fontWeight(this.commTab === key ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.commTab === key ? Theme.accentFor() : Theme.textMuted)
if (badgeText(badgeCount(key, this.unreadCount, this.pendingCount)).length > 0) {
Text(badgeText(badgeCount(key, this.unreadCount, this.pendingCount)))
.fontSize(10).fontColor(Theme.accentFg)
// 红=有东西要读、橙=有人被卡住(更急);色值来自 Theme,页面不自己挑
.backgroundColor(badgeTone(key) === 'warn' ? Theme.warnFg : Theme.danger)
.borderRadius(9)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.margin({ left: 4 })
}
}
.justifyContent(FlexAlign.Center)
.layoutWeight(1)
/*
* 用 `TAB_BAR_HEIGHT` 而不是字面量 44 —— 它同时决定 `TAB_BAR_RADIUS`
* (半径 = 高/2)。写成两个字面量的话,哪天改高忘了改半径,
* 半径又会超过一半、圆角重新变成怪形(正是 2026-09-21 修的那个 bug)。
*/
.height(TAB_BAR_HEIGHT)
// 选中态用**下划线**(与 WebUI 的 `border-b-2` 同一观感),不用浮动白胶囊
.border({ width: { bottom: this.commTab === key ? 2 : 0 }, color: Theme.accent })
.attributeModifier(PressEffectModifier.of())
.onClick(() => {
this.commTab = normalizeCommTab(key);
this.navPathStack.clear();
this.publishStackDepth();
this.refreshCounts();
})
}, (key: string) => key)
/*
* 顶栏文案(摘要 / 一言 / 签名轮播)。
*
* ★ 放在页签条的**同一个 Row** 里、排在三个页签之后 ——
* 这样它天然落在页面右上(三键的下方),与左边三个页签形成左右平衡,
* 而不是另起一行(那会多一条横带,用户要的是"纯文字不要加底")。
*/
}
/*
* ─────────────────────── 页签条造型:三次反转记在这里 ───────────────────────
*
* ① **09-20 之前**:`backgroundColor(Theme.surface)`(系统卡片色,实心白)。
* 当时它是整页唯一一块实心白 —— 别的都玻璃化了、只有它白着一横条。
* 用户:「顶部的收件箱发件箱和授权为什么不做成悬浮玻璃?」
*
* ② **09-20 照做**:改成**悬浮玻璃条**(`Theme.navMaterial` + 圆角 + 左右留白),
* 依据记在 `NavItems.ts`:
* 「底部导航条已经确立了这个语汇,顶部再做一个通栏的,
* 会在同一条轴线上出现两种不同的"条"」
* ★ 那个推理**漏了一件事**:底部条是**独立悬浮在内容之上**的(该是浮条),
* 而页签条是**列表栏的一部分** —— 两者不是同一类东西。
* 代价当场就来了:贴顶通栏 44vp 的几何把系统材质**自带的方向性明暗**
* 暴露成下缘一条暗带。用户:「顶栏莫名其妙的底部阴影」。
*
* ③ **09-24 改回通栏**(用户并排看 WebUI/App 后:「通信页面 webui 和 app
* 存在很大的区别」+「去吧」)。WebUI 的真实形状(`App.tsx:214-216`):
* <div className="comm-pane relative flex-1 …">
* <CommTabs /> ← 「shrink-0 … px-3 border-b border-gray-200」
* {listBody}
* 页签条与列表**同属一个 `.comm-pane`**,它是那张卡顶上的**一条边**、
* 不是一块浮起来的板。
*
* ── 现在的形状(逐条对齐 WebUI)──
* · **通栏**:`width('100%')`,无左右留白、无圆角
* (`TAB_BAR_SIDE`/`TAB_BAR_RADIUS` 已在 09-21 删除,理由见 `NavItems.ts`);
* · **无材质**:WebUI 的页签条没有 `backdrop-filter`,只有一条底边线;
* · **有底色**:`Theme.surface`(= WebUI 列表栏的 `bg-white`)。
* ★ 这与"不做成悬浮玻璃"**不冲突** —— 那是"材质 vs 实色",
* 这是"有底色 vs 透明"。WebUI `index.css:1648` 写得很明确:
* 「列表/详情的外框在壁纸模式下让位给"每项一张卡",
* 但**工具条与头部**仍要有底色,否则会直接压在壁纸上读不清」
* · **底边线**:`Theme.border`(WebUI 的 `border-b border-gray-200`)。
*
* ── 不随本次反转回退的那次修复 ──
* 页签条下方曾有一条**渐变暗带**。真凶不是页签条自己,而是 `InboxTab`
* 根容器的投影向上扩散压住了它(几何取证:页签条 y142→271、
* InboxTab y271→2202,观测到的渐变区 y240→268 完全吻合)。
* 修在 `InboxTab` 上(`PaneModifier.plain`),与本次造型反转无关,保留。
*/
.width('100%')
/*
* ★★★ 2026-09-24 **改回通栏**(用户:「通信页面 webui 和 app 存在很大的区别」+「去吧」)。
*
* ── 这次只是**几何上的反转**,不是材质上的 ──
*
* 09-20 用户问:「顶部的收件箱发件箱和授权为什么不做成悬浮玻璃?」
* 我把它做成了**带左右留白的浮条**(材质 + 圆角 + `TAB_BAR_SIDE`)。
* 用户 09-21 又连指三次,最后裁定
* 「你右边改成没圆角不就行了」「你又在内部套了一个胶囊」
* ⇒ 那次改的是**几何**:不再内缩、不再自己做满圆角,而与窗格齐平。
*
* ★ 玻璃这一半一直是保留的(用户 09-20 明确要的),判据
* `顶部页签条与底部导航条同一族` 钉的就是它。
* 我这次一度把它一并推翻(改成 `Theme.surface` 实心),那是把**几何反转误当成材质反转** ——
* 把用户当初点名要的东西删掉了,也正是这次「不够通透」的直接原因。
*
* ── WebUI 的真实结构(几何 + 材质的完整解释)──
* 底部导航条是**独立悬浮在内容之上**的(它确实该是浮条);
* 而页签条是**列表栏顶上的那条边** —— WebUI 里 `CommTabs` 与 `MailList`
* 同属 `.comm-pane`(`App.tsx:214-216`):
* <div className="comm-pane relative flex-1 …">
* <CommTabs /> ← className="shrink-0 … px-3 border-b border-gray-200"
* {listBody} ← 自己带 .glass-card 头部
* 它**自己无背景**,透出的是底下那张玻璃卡 —— 这才是它"通透"的来源。
* 所以:几何上它随窗格(通栏、只左上圆角),材质上它仍是玻璃族。
*
* ── 按 WebUI 对齐后的形状 ──
* · **通栏**:`width('100%')`,无左右留白;
* · **无实心面**、**吃系统玻璃**:与底部导航条同档(`Theme.navMaterial`);
* · **只给左上圆角**(右上 0):与窗格左上角那道弧重合;
* · **底边线**:`Theme.border`(WebUI 的 `border-b border-gray-200`)。
*
* ── ★★★ 2026-09-24 再纠一次(同一个错我前后犯了两次)──
*
* 我上一版把"无背景"实现成了 `backgroundColor(Theme.surface)`,
* 理由是「WebUI 列表栏是 `bg-white`」—— **那个引用是错的**。
* WebUI 的真实结构:
* .comm-pane ← 一张 `.glass-card`(半透明白,浅色 0.92 / 壁纸下 0.78)
* └─ CommTabs ← `shrink-0 … px-3 border-b border-gray-200`,**自己无背景**
* 页签条**自己不带底色**,透出的是底下那张玻璃卡。
*
* 给它铺 `Theme.surface`(系统实心卡片色)= 把那张玻璃卡换成一横条实心白
* ⇒ **壁纸再也透不过来** —— 这正是用户这次说的
* 「通信页面我觉得没有 webui 那么通透」。
*
* 判据 `harmony-nav.test.mjs:732`「顶部页签条与底部导航条同一族」
* 当场判红(“一块实心白就是割裂”)—— 它盯的形状与我这次犯的**一模一样**。
* 教训:判纪引的出处不能只看“像”,要看那个类的**真实层叠位置**。
*
* ── 顺带保留的那次修复(它是 bug,与本反转无关)──
* 页签条下方曾有一条**渐变暗带**。真凶不是页签条自己,而是
* `InboxTab` 根容器的投影向上扩散压住了它(几何取证:页签条 y142→271、
* InboxTab y271→2202,观测到的渐变区 y240→268 完全吻合)。
* 那条修在 `InboxTab` 上(`PaneModifier.plain`),**不随本次反转回退**。
*/
.width('100%')
/* 玻璃:与底部导航条同一族(判据 `顶部页签条与底部导航条同一族` 钉的正是这条)。
壁纸关着(`bgActive=false`)时退成 `BlurStyle.NONE`,与底部条同一取舍。 */
.backgroundBlurStyle(this.bgActive ? Theme.navMaterial : BlurStyle.NONE)
.borderRadius({ topLeft: Theme.glassRadius, topRight: 0 })
.border({ width: { bottom: 1 }, color: Theme.border })
}
/**
* 把通信页导航栈的**层数**发布到 `AppStorage`(根上的 Esc 派发要读它)。
*
* ★★ 2026-09-24 新增(用户:「esc返回上一级」)。
*
* ── 为何发布层数而不是自己维护一个布尔 ──
* `navPathStack.size()` 是**框架的真实状态**;布尔要在每处 push/pop 各改一次,
* 而栈的操作点有四个(详情 push、写信 push、两处 pop、一处 clear)——
* 漏一处的表现是"Esc 行为时对时不对",很难查。
* 发布真实层数则无从分叉。
*
* ── 调用点 ──
* 所有改动栈的地方后面都调一次(`openMail` / `openCompose` / `onBack` / 切栏 clear)。
* 本来更想用一个集中的钩子,但 `NavPathStack` 没有"变化即回调"的入口
* (`Navigation` 只给了 `onNavBarStateChange`/`onNavigationModeChange`,都与栈深无关)。
*/
private publishStackDepth(): void {
AppStorage.setOrCreate<number>(KEY_COMM_STACK_DEPTH, this.navPathStack.size());
}
openMail(mailId: string, accountId: string): void {
/* 记下"正在看哪一封" ⇒ 列表里那一行高亮(对齐 WebUI 的 currentMail)。
放在 push 之前:即使 push 失败,用户也确实点了这一封。 */
this.currentMailId = mailId;
/*
* ★★★ 2026-09-24 修(用户:「宽屏状态一个邮件被反复点击会被多次填充到右侧」)。
*
* ── 根因:默认的 `LaunchMode.STANDARD` 会逐次入栈 ──
* 官方 `navigation.d.ts:498-537` 的三种模式:
* · `STANDARD`(默认)—— push 就是把这一页**加到栈上**;
* · `MOVE_TO_TOP_SINGLETON` —— *“searches from the bottom to the top of the
* routing stack. If a NavDestination page with the specified name exists,
* it moves that page to the top”* ⇒ 同名已在栈里就**移上去,不新建**;
* · `POP_TO_SINGLETON` —— 同理,并把它上面的都弹掉。
*
* 我们一直用默认值 ⇒ 反复点同一封(或不同封)就叠出多层同名的
* `MAIL_DETAIL_ROUTE`,每叠一层就:**重建详情组件 + 重拉一次数据 +
* 重放一次入场动画** —— 用户看到的"被多次填充到右侧"就是这个。
* 而且返回要按多次才能回到列表。
*
* ── WebUI 为何没这毛病 ──
* WebUI 是 `mailStore.selectMail(mail)` → `set({ currentMail: mail })`
* (`mailStore.ts:115`)—— **幂等赋值**,点同一封两次与一次完全等价。
* 鸿蒙的"详情"是导航栈上的一层,不是一块状态 ⇒ 必须显式去重。
*
* ── 为何选 MOVE_TO_TOP_SINGLETON 而不是自己判 `if (当前已是这封) return` ──
* 自己判只能挡"同一封反复点";而**点另一封再点回来**同样会叠三层
* (A→B→A 三个实例)。`MOVE_TO_TOP_SINGLETON` 按**路由名**去重,
* 把这两种情况一并解决,且语义就是"详情栏只该有一份"。
*
* ★ 它仍然是 push(带入场动画),与 WebUI 的"右栏就地换内容"观感一致;
* 只有"已经是栈顶那一层"时才会退化成"移上去"(即无变化)。
*/
const params: MailDetailParams = {
mail_id: mailId,
account_id: accountId
};
this.navPathStack.pushPath({ name: MAIL_DETAIL_ROUTE, param: params },
{ launchMode: LaunchMode.MOVE_TO_TOP_SINGLETON });
/*
* ★★ 2026-09-24:**发布「详情已打开」**,供主页根的键盘派发用。
*
* ── 为何需要它(实测撞出来的)──
* 详情页自己也挂了 `onKeyEvent`(Enter=回复、Esc=返回),
* 但**实测没触发**:官方要求 `onKeyEvent` 在"组件**获得焦点**"后才响应
* (`common.d.ts:19510`),而页面根容器默认不可聚焦 ——
* 加 `.focusable(true)` 也不够(没人主动 requestFocus)。
* 于是键**直接冒到主页根**,被那里那条"Enter=写信"抢先处理:
* 在详情页按回车弹出了**写信页**而不是回复框(截图硬证)。
*
* ── 改成根上派发(与 WebUI 同构)──
* WebUI 也是**一处**全局监听(`App.tsx`)+ 按当前状态分派语义,
* 不是每个页面各挂一个。所以这里只发布状态,
* 由 `MainPage` 根上的 `onKeyEvent` 读它决定"回车该干什么"。
*
* ★ 发布的是 `mail_id`(不是布尔):详情换了一封也要重发,
* 而布尔从 true 再设 true **不产生变化通知** —— 那正是本仓
* `bgContentRev` 注释里记过的坑(“改了没反应”的经典形态)。
*/
AppStorage.setOrCreate<string>(KEY_OPEN_MAIL_ID, mailId);
this.publishStackDepth();
}
/**
* 详情栏空了(返回/切栏)⇒ 清掉发布键。
*
* 与 `openMail` 成对:只写不清的话,回到列表再按回车会以为"还在详情",
* 回车会去开回复而不是写信。
*/
private closeDetail(): void {
this.currentMailId = '';
AppStorage.setOrCreate<string>(KEY_OPEN_MAIL_ID, '');
}
/**
* 右栏占位(`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` 在栈空时自己渲染。
*
* 文案**逐字对齐 WebUI**(同一句话,不自己改写)—— 本仓纪律:
* 两端对同一件事说同一句话(见 `harmony-logic` 里「空态主句逐字一致」那条)。
*/
@Builder
DestinationBuilder(name: string, param: Object) {
if (name === MAIL_DETAIL_ROUTE) {
MailDetailDestination({ navReserve: this.navReserve, bgActive: this.bgActive })
} else if (name === COMPOSE_ROUTE) {
ComposeDestination({ navReserve: this.navReserve, bgActive: this.bgActive })
}
}
build() {
Navigation(this.navPathStack) {
/*
* ★ 悬浮加号必须待在 **导航栏内容(列表侧)内部**,不能当 `Navigation` 的兄弟。
*
* 原来它和 `Navigation` 平级放在外层 Stack 里 —— 窄屏 Stack 模式 push 详情时,
* 详情是画在 `Navigation` **里面**的,而外层那个加号画在 `Navigation` **之上**,
* 于是详情页右下多出一个悬空的 compose 圆,正好压在详情自己的「回复」按钮上
* (2026-09-17 模拟器截图硬证)。
*
* WebUI 的对应物是 `.comm-pane`:加号挂在**列表窗格内部**
* (`App.tsx` 的 `{listBody}<ComposeFab />`),所以窄屏滑上来的详情层
* 把列表整块(含加号)盖住 —— 详情自己的回复按钮才露得出来。
* Split 模式下加号仍留在左栏(与 WebUI 的两栏并排一致)。
*/
Stack({ alignContent: Alignment.BottomEnd }) {
Column() {
this.CommTabBar()
if (this.commTab === 'sent') {
SentTab({
bgActive: this.bgActive,
currentMailId: this.currentMailId,
navReserve: this.navReserve,
onOpenMail: (mailId: string, accountId: string): void => { this.openMail(mailId, accountId); }
})
} else if (this.commTab === 'permissions') {
PermissionTab({
bgActive: this.bgActive,
navReserve: this.navReserve,
onOpenMail: (mailId: string, accountId: string): void => { this.openMail(mailId, accountId); }
})
} else {
InboxTab({
bgActive: this.bgActive,
currentMailId: this.currentMailId,
navReserve: this.navReserve,
onOpenMail: (mailId: string, accountId: string): void => { this.openMail(mailId, accountId); },
onOpenCompose: (accountId: string): void => { this.openComposeWith(accountId); }
})
}
}
.width('100%').height('100%')
/*
* ★★ 2026-09-19 修(用户:「你这玻璃也不透明啊」)。
*
* 这一层是 `Navigation` 的 **navBar 内容**(列表那一栏的根容器),
* 原来**没有背景** ⇒ 由系统给的 `NavBar` 外壳显出不透明白。实测:
*
* NavBar [229,140,1149,2204] ← 255,255,255(整条列表栏)
* 右栏同高位置 ← 187,190,191(**壁纸透出来了**)
*
* 外层 `Navigation` 的壳修好之后,列表栏自己还盖着一块白。
* 内外两处都要透明,链路才通(Navigation → navBar 内容 → 卡片)。
*
* ★ 这里**不加**材质:WebUI 的同位置规则(`index.css:1641`)给列表容器
* 恰恰是 `background-color: transparent`,注释写明
* 「面板退成透明,**每一项自己是一张玻璃卡**」。
* 玻璃在**卡片**那一层,容器只负责让位 —— 若这里也铺材质,
* 卡片就成了"玻璃套玻璃",正是 WebUI 用
* `.bg-white .bg-white { backdrop-filter: none }` 明确禁止的事。
*/
.attributeModifier(PaneModifier.plain(this.bgActive))
/*
* ★★ 2026-09-20(用户:「邮件展示左侧没有圆角」)。
*
* 这一层是**左栏(列表)**的根。WebUI 宽屏是三个**独立圆角面板**并排
* (`App.tsx:262` 的 `.app-shell` 下 `<Sidebar/>{list}{main}`,
* `index.css:968` 给 `.app-shell > *` 统一 `border-radius` + 裁切),
* 所以列表栏与详情栏**各自有四个圆角**,中间的缝里透出壁纸。
*
* 我们这边列表在 `Navigation` 的 navBar 里、详情在 content 里,
* 系统只给一条分割线 ⇒ 内部两栏是直角。实测详情栏白区左缘
* 在 y=145 与 y=2195 都是 `x=1152`(完全垂直,无圆角)。
* 这里给左栏补上,与右栏那处成对。
*/
.borderRadius(this.bgActive ? Theme.glassRadius : 0)
/*
* ★★ 2026-09-20 修(用户:「你一改了之后,我列表都没法滚动了」)。
*
* 我第一版在这里写了 `.clip(this.bgActive)` —— **把滚动整条裁掉了**。
* 这不是新坑:WebUI 的 `index.css:968` 同一个位置**早就写着这条教训**:
*
* .app-shell > * {
* border-radius: var(--radius-card);
* // ★ 这里**不能**写 overflow: hidden(2026-09-14 用户:
* // 「通信页面完全无法上下滑动」)。
* // 面板自己就是滚动容器,而这条规则特异性比 .overflow-y-auto 高
* // ⇒ 滚动被静默干掉:实测当时**一个可滚动容器都不存在**
* // (scrollerCount=0),内容是直接被裁掉的,连"滚到底"都做不到。
*
* 我照"两栏各自圆角"改的时候,把 `overflow: hidden` 一起搬了过来 ——
* 而 WebUI 那段注释**就在同一个规则块里**,正是为了防这一手。
*
* ★ 正确做法(WebUI 同款):**圆角保留、不裁切**。
* 角落的方角残影用 `background-clip: padding-box` 处理
* (鸿蒙对应物是让子容器自己圆角),代价是溢出内容在圆角处可能露一点
* —— 比"完全不能滚动"好得多(WebUI 的原话)。
*/
/*
* 悬浮的圆形加号(用户点名要的形状):56 圆、品牌色、右下角。
* 三个栏里都在(新建邮件这件事不挑栏),但**只在列表窗格内**。
*
* ★ 离底要抬过悬浮条:窗格现在是**满高**的(内容要能滑到条底下,
* 玻璃才有东西可糊),所以加号的 `bottom` 必须自己抬过条高 +
* 离底留白 + 余量(`navReserve`)—— 否则它会压在条上。
*/
Button() {
AmIcon({ iconName: 'compose', iconSize: 24, iconColor: Theme.accentFg })
}
.width(56).height(56)
.borderRadius(28)
.backgroundColor(Theme.accent)
/*
* ★★ 2026-09-21 共享元素转场的 **out 端**(另一端在 `ComposeDestination`)。
*
* 用户:「webui 行为是按钮变成对应的写邮件页面或输入框吧,你做的啥?」
* —— 对,WebUI `ComposePage.tsx:57-79` 的 FLIP 就是"球长成整页",
* 起点的 `borderRadius: '28px'` **正是这个球的半径**。
*
* 官方 FAQ `faqs-arkui-991` 给的正是"在 NavDestination 子页面里做
* 共享元素转场"的完整步骤(路由跳转 + 两端绑同一 id +
* **把 pushPath 放进 `animateTo` 的闭包**)—— 我们这里照做。
*
* `follow: false`(默认):两端互斥出现(一端在树上时另一端不在),
* 不是"始终在树上跟随"的那种。
*/
.geometryTransition('compose-morph')
.margin({ right: 16, bottom: this.navReserve + 16 })
.onClick(() => { this.openComposeWithMorph(); })
}
.width('100%').height('100%')
/*
* 背景开关仍在这一层(它是通信页的根):卡不透明、容器透明,
* 壁纸才从卡片间与顶/底边缘透出来(与 WebUI 的 `.comm-pane` 同构)。
*/
.attributeModifier(PaneModifier.plain(this.bgActive))
}
.navDestination(this.DestinationBuilder)
.mode(NavigationMode.Auto)
/* WebUI 邮件列表栏固定约 320px;Auto 的断点 = 列表最小宽 + 详情最小宽。 */
.navBarWidth(320)
.navBarWidthRange([280, 360])
.minContentWidth(360)
.hideTitleBar(true)
/*
* ★★ 2026-09-21 补:**联系人页的 `Navigation` 缺背景声明**
* (用户:「邮件看着没有玻璃效果」→ 实测发现卡内像素 (253,253,253),
* 与"白 0.78 叠在壁纸上应得 (240,242,244)"差得太远 ⇒ 下面另有不透明层)。
*
* `Navigation` 不设背景时用**系统默认底**(近白不透明)。
* 实测纵深采样(窄屏 1008px,y=500):
* x=2 (191,199,209) ← 壁纸
* x=20 (184,192,204) ← 壁纸
* x=32 (243,244,248) ← **突然变近白** ⇒ 这一层就是 Navigation 的壳
* x=100 (251,253,253) ← 卡片(白纱叠在这个近白壳上,当然看不见壁纸)
*
* ⇒ 整条链路(壁纸 → pane → 卡片)里,pane 与卡片都已经是半透明了,
* **只剩这层壳把壁纸挡在外面** ⇒ 玻璃感整体失效。
*
* ★ `CommPage` 那个 `Navigation` 在 2026-09-19 已经补过同一处
* (见那段注释里记的"那条缝是决定性的证据"),**联系人页漏了** ——
* 同一个形状在两个 struct 里各写一遍,就是会漏一个。
*/
.attributeModifier(PaneModifier.of(this.bgActive))
.width('100%').height('100%')
/* 右栏占位:栈空时显示引导(否则宽屏两栏并排时右栏是一大片空白)。
用系统入口 `splitPlaceholder`,**不是** NavDestination 的 else —— 后者栈空时不挂载。 */
.splitPlaceholder(new ComponentContent(
this.getUIContext(), wrapBuilder<[]>(DetailPlaceholder)))
/*
* ★★ 2026-09-19 修(用户:「你这玻璃也不透明啊」)。
*
* 这一行原来**没有** —— `Navigation` 不设背景时用系统默认(不透明白),
* 于是它把里面已经透明的窗格整片盖住了。实测(模拟器 3184×2232):
*
* Navigation [229,140,3156,2204] ← 255,255,255(整块内容区)
* y=2210(Navigation 之外那条缝) ← 226,225,235(**壁纸清楚可见**)
*
* 那条缝是决定性的证据:**壁纸层本身是好的**,白是因为它上面盖了
* 一个不透明的 `Navigation`。此前一直在调窗格自己的 `bgActive`,
* 而真正挡住的是外面这层壳。
*/
/*
* ★★ 2026-09-24 删掉这里**重复的** `.attributeModifier(...)`。
*
* 这个 `Navigation` 实例上原先挂了**两个** modifier(上面一个、这里一个)——
* 而 `.attributeModifier` 是**单一插槽**(本仓注释里记过:`.backgroundColor` 链在
* modifier 之后会被静默覆盖,同一形状)。两个 modifier 里只有**后者**生效,
* 于是"壳有没有投影"取决于书写顺序,而不是取决于意图。
*
* ⇒ 现在只留上面那一个(`PaneModifier.of` = 带投影)——
* `Navigation` 是**并列窗格**,对应 WebUI `.app-shell > *`(`index.css:980`)
* 那一层,是**唯一该投投影**的地方。
* 面板**内部**的子 tab 根容器(`InboxTab`/`SentTab`/`PermissionTab`/`ContactsTab`
* 的 navBar 内容)改用 `PaneModifier.plain` —— 它们不是'浮在壁纸上的板'。
*/
}
}
@Entry
@Component
struct MainPage {
@State currentIndex: number = 0;
/** 宽屏模式(≥768vp):复刻 WebUI 的三栏布局(Sidebar | 内容,无底部导航条) */
@State isWide: boolean = false;
/**
* "当前是否深色"的发布键(`AppStorage`)。
*
* 各窗格里有若干"品牌浅底"(`Theme.accentSoft`),深色下必须换深色变体。
* 窗格自己算不出这件事(`Theme` 是静态类、拿不到 Context)⇒
* 由 MainPage 算好发布、窗格 `@StorageProp` 读(与徽标同一套机制)。
*/
private readonly KEY_IS_DARK: string = 'agentmail.appearance.isDark';
/*
* 壁纸层(背景的**画法**在 `model/Wallpaper.ts` 里算好,这里只负责画)。
*
* P4 的第一版只做到"取回壁纸本体",**没有任何东西去画它** —— 也就是说
* 那一版里"壁纸"其实只有数据没有画面(我当时的提交信息说"取回 PixelMap",
* 那是真的;但文档里把它列成"未验渲染",听着像已经画出来了,这是我说得比证据强,已改)。
* 现在补上:预设档画渐变、图片档画图 + 压暗。
*/
/**
* 窗口避让区(vp)—— 由 `EntryAbility.setupFullScreenWindow` 读窗口写进来。
*
* ★ 全屏布局的**另一半**:`setWindowLayoutFullScreen(true)` 让内容铺到屏幕四边
* (黑边因此消失),代价是内容会跑到状态栏/手势条底下 —— 这个值就是用来让开的。
* 少了它,页签会被时钟盖住(上一次退回的原因);
* 少了全屏,黑边就一直在(用户三次报修的原因)。
*
* `@StorageLink` 而不是 `@StorageProp`:跟随变化(横竖屏/折叠改避让高度)。
* 键名走 `KEY_WINDOW_INSETS` 常量 —— `@StorageLink('xxx')` 拼错不报错、只会恒为默认 0,
* 那正好是本轮要修的病症(避让永远 0 = 黑边照旧),不能用会静默失效的写法。
*/
@StorageLink(KEY_WINDOW_INSETS) @Watch('recomputeNavReserve') windowInsets: Insets = new Insets();
/*
* 导航项徽标计数 —— 由 `CommPage.loadBadges` 经 AppStorage 发布(单向:窗格写、导航栏读)。
*
* `@StorageProp` 而不是 `@StorageLink`:导航栏**只读**,不该往外写。
* 键名与 `CommPage` 里的常量必须一致 —— 拼错不报错、只会恒为默认 0
* (那正好是"徽标永远不出现"这个症状,与 windowInsets 同一个坑)。
*/
/*
* 外观变更计数器(`SettingsPage.setBackground` 发布)—— 变了就重算壁纸层。
*
* ★★ 2026-09-19 补(设备实测撞出来的真 bug):原先 `bgPlan` 只在启动的
* `syncAppearance()` 里算一次,之后没人动它 ⇒ **在「我的」页改档位、
* 页面背景一点不变**(色板出现、状态显示「已同步」、服务端也真存了,
* 但壁纸层压根没重画)。
*
* 用**计数器**而不是布尔:连改两次压暗也应各触发一次重算;
* 布尔从 true 再设 true 不产生变化通知 —— 那正是"改了没反应"的经典形态。
*/
@StorageProp('agentmail.appearance.revision') @Watch('onAppearanceChanged') appearanceRev: number = 0;
@StorageProp('agentmail.nav.unread') navUnread: number = 0;
@StorageProp('agentmail.nav.pending') navPending: number = 0;
@StorageProp('agentmail.nav.contacts') navContacts: number = 0;
@State bgPlan: BackgroundPlan = new BackgroundPlan();
/**
* 壁纸层的内容版本号 —— **必须有它**,`bgPlan` 单独不够。
*
* ★★ 2026-09-19 修(真 bug,设备实测撞出来的):`bgPlan` 是 `@State`,
* 但 ArkTS 的 `@State` **观察不到类内部字段**的变化 —— 而 `WallpaperLayer()`
* 读的正是 `this.bgPlan.layers`(数组)与 `this.bgPlan.kind`(成员)。
* 于是 `applyAppearance()` 把新的 plan 赋进去之后,**壁纸层不重渲染**,
* 屏幕上一直是最初那份(`kind=none`)。
*
* 实测证据(hilog):
* `Appearance: sync: bgKind=preset ... hasImg=true` ← 数据是对的
* `Wallpaper: kind=none layers=0 active=false` ← 渲染读到的还是旧值
* 两行同一次启动,前后相差 100ms。
*
* ⇒ 加一个**原始类型**(number)的计数器,每次算完 plan 就 +1。
* `@State` 对原始类型必然敏感,`WallpaperLayer()` 的读法也带上它
* (见那里的 `this.bgContentRev` 注释)—— 两者缺一不可:
* 只加计数器而渲染不读它,等于没加。
*
* 同一个坑在本仓出现过(`AppearanceStore` 那段注释里写着"@State 观察不到
* 类内部字段的变化",当时只用来解释"为什么要多存一份显示副本")。
* 这一次是同一个原因、换了个地方发作。
*/
@State bgContentRev: number = 0;
/**
* 侧栏底部头像要显示的账号标签(取前两字,对齐 WebUI `Sidebar.tsx:190`
* 的 `(user?.display_name || user?.username || '?').slice(0, 2)`)。
*
* 与各 pane 的 `accountName` 分开存:那些是**收件箱筛选器**当前选中的账号
* (可能是"全部邮箱"),而头像要的是**当前活跃账号**——多账号下不同。
* 放在 `MainPage` 而不是侧栏自己查:侧栏是无状态展示组件,
* 而账号列表只有主框架持有(与徽标走同一套"父算子读")。
*/
@State accountLabel: string = '';
/*
* ── 顶栏文案轮播(2026-09-24 用户裁定)──
* 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」
* 「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」
*
* 三种文案轮着显示,一次只显示一种:
* · 摘要 —— 当前栏的统计(如「7 封 · 2 未读 · 1 待决」)
* · 一言 —— 服务端句库(本地有缓存,离线也照转)
* · 签名 —— 用户个人签名(没设过就不进轮播)
*/
@State topQuote: string = '';
@State topQuoteSource: string = '';
@State topSignature: string = '';
/** 当前显示到第几个(对"可用文案列表"取模) */
@State topIndex: number = 0;
/** 淡出/淡入用:0 = 不可见、1 = 可见(驱动 opacity,见 TopbarText) */
@State topOpacity: number = 1;
private topTimer: number = 0;
/**
* App 级 SSE 监听(`aboutToAppear` 注册 / `aboutToDisappear` 摧掉)。
*
* ★★ 2026-09-24:原来这个监听在 `InboxTab` 里 —— 但那个组件是**条件挂载**的,
* 切到发件箱/授权就被销毁,监听跟着没 ⇒ 在那两栏时收不到新邮件。
* 搬到 `MainPage`(`@Entry`,全程在)。
*
* ★ 保存同一个函数引用(`onGlobalSseEvent`)才能摧掉:
* `SseService.removeListener` 用 `indexOf` 比对引用,
* 传一个新写的箭头函数是摧不掉的(本仓 `ComposeIntent.clearListener` 同类坑)。
*/
private sseService: SseService | null = null;
/** 当前是否深色(侧栏主题按钮的图标);由 `applyAppearance` 算出来 */
@State isDarkNow: boolean = false;
/**
* 背景是否开着 —— 传给每个页面,让它们把**页面底**让出来(变成透明)。
*
* 这是"背景画出来了"这句话的另一半:只把壁纸铺在最底层、而每个页面自己又刷一层
* 系统页面底(`Theme.pageBg` 是不透明的),壁纸就**全被盖住**,等于没画。
* WebUI 侧对这件事的原话:「页面底 → 完全透明,让出背景;不改 27 个组件的 class,
* 逐个加 class 必然漏(漏掉的那块就是一张不透明卡片浮在背景上)」。
*/
@State bgActive: boolean = false;
/**
* 底部悬浮条要占的高度(vp)——页面传给各滚动容器作为**内容末尾**让位。
*
* 窄屏 = `NAV_CONTENT_RESERVE`(条高+离底留白+余量),宽屏 = 0(没条)。
*
* ★ 这个值加在滚动容器的 `contentEndOffset`,**不是**加在窗格的 padding 上:
* 窗格必须**满高**,内容才滑得到条底下 —— 否则玻璃条背后只剩壁纸,
* 系统材质无东西可糊,看起来就是一块普通浅色面板(用户 2026-09-17 的反馈)。
*
* ★ 它由 `recomputeNavReserve()` **一处**算出来,两个触发点都调它:
* ① 窗口宽度变(`onAreaChange` → isWide 可能变);
* ② 避让区变(`@Watch` 订到 windowInsets —— **横竖屏/折叠会只改避让不改宽度**,
* 只挂在 ① 上的话,转屏后手势条高度变了而 reserve 没变)。
*/
@State navReserve: number = NAV_CONTENT_RESERVE;
/**
* 日历窗格(**常驻挂载**那一个)的入场进度:0 = 刚出现,1 = 到位。
*
* ★★ 2026-09-19 新增(用户:「最严重的动画问题你一点也不该改」)。
*
* 日历 pane 是**常驻**的(用 `visibility` 控制显示,因为它里面 `today` 要随时间重算、
* 也要保住“正在看哪个月”)。而 `.transition()` 只在**挂载/卸载**时触发
* (SDK 原话:"when it appears and disappears")——
* 所以原先挂在它上面的 `.transition(Theme.paneRiseIn())` **一次也不会播**:
* 代码看着挂了动画,实际切过去是硬弹。
*
* WebUI 踩过**同一个坑**,并在 `index.css:1305-1320` 把自己的错法记下来了:
* 「我上一版只给回复框/转发面板挂了类,却没让**触发窗口**在那两个动作上出现
* —— 于是类挂着、动画永远不播」。它用 `html.view-switch` 重放窗口解决。
*
* ArkUI 这边的对应做法:**不靠 TransitionEffect**,用 `animateTo` + 两个显式
* `@State`(透明度 + 上浮位移)去驱动。切到日历时:先把 `calPaneIn` 置 0(瞬回未入场态),
* 再在 `animateTo` 里置 1 —— 系统根据这两个值算出插值。
*
* ★ 为何不“换个 key 让节点重挂”:日历**故意常驻**(保住月份/选中日/只读缓存),
* 重挂就把那两个理由一起丢了。所以只能动属性,不能动挂载。
*/
@State calPaneIn: number = 1;
/** 环境变化回调 id(-1 = 没订阅);`lastColorMode` 用来只在真的换向时重算 */
private envCallbackId: number = -1;
/**
* 上一次看到的系统深浅色。类型跟着 SDK 走(`Configuration.colorMode` 是
* `ConfigurationConstant.ColorMode | undefined`)—— 我先写成 `number` 初值取 `NOT_SET`,
* 编译直接报"枚举类型不能赋给 number",于是照 SDK 的类型写。
*/
private lastColorMode: ConfigurationConstant.ColorMode | undefined = undefined;
@State wallpaperImage: image.PixelMap | null = null;
/**
* 主题切换时盖在最上层的**旧主题位图**(淡出后置空)。
*
* 见 `toggleTheme` 与 `Motion.captureForThemeFade` 的注释。
* `null` = 没有正在进行的主题淡出。
*
* ★ 必须在动画结束后清空:留着就是一张**整屏大小**的 PixelMap 常驻内存,
* 而且它盖在最上层、会吃掉所有触摸事件(界面看着正常但点不动)。
*/
@State themeFadeImage: image.PixelMap | null = null;
/** 淡出层的不透明度(1 → 0),用显式状态驱动 `animateTo`。 */
@State themeFadeOpacity: number = 0;
/**
* 壁纸淡入进度(0 → 1)。
*
* 对齐 WebUI `index.css:742-752` 的 `.app-backdrop`:
* `opacity: 0;` → `html[data-bg='on'] .app-backdrop { opacity: 1 }`
* 过渡时长用 `--dur-base: 180ms` + `--ease-out-soft`。
*
* ★ 这一条是**补的欠账**:`Theme.durBase` 早就声明了(注释写着"壁纸淡入"),
* 但壁纸层从来没有真的读过它 —— 令牌成了孤儿,`cross-client-theme`
* 那条"死令牌"判据把它揪了出来。查 WebUI 才发现淡入是**真有的动效**,
* 于是补上而不是把令牌删掉(删掉等于把差异抹平、还说成"清理")。
*/
@State bgFadeIn: number = 0;
private gridSettings: RenderingContextSettings = new RenderingContextSettings(true);
private gridCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.gridSettings);
/**
* 顶栏文案的**候选列表**(摘要 / 一言 / 签名)。
*
* ★ 返回数组而不是三个布尔:轮播要对"可用项"取模,
* 而某些项会缺席(没设签名、句库为空)—— 用布尔写会绕一圈还容易错位。
* 数组天然只含**真的能显示的**那些。
*/
private topbarTexts(): string[] {
const out: string[] = [];
/* 摘要:当前栏的统计。三个栏各自的口径见 loadBadges/summarize 的注释 */
const summary: string = this.topbarSummary();
if (summary.length > 0) {
out.push(summary);
}
if (this.topQuote.length > 0) {
out.push(this.topQuoteSource.length > 0
? this.topQuote + ' —— ' + this.topQuoteSource
: this.topQuote);
}
if (this.topSignature.length > 0) {
out.push(this.topSignature);
}
return out;
}
/**
* 摘要文案。
*
* ── 数据从哪来(这是把它从 CommPage 提到 MainPage 时改掉的那处)──
* `unread`/`pending`/`contacts` 三个计数**本来就由 `CommPage` 发布到
* AppStorage**(底栏与侧栏的徽标读的就是这三个键,见 `navUnread` 那组
* `@StorageProp`)。摘要要的是同一份数据 ⇒ 直接读同一组键,
* **不另发请求、也不把 CommPage 的状态搬上来**。
*
* ★ 口径与徽标**逐字一致**:同一个数在两处显示成不同的值是最难解释的 bug。
* 徽标只显示数字,这里补上单位(「7 封」「2 未读」)——
* 单位不同、数字同源。
*
* ★ 为什么不带"哪一栏"(收件箱/发件箱/授权):那个状态(`commTab`)
* 在 CommPage 里,而顶栏在整窗层。为它再建一条跨层通道不值得 ——
* 这三项本来就是"这个账号下待处理的东西",一起说也是对的信息。
*/
private topbarSummary(): string {
const parts: string[] = [];
if (this.navUnread > 0) {
parts.push(this.navUnread + ' 未读');
}
if (this.navPending > 0) {
parts.push(this.navPending + ' 待决');
}
if (this.navContacts > 0) {
parts.push(this.navContacts + ' 会话');
}
/*
* ★★ 2026-09-25 加兜底(实测撞出来的):
* 三项都是 0 时返回空串 ⇒ `topbarTexts()` 成空数组 ⇒ 整块**不渲染**。
* 而"没有未读"恰恰是**常态**(收件箱清干净了)——
* 那时顶栏该说"都清完了",而不是整块消失让人以为坏了。
*
* ★ 这与我先前那版"三项都是 0 就不显示"的判断相反 ——
* 那句话是我按"有信息才显示"想当然写的,没考虑"零"本身也是信息。
*/
if (parts.length === 0) {
return '暂无待办';
}
return parts.join(' · ');
}
/**
* 启动轮播。
*
* ── 为什么用 `setInterval` 而不是 `animateTo` 的完成回调 ──
* 轮播是**永续的**(只要页面在)。用回调串起来会在某次不可见时断链
* (切到别的栏 `aboutToDisappear` 清了定时器,回来就再也不转了)。
* 定时器 + 显式的启停更直白。
*
* ── 为什么在 `aboutToAppear` 里启、`aboutToDisappear` 里停 ──
* 不清的话,用户切到日历页之后这个定时器还在跑(白耗电、且会改一个
* 已不可见组件的状态)。
*/
private startTopbarRotation(): void {
this.stopTopbarRotation();
this.topTimer = setInterval(() => {
const texts: string[] = this.topbarTexts();
if (texts.length <= 1) {
/* 只有一条(甚至没有)就没什么可轮的 —— 别浪费一次淡入淡出 */
return;
}
/*
* 消失 → 出现。★ 两步而不是一步换字:
* 用户要的是「消失出现动画」,而直接换字只是闪烁。
* 用 `animateTo` 包住 opacity 的变化(主题时长见 Theme)。
*/
const ui = this.getUIContext();
ui.animateTo({ duration: TOPBAR_FADE_MS, curve: Theme.easeOutSoft }, () => {
this.topOpacity = 0;
});
setTimeout(() => {
this.topIndex = (this.topIndex + 1) % this.topbarTexts().length;
ui.animateTo({ duration: TOPBAR_FADE_MS, curve: Theme.easeOutSoft }, () => {
this.topOpacity = 1;
});
}, TOPBAR_FADE_MS);
}, TOPBAR_ROTATE_MS);
}
private stopTopbarRotation(): void {
if (this.topTimer !== 0) {
clearInterval(this.topTimer);
this.topTimer = 0;
}
}
/**
* 拉一次顶栏内容(一言 + 签名)。
*
* 先读**本地缓存**(秒出、离线可用),再后台拉一次更新 —— 与服务端那层
* 缓存的分工见 `TopbarStore` 的文件头。
* 拉失败什么都不做(保留缓存那份):顶栏是装饰性的,不该弹错。
*/
private loadTopbar(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
acctMgr.load().then(() => {
const acct: AccountInfo | null = acctMgr.getActiveAccount();
if (acct === null) {
return;
}
const store: TopbarStore = TopbarStore.getInstance();
/* ① 先用缓存(同步) */
const local: TopbarContent = store.loadLocal(ctx, acct.id);
this.applyTopbar(local);
/* ② 再拉一次(异步,失败就留着缓存那份) */
store.refresh(ctx, acct.id, acct.server, acct.token).then((fresh: TopbarContent | null) => {
if (fresh !== null) {
this.applyTopbar(fresh);
}
});
});
}
/** 把顶栏内容接进 @State(`Array` 取第一条做轮播的一句话) */
private applyTopbar(content: TopbarContent): void {
/*
* ★ 只取**第一条**做轮播:服务端一次给一批是给客户端"够轮播一阵子"的,
* 而我们这里已经有本地定时轮播 —— 一次显示多句反而乱。
* 批次的存在意义是"少请求",不是"一次全显示"。
*/
if (content.quotes.length > 0) {
this.topQuote = content.quotes[0].text;
this.topQuoteSource = content.quotes[0].source;
}
this.topSignature = content.signature;
}
aboutToAppear(): void {
this.applyAppearance();
this.watchEnvironment();
/*
* 顶栏文案(摘要 / 一言 / 签名)—— 用户 2026-09-24:
* 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」+
* 「自动轮播,要有消失出现动画」+「在三键的左边」。
*
* ★ 为什么由 **MainPage** 起(而不是 CommPage):
* 文案要画在**整窗右上**(三键左边),那是 `MainPage` 的根 Stack 层;
* 而 `CommPage` 只是通信那一栏、只占左边 608px。
* 数据来源不冲突:摘要读的是 CommPage 发布到 AppStorage 的那三个计数。
*/
this.loadTopbar();
this.startTopbarRotation();
/*
* ★★ 2026-09-24:**把 SSE 监听提到 App 级**(用户:「鸿蒙 app 接收邮件的能力也有点不正常」)。
*
* ── 原来错在哪 ──
* 监听原来挂在 `InboxTab.aboutToAppear`(`this.sseService.addListener`),
* 而 `CommPage` 里三个 tab 是 `if/else` **条件挂载**的:
* 用户切到「发件箱」或「授权」时 `InboxTab` 被销毁 → `aboutToDisappear`
* 就把监听摧掉了 ⇒ **在那两栏时收不到任何新邮件通知**,
* 切回收件箱也不会补(它只在挂载时拉一次)。
*
* `MainPage` 才是真正的常驻组件(`@Entry`),所以监听放这里。
*
* ── 与 WebUI 对齐 ──
* WebUI 的 `connectSSE` 注册在 **App 根组件**(全程在),
* 不是某个页面(`client/electron/src/api/sse.ts`);事件到达后叫 store 重拉。
* 同一个形状。
*
* ── 不在这里直接 `loadInbox` ──
* `loadInbox` 需要 `ctx` + 当前 `accountFilter`,那是**窗格**的状态。
* 所以只调 `notifyRemoteChange()` 广播修订号,由挂着的窗格
* (`@Watch('onMailRevChanged')`)自己拿手上的参数去拉。
*/
this.sseService = SseService.getInstance();
this.sseService.addListener(this.onGlobalSseEvent);
}
aboutToDisappear(): void {
this.unwatchEnvironment();
/* 停轮播 —— 不清的话页面销毁后它还在跑(白耗电,且会改不可见组件的状态) */
this.stopTopbarRotation();
if (this.sseService !== null) {
this.sseService.removeListener(this.onGlobalSseEvent);
}
}
/**
* App 级的 SSE 事件处理(所有账号、所有栏都只有一个入口)。
*
* ★ 事件名与服务端逐字对应(`client/electron/src/api/sse.ts:7-13` 的 `EVENTS`):
* `new_mail` / `permission_decision` / `session_update` /
* `session_archived` / `agent_online`。
*
* 前四个都会影响列表内容 ⇒ 都是"重拉"的信号。
* `agent_online` 只影响在线状态展示(侧栏),不动邮件列表。
*
* ★ 本仓失败的形状之一是“写了一个 handler 但只处理其中一种事件” ——
* 原先 `InboxTab` 那个只看了 `new_mail`,而服务端在权限决策后发的是
* `session_update`(`permission.go:387`)与 `new_mail`(`permission.go:265`),
* 于是"授权栏里处理过的申请,收件箱还是旧的样子"。
*/
private onGlobalSseEvent = (event: SseEvent): void => {
if (event.type === 'new_mail') {
/*
* 新邮件 toast —— 原来在 `InboxTab` 里(会拿 `AccountManager` 查显示名)。
* 搬到 App 级后取不到那个窗格的 `accountList` 了,所以取**活跃账号**的显示名。
* 多账号下“哪个账号来的”确实会不准 —— 但事件里只带 `accountId`,
* 不查库是不知道名字的;宁可只说“新邮件到达”也不拿错的账号名骗人。
*/
let sourceName: string = '';
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
const active: AccountInfo | null = AccountManager.getInstance(ctx).getActiveAccount();
if (active !== null && active.id === event.accountId) {
sourceName = active.displayName;
}
}
this.getUIContext().getPromptAction().showToast({
message: sourceName.length > 0 ? '新邮件:' + sourceName : '新邮件到达'
});
MailStore.getInstance().notifyRemoteChange();
return;
}
if (event.type === 'permission_decision' || event.type === 'session_update'
|| event.type === 'session_archived') {
MailStore.getInstance().notifyRemoteChange();
}
};
/**
* 算一遍「内容末尾要让多少位」——**唯一**的算法,两个触发点共用。
*
* 宽屏:0(没有底部悬浮条,列表不必让位)。
* 窄屏:`NAV_CONTENT_RESERVE`(条高 + 离底留白 + 余量)**再加系统手势条**。
*
* ★ 为什么要加手势条:全屏(`setupFullScreenWindow`)之后屏幕最底那段是系统的
* 手势条(实测 20px ≈ 6vp)。底栏自己已经为它让开了(`NavBar` 的
* `bottom: NAV_BAR_BOTTOM + navIndicator`),而 reserve 是给**内容末尾**让位的量 ——
* 不加的话,条向上移了而内容没跟着,最后一行会被条的下缘压住
* (正是 2026-09-16 那轮修过的"看得见、点不到")。
*
* ★ 两处触发点、一个算法:宽度与避让**是两件独立的事**,
* 写成两处赋值就迟早出现"改了一处忘了另一处"(今天就是这样:转屏只改避让不改宽度)。
*/
private recomputeNavReserve(): void {
this.navReserve = this.isWide ? 0 : (NAV_CONTENT_RESERVE + this.windowInsets.navIndicator);
}
/**
* 侧栏底部的主题快捷开关(对齐 WebUI `ThemeToggleButton` → `themeStore.toggle`)。
*
* ★ 从 `system` 翻转时落到**当前生效值的反面**(显式 light/dark),
* **不是**回 `system` —— WebUI `themeStore.ts:79-88` 把这条写明了:
* 人点这个按钮的意图是"现在换个样子",变成 system→light(可能毫无变化)
* 会让按钮看起来坏了。
*
* ★ 走的是与「我的」页三选一**同一条写入路径**(`AppearanceStore` + 服务端 put),
* 不是直接 `setColorMode`:直接切系统色彩模式只改本机、换设备不跟着走,
* 而外观是账号级偏好。
*/
/**
* 主题快捷切换(侧栏按钮 / 设置页)—— **带交叉淡出**。
*
* ★★ 2026-09-21 加(用户:「深色浅色主题切换也加对应的动画」)。
*
* ── 为什么不能只用 `animateTo` ──
*
* 主题走 `app.setColorMode()`,而界面颜色是**系统资源**(`$r('sys.color.*')`)。
* `animateTo` 只能补间**数值属性** —— "把某个资源换成另一个资源"没有中间值
* ⇒ 实测是整屏同时跳变(闪一下)。
*
* WebUI 那边不存在这个问题:CSS transition 挂在 `background-color` 上,
* 主题换的是 CSS **变量**,于是每个元素各自补间(`index.css:1169`)。
* ArkUI 没有对应机制 ⇒ 只能自己造一个"旧屏淡出"。
*
* ── 做法 ──
*
* ① 切主题**之前**把当前界面抓成 `PixelMap`;
* ② 立刻切主题(底层已经是新主题);
* ③ 把那张位图盖在最上层、`opacity` 1 → 0 补间 ⇒ "旧界面淡出、新界面透出"。
*
* ★ 抓图失败(或动画被系统关掉)时**直接切主题**:
* 功能优先于观感 —— 不能因为动画失败而切不了主题。
*/
private toggleTheme(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const next: string = this.isDarkNow ? 'light' : 'dark';
const ui: UIContext = this.getUIContext();
/* 动画被系统关掉时不做交叉淡出(与 `Motion.dur` 折 0 同一口径) */
if (Motion.reduced()) {
this.applyThemeNow(ctx, next);
return;
}
Motion.captureForThemeFade(ui, THEME_FADE_ROOT_ID).then((bmp: image.PixelMap | undefined) => {
if (bmp === undefined) {
/* 抓不到就不做动画(退化成原来的瞬切) */
this.applyThemeNow(ctx, next);
return;
}
this.themeFadeImage = bmp;
this.themeFadeOpacity = 1;
/* 先切主题(底层变新),再让旧屏淡出 */
this.applyThemeNow(ctx, next);
ui.animateTo({
duration: Motion.dur(Theme.durThemeFade),
curve: Theme.easeOutSoft
}, () => {
this.themeFadeOpacity = 0;
});
/*
* 动画结束后**必须清掉位图**(理由见 `themeFadeImage` 的注释)。
* 用 `setTimeout` 而不是完成回调:`animateTo` 没有可用的完成钩子,
* 而本仓既有口径就是"时长到了就收"。+40ms 余量避免在最后一帧就抽掉。
*/
setTimeout(() => {
this.themeFadeImage = null;
this.themeFadeOpacity = 0;
}, Motion.dur(Theme.durThemeFade) + 40);
});
}
/**
* 真正的主题落地(**不含动画**)—— `toggleTheme` 的两条路径共用。
*
* 抽出来是为了让"有动画"与"没动画(抓图失败 / 系统关了动画)"
* 两条路走**同一份**状态变更逻辑。两份拷贝必然漂移。
*/
private applyThemeNow(ctx: Context, next: string): void {
const store: AppearanceStore = AppearanceStore.getInstance();
const snap: AppearanceSnapshot = store.current();
snap.theme = next;
this.isDarkNow = next === 'dark';
/*
* ★★ 2026-09-21 顺序修正:**先发布、再应用主题**。
*
* 原来这里 `store.applyTheme()` 在 `AppStorage.setOrCreate(KEY_IS_DARK, …)`
* **之前**。而 2026-09-21 起 `Theme.glassCardFor()` 是**从那个键读深浅**的
* (`Theme.isDarkNow()` → `AppStorage.get(KEY_IS_DARK)`)。
*
* 后果:`setColorMode` 会触发一轮重渲染,那一轮重渲染里
* `glassCardFor()` 读到的还是**旧**的深浅 ⇒ 卡片用错 alha 画一帧,
* 下一轮才纠正。切主题本来就慢(260ms 淡出),这一帧错色容易被看见。
*
* ⇒ 顺序固定为:算 → 发布 → 应用(含重渲染)。
*/
AppStorage.setOrCreate(this.KEY_IS_DARK, this.isDarkNow);
/* 本机先生效(不等人看着);服务端写失败只影响"换设备带不带着走" */
store.applyTheme(ctx, next);
store.saveLocal(ctx, snap);
const client: ApiClient = ApiClient.getInstance(ctx);
new AppearanceApi(client).put(snap, store.wallpaper !== null).catch(() => {});
}
/**
* 订阅系统环境变化(深浅色切换),**重算我们自己算出来的那部分**。
*
* pi 2026-09-14 给的规则(这条会被 P5/P6 咬到):
* **系统自动跟随的东西(语义色、材质)不会顺带把"我们自己算出来的值"一起更新** ——
* 凡是我们计算/缓存且随主题变化的值,都必须挂在**同一个主题变化事件**上重算,
* 否则它迟早是界面上唯一一处不跟随的。
*
* 这里"我们自己算的"就是预设色板(`layersFor(id, dark)`):系统的 surface/文字/材质
* 会立刻跟着深浅色换,而色板是我们算的 —— 不重算就会出现"一部分跟随、一部分不跟随"的撕裂,
* 正是这一整轮在治的病。所以除了 `applyAppearance()`(把色板按当前深浅重算一遍),
* 不引入别的机制。
*/
private watchEnvironment(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
if (this.envCallbackId >= 0) {
return; // 已经订阅过(aboutToAppear 可能被多次触发)
}
const cb: EnvironmentCallback = {
onConfigurationUpdated: (config: Configuration) => {
if (config.colorMode !== this.lastColorMode) {
this.lastColorMode = config.colorMode;
// 只重算我们自己的那部分;系统色/材质由系统自己换
this.applyAppearance();
}
},
onMemoryLevel: () => {
}
};
this.envCallbackId = ctx.getApplicationContext().on('environment', cb);
}
private unwatchEnvironment(): void {
if (this.envCallbackId < 0) {
return;
}
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
ctx.getApplicationContext().off('environment', this.envCallbackId);
}
this.envCallbackId = -1;
}
onAppearanceChanged(): void {
this.applyAppearance().catch((e: Error) => {
hilog.warn(0x0001, 'MainPage', '外观变更后重算失败:%{public}s', e.message);
});
}
/**
* 读外观(按**账号**)→ 算出画什么 → 落进 @State。
*
* `applyAppearance` 的名字与 AppearanceStore 里的同名方法一致:那边负责
* "谁覆盖谁"和系统色彩模式,这里负责把结果**变成画面**。
*/
async applyAppearance(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
await acctMgr.load();
const store: AppearanceStore = AppearanceStore.getInstance();
store.loadLocal(ctx, acctMgr.getActiveId());
/*
* ★ 必须用**单例**,且**不能**调 `init()`。
* 登录页的快速路径(已有账号)走 `client.setToken(active.token)`——
* 只写单例内存、**不** persistToken。如果这里再调 `init()`,
* 会从 preferences 读回可能空/旧的 token,把内存里正确的 token 覆盖掉
* ⇒ `/me/appearance` 恒 401(hilog 实测:response_code 401)。
* 单例由 LoginPage 先拿到,token 已在内存里;这里直接用即可。
* 只有冷启动直跳 MainPage(理论上不会发生)时才需要 init() 兜底。
*/
const client: ApiClient = ApiClient.getInstance(ctx);
if (client.getToken().length === 0) {
await client.init();
}
await store.syncFromServer(ctx, client);
const snap: AppearanceSnapshot = store.current();
this.wallpaperImage = store.wallpaper;
/*
* 预设色板要跟着主题走:`system` 时把系统当时反馈的 colorMode 一起看
* (`isDarkMode`)。否则深色主题下会是"浅色渐变垫在深色系统表面之下"。
*/
/*
* 系统当时的深浅色:从 `resourceManager` 的配置读(`Context` 基类没有 `config`,
* `UIAbilityContext.config` 要转型;而 `ctx.resourceManager.getConfigurationSync()`
* 在基类上就有)。两个枚举的取值一致,都核过 SDK:
* `ConfigurationConstant.ColorMode.DARK = 0 / LIGHT = 1`,
* `resourceManager.ColorMode.DARK = 0 / LIGHT = 1`。
*/
const systemMode: number = ctx.resourceManager.getConfigurationSync().colorMode;
const dark: boolean = isDarkMode(snap.theme, systemMode);
/* 侧栏底部那个主题按钮的图标跟着它走(WebUI `ThemeToggleButton` 同口径) */
this.isDarkNow = dark;
/*
* ★★ 2026-09-21 **把用户存的主题真正应用上去**(这里原来漏了这一步)。
*
* ── 实测到的症状 ──
*
* 「我的」页选「深色」后:
* · 按钮显示为选中(`appearanceTheme` 来自存储,是 'dark');
* · 服务端也记下了 `theme:'dark'`(实测 `GET /me/appearance` 返回 dark);
* · 但**界面仍是浅色**,而且文字是浅色压浅底 —— 实测
* 背景 `rgb(243,243,243)` / 文字 `rgb(243,243,243)`,对比度 ≈1.0:1,完全不可读。
*
* ── 根因(两处叠加)──
*
* ① `EntryAbility.onCreate` 里有一句**无条件**的
* `setColorMode(COLOR_MODE_NOT_SET)`(= 跟随系统)—— 每次冷启都把应用色彩模式
* 重置回"跟系统走",用户的选择被丢掉。(那句是目录迁移时抄进来的,
* 没有任何注释说明为什么,也没有谁在用它。)
* ② 本函数算出了 `isDarkNow`、发布了 `AppStorage`,但**从来没有调用
* `store.applyTheme(...)`** ⇒ 系统色彩模式没被设过。
*
* ⇒ 两件事一起修:EntryAbility 那句删掉(改由这里按存储值决定),
* 本函数在算完之后**真的应用一次**。
*
* ★ 为什么放在这里而不是"每次启动都设一遍默认值":
* 主题是**用户偏好**,权威在 `snap.theme`(本机缓存 + 服务端)。
* 启动时不读它就是在丢用户的设置。
*
* ★ 幂等:`setColorMode` 设同一个值不会有什么副作用
* (`applyAppearance` 在启动、切账号、环境变化时都会被调用)。
*
* ★★ 顺序很要緊:必须**先发布 `KEY_IS_DARK`、再应用主题**。
* 下一条语句就是 `AppStorage.setOrCreate(this.KEY_IS_DARK, dark)`,
* 而 `Theme.glassCardFor()` 是从那个键读深浅的(`Theme.isDarkNow()`)。
* 反过来的话,本函数内的卡片会用**上一次**的深浅重渲染一帧。
* 所以这里的布局是:算 dark → 发布 dark → 应用主题(含重渲染)。
*/
/*
* 把"当前是不是深色"**发布出去** —— 各窗格(通信/日历/联系/我的)里
* 有若干"品牌浅底"(`Theme.accentSoft`),深色下必须换成深色变体。
* 那些窗格自己算不出这件事(`Theme` 是静态类、拿不到 Context),
* 所以走 `AppStorage` 单向发布(与徽标、windowInsets 同一套机制):
* MainPage 算 → 写 → 窗格 `@StorageProp` 读。
*/
AppStorage.setOrCreate(this.KEY_IS_DARK, dark);
/*
* ★★ 上面那段长注释说的就是这一行:把用户存的主题**真正应用上去**。
* 位置在 `setOrCreate(KEY_IS_DARK, dark)` **之后” ——
* 因为 `Theme.glassCardFor()` 是从那个键读深浅的,先发布再重渲染,
* 这一次重渲染才会用对深浅。
*/
store.applyTheme(ctx, snap.theme);
/*
* 侧栏头像的前两字:当前**活跃账号**的展示名(不是 `accountName`,
* 那是收件箱筛选器选的)。拿不到就让它空着 —— 侧栏会显示 `?`。
*/
try {
const active: AccountInfo | null = AccountManager.getInstance(ctx).getActiveAccount();
this.accountLabel = active === null ? '' : active.displayName;
} catch (e) {
this.accountLabel = '';
}
// 模糊强度是**服务端给的 px 原值**:计划只搬运它,映射成系统材质档在画的那一层做
this.bgPlan = resolveBackground(snap.bgKind, snap.bgPresetId, scrimOpacity(snap.bgDim, dark), store.wallpaper !== null, dark, snap.bgBlur);
this.bgActive = this.bgPlan.kind !== 'none';
/* 让 `@State` 感知到"壁纸内容换了"(对象内部字段的变化观察不到 —— 见它的声明) */
this.bgContentRev = this.bgContentRev + 1;
/*
* 壁纸淡入(对齐 WebUI `index.css:742-752`:`.app-backdrop` 从 `opacity:0`
* 过渡到 `1`,时长 `--dur-base: 180ms`、曲线 `--ease-out-soft`)。
*
* ★ 顺序与"常驻窗格入场"同一条纪律(见 `calPaneIn` 那段):
* reset 到 0 **必须**在 `animateTo` **外面**先做 —— 写进回调里会与同帧的
* 1 相抵,渲染层只看得见最终值 ⇒ 动画退化成一次瞬移(等于没做)。
*
* ★ 只在**真的换成了有色/图片壁纸**时播:`none` 时没有可淡入的层,
* 无脑播会是"看不见的空转",也会让"开了壁纸"这件事得不到视觉反馈。
*/
if (this.bgActive) {
this.bgFadeIn = 0;
this.getUIContext().animateTo({
duration: Theme.durBase,
curve: Theme.easeOutSoft
}, () => {
this.bgFadeIn = 1;
});
} else {
/* 关掉壁纸:直接归零,不需要动画(淡出会让"关壁纸"这个动作显得迟钝) */
this.bgFadeIn = 0;
}
}
/** 一层渐变的色标:`['色', 位置]` 成对(页面才拼,纯逻辑里只存两个数组) */
private gradientColors(layer: PresetLayer): [ResourceColor, number][] {
const pairs: [ResourceColor, number][] = [];
for (let i = 0; i < layer.colors.length; i++) {
const c: string = layer.colors[i];
pairs.push([c === TRANSPARENT ? Color.Transparent : c, layer.stops[i]]);
}
return pairs;
}
/**
* 壁纸层:**整幅**铺在内容之下。
*
* 迷雾(模糊)**不在这里**:pi 的口径修正过 —— 正确的是"同一张底只许被模糊一次",
* 而模糊该出现在"背后是可变内容"的层。壁纸层背后没有别的东西,
* 在这里再糊一次只是把同一张图糊两遍(WebUI 侧的原话是"更脏、更掉帧")。
* 导航条那一层的系统材质才是唯一一次模糊(判据按互斥形式钉住)。
*/
@Builder
WallpaperLayer() {
/*
* ★ `this.bgContentRev` 必须在**这里被读到** —— 光有计数器不够。
* ArkUI 按"这个 Builder 读了哪些 @State"决定要不要重跑它:
* 不读,计数器变了也不会重渲染(那正是"加了计数器却没效果"的形状)。
* `@Builder` 里**不能声明局部变量**(编译报 "Only UI component syntax"),
* 所以直接把条件写进 if —— 表达式里消费它即可。
*/
if (this.bgPlan.kind === 'preset' && this.bgContentRev >= 0) {
Stack() {
ForEach(this.bgPlan.layers, (layer: PresetLayer) => {
if (layer.kind === 'radial') {
Column()
.width('100%').height('100%')
.radialGradient({
center: [layer.cx + '%', layer.cy + '%'],
radius: layer.radius,
colors: this.gradientColors(layer)
})
} else if (layer.kind === 'linear') {
Column()
.width('100%').height('100%')
.linearGradient({ angle: layer.angle, colors: this.gradientColors(layer) })
} else {
/*
* 网格档:CSS 的 `repeating-linear-gradient` 系统没有对应原语,
* 用系统 `Canvas` 画线(线色/间隔照抄 CSS:gray-200 / 0.55 / 28)。
*/
Canvas(this.gridCtx)
.width('100%').height('100%')
.onReady(() => {
const w: number = this.gridCtx.width;
const h: number = this.gridCtx.height;
this.gridCtx.strokeStyle = layer.lineColor;
this.gridCtx.lineWidth = 1;
this.gridCtx.globalAlpha = layer.lineAlpha;
for (let x = 0; x < w; x += layer.step) {
this.gridCtx.beginPath();
this.gridCtx.moveTo(x, 0);
this.gridCtx.lineTo(x, h);
this.gridCtx.stroke();
}
for (let y = 0; y < h; y += layer.step) {
this.gridCtx.beginPath();
this.gridCtx.moveTo(0, y);
this.gridCtx.lineTo(w, y);
this.gridCtx.stroke();
}
})
}
}, (layer: PresetLayer, idx: number) => layer.kind + idx)
/*
* 遮盖层:**预设档也要**(与 WebUI 的 `--bg-dim` 一致,它不区分档位)。
* 用**系统遮罩色** + 服务端浓度:浅色下由系统"洗白"、深色下"压黑",
* 不自己写 alpha。目的不是装饰,是让压在渐变上的正文读得动。
*/
Column()
.width('100%').height('100%')
// 遮盖色 = **页面底色系**(朝底色淡化),不是模态遮罩 mask(浅色下那会压暗,方向相反)
.backgroundColor(Theme.wallpaperScrim)
.opacity(this.bgPlan.scrim)
}
.width('100%').height('100%')
.opacity(this.bgFadeIn)
} else if (this.bgPlan.kind === 'image' && this.wallpaperImage !== null && this.bgContentRev >= 0) {
Stack() {
Image(this.wallpaperImage)
.width('100%').height('100%')
.objectFit(ImageFit.Cover)
/*
* ── 这里的模糊是「**图片内容模糊**」,与导航条的「面板材质模糊」是两件事 ──
*
* WebUI 侧核实过(`client/electron/src/index.css`):
* · 壁纸层 `.app-backdrop`(z-index:-1,背后什么都没有)吃
* `filter: blur(var(--bg-blur))` —— **图片内容模糊**;
* · 面板另有 `backdrop-filter: blur(8px)`(`.app-backdrop` 之上的那层)
* —— **背后内容模糊**。
* 两者是**两个不同的物理量**,所以"壁纸糊一次 + 导航条材质一次"**不是**
* "同一张底被模糊两遍"。原来那条判据把两者混为一谈(见 `harmony-appearance.test.mjs`
* 里已修正的那条),曾让我以为"壁纸层不许有任何模糊"。
*
* ★ 用 `blur(radius)`(`CommonMethod` 的图片内容模糊,与 CSS `filter: blur()` 同一个量)
* 而**不是** `backgroundBlurStyle`:后者是**面板材质**,作用在组件的**背景**上
* (作用对象是它背后的内容),语义对不上。导航条那处才该用材质。
* ★ 半径直接用服务端给的那个 px 值:WebUI 就是 `blur(var(--bg-blur))`,
* 两边**同一个物理量、同一个数** ⇒ 这一处不需要映射表,也不该有。
* (**px 半径**与**系统材质档**是两个量,别再合到一起:这里用 px;
* 页面侧的面板材质走 `Theme.navMaterial`,见 `NavBar`。
* 曾经那张"px → 材质档"的表连同它的函数已随固定档方案删除。)
*/
.blur(this.bgPlan.blurPx)
// 压暗用**系统遮罩色** + 服务端给的浓度:换向(浅色洗白/深色压黑)由系统负责
Column()
.width('100%').height('100%')
// 遮盖色 = **页面底色系**(朝底色淡化),不是模态遮罩 mask(浅色下那会压暗,方向相反)
.backgroundColor(Theme.wallpaperScrim)
.opacity(this.bgPlan.scrim)
}
.width('100%').height('100%')
.opacity(this.bgFadeIn)
}
}
/**
* 导航项徽标文字(空串 = 不渲染)。
*
* 计数来自 `@StorageProp`(`CommPage` 发布、导航栏只读)—— 单向:
* 窗格算,导航栏读。取值/色调的规则都在 `model/NavItems.ts`(纯逻辑)。
*/
navBadgeOf(key: string): string {
return navBadgeText(navBadgeCount(key, this.navUnread, this.navPending, this.navContacts));
}
/**
* 导航项徽标(独立 `@Builder`,便于在 `Column` 里就地条件渲染)。
*
* 取值/色调都在 `model/NavItems.ts`(纯逻辑,判据直接执行那一层)——
* 与 WebUI `Sidebar.tsx:88-95` 的 `badge`/`badgeTone` 同口径。
*/
@Builder
NavBadge(key: string) {
if (this.navBadgeOf(key).length > 0) {
Text(this.navBadgeOf(key))
.fontSize(9).fontColor(Color.White)
.backgroundColor(navBadgeTone(key, this.navPending) === 'perm'
? Theme.warnFg
: (navBadgeTone(key, this.navPending) === 'plain' ? Theme.badgePlain : Theme.danger))
.borderRadius(8)
.padding({ left: 4, right: 4 })
/*
* 偏移对齐 WebUI `absolute top-1 right-[22%]`:压在**图标右上角**,
* 而不是排在文字下面(那次正是把绝对定位写成了 Column 的子节点)。
*/
.translate({ x: 4, y: -2 })
}
}
@Builder
NavItem(item: NavItem, index: number) {
Column() {
/*
* 图标 + 文字(与 WebUI `NarrowNav.tsx` 同构)。
*
* ── 修正一处**事实错误**(2026-09-17)──
* 这里曾经写着「WebUI 的底部导航是**纯图标**」,并据此去掉了文字。
* 那句是**错的**:`NarrowNav.tsx:83-86` 的每个导航项是
* `flex flex-col items-center justify-center gap-0.5`
* 里面先 `<Icon />` 再 `<span className="text-3xs leading-none">{short}</span>`。
* 也就是说 WebUI **一直有文字**(四段:通信 / 日历 / 联系人 / 我的)。
* 所以“去掉文字”既不是对齐 WebUI,也不是用户当时想要的最终形态 ——
* 用户 2026-09-17 原话:「还是在导航栏加上文字吧,没有文字还是不好看」。
*
* 选中态:**图标与文字一起换色**,不引入背景/指示条 ——
* 与 WebUI 同一套表达(用户 2026-09-14:「选中对应的文字和图标变色即可」)。
*/
/*
* ★★ 2026-09-19 修 bug(用户:「底栏数字为什么显示在图标下面?」)。
*
* 图标外面再包一层 `Stack`,**只为把徽标锚在图标上**。
*
* 徽标原先写成这个 `Column` 的**第三个子节点** —— 于是它参与竖向布局,
* 被排在图标、文字**之后**,看起来就是"数字掉到图标下面"。
* WebUI 不是那样:`NarrowNav.tsx:87-89` 是
* `<span className="absolute top-1 right-[22%] …">`
* —— `absolute` 意味着**脱离文档流**,浮在图标右上角、不占布局位置。
*
* ★ 锚在**图标**上,不是锚在整个项上:项被 `layoutWeight(1)` 撑开、宽度随屏况变,
* 而 WebUI 那个 `right-[22%]` 本就是为了落到图标肩上试出来的数。锚在图标上
* 就不需要百分比了 —— 图标多宽,徽标就贴它多宽。
*
* ★ 用 `Stack({ alignContent })` 而不是 `.justifyContent()`:
* **Stack 没有 justifyContent/alignItems**(对齐只能走构造参数),
* 写在链上直接编译报错。这一条我刚刚撞过
* (`arkts-grammar-standards` 的 Stack 那行写明了)。
*/
Stack({ alignContent: Alignment.TopEnd }) {
AmIcon({
iconName: item.iconKey,
iconSize: 24,
iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg
})
/* 徽标:见 NavBadge(口径与 WebUI Sidebar 的 badge/badgeTone 一致) */
this.NavBadge(item.key)
}
.width(30).height(26)
Text(item.label)
.fontSize(10)
.lineHeight(12)
.fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)
.margin({ top: 3 })
}
/*
* 命中区:**显式给下限**(44vp),不靠"看起来够大"。
* 每个项 `layoutWeight(1)` 分到条宽的一半,高度就是条高 —— 两处都远大于 44,
* 但仍把下限写出来:以后有人把条调矮时,这里会挡住(判据也钉这个常量)。
*/
.layoutWeight(1)
.height(NAV_BAR_HEIGHT)
.constraintSize({ minHeight: NAV_ITEM_MIN_HIT, minWidth: NAV_ITEM_MIN_HIT })
.justifyContent(FlexAlign.Center)
/*
* 选中/取消选中时图标与文字**变色**要有过渡(不是硬切)。
*
* 用户(2026-09-17):「一方面一点动画都没有」。
* 与 WebUI `.nav-item { transition: color 150ms }` 同一语义;
* 时长用令牌(120ms, `--dur-fast`),不另拍一个数。
*/
.animation({ duration: Theme.durFast, curve: Theme.easeOutSoft })
/* 按压反馈:导航项是最高频的可点元素,按下要有明确的"吃到了" */
.attributeModifier(PressEffectModifier.of())
.onClick(() => {
/*
* ★ 四项**都是内容窗格**(用户 2026-09-17:「我的页面完全没有遵守 nav 的导航规则」)。
*
* 原先第 4 项走 `pushUrl('pages/SettingsPage')` —— 推一个独立 @Entry 页,
* 底部导航整条消失。WebUI 的 `account` 只是一个 `viewMode`,与收件箱同级、
* 导航常驻。所以现在统一按 `currentIndex` 分派(normalizeNavIndex 的上界
* 也跟着放宽到四项)。
*
* ★ 切窗格走 `animateTo`:用户(2026-09-17)「一方面一点动画都没有」。
* 改 `currentIndex` 会换掉整块内容 —— 不包一层过渡就是硬切。
* WebUI 的对应物是 `html.view-switch .pane-rise { animation: rise-in 150ms }`
* (只给**刚出现的面板**做 4px 上浮 + 淡入,骨架不动 —— 用户对"闪"很敏感,
* 所以不做整屏淡入、不做缩放)。
* 时长/曲线用令牌(180ms + cubic-bezier(0.22,1,0.36,1)),与 WebUI 同一根尺子。
* 必须走 `getUIContext().animateTo`:全局 `animateTo` 已被 SDK 标废弃,
* 判据(harmony-system-api)对全局调用默认判红。
*/
const target: number = normalizeNavIndex(index);
if (target === this.currentIndex) {
return;
}
/*
* ★★ 2026-09-19:日历那一支要先把入场进度**瞬回 0**,再在动画窗口里推到 1。
*
* ★ 顺序是这个修法的**全部关键**:reset 必须在 `animateTo` **外面**先做。
* 写进 `animateTo` 的回调里是错的 —— 那两句在同一个 handler、同一帧里执行,
* 渲染层只看得见最终值 1,起点也是 1 ⇒ **动画退化成一次瞬移**(等于没修)。
* 先 reset(这一帧就会以 0 渲染)再 `animateTo` ⇒ 起点是 0、终点是 1 ⇒ 真的播。
*
* 其余三个 pane 是 `if/else` 换子树 —— `.transition(paneRiseIn())` 在换的那一刻
* 自然就播了。日历是**常驻**的(`visibility` 控制),它的 `.transition()` 永远不触发,
* 所以只有它需要这里显式驱动。
*/
if (target === 1) {
this.calPaneIn = 0;
}
this.getUIContext().animateTo({
duration: Theme.durRise,
curve: Theme.easeRise
}, () => {
this.currentIndex = target;
/* 日历:同一动画窗口内推到 1(与其余 pane 的 transition 同源同时长) */
this.calPaneIn = 1;
});
})
}
/**
* 底部导航:**自绘的悬浮玻璃条**(P5),取代系统 `Tabs` 的 bar。
*
* 为什么换成自绘的:
* - 系统 `Tabs` 的 bar 只能在 `barPosition` 上下两侧、贴边、按系统规矩排布,
* 摆不出 WebUI 那种"浮在内容之上、四周留白、圆角胶囊"的形状;
* - 内容仍按 `currentIndex` 挂载(状态机是同一个),所以这次换的是**条**,不是信息架构。
*
* 玻璃**只在这一层**,而且这次是"**第二处**允许的模糊":
* 壁纸层整个不吃材质(同一张底只许糊一次),而这条背后是**会滚动的内容** ——
* 满足 pi 给的放行条件(§7.16),所以它在 `GLASS_REGISTRY` 里登记了并写了理由。
*/
@Builder
NavBar() {
/*
* ★ 外层容器带 padding,**不是** `width('100%') + margin`。
*
* ArkUI 的 margin 加在宽度**外面**:`width('100%')` 再配左右 margin 不会让元素
* 缩到「100% − margin」,而是整个顶出父容器、两侧被裁 ——
* 实测底栏左缘 x=0、右缘贴满 1256(应各留 16vp=56px),
* 于是它看起来是**贴边的通栏**而不是“浮在壁纸之上的胶囊”,
* 玻璃材质也就失去意义(背后没有内容/壁纸可透)。
* 这与收件箱头卡片修过的是同一个坑(`width('100%') + margin` 在 ArkUI 里不缩宽)。
*/
Column() {
Row() {
ForEach(NAV_ITEMS, (item: NavItem, index: number) => {
this.NavItem(item, index)
}, (item: NavItem) => item.key)
}
.width('100%')
.height(NAV_BAR_HEIGHT)
.borderRadius(NAV_BAR_RADIUS)
/*
* 导航底:**系统材质**,不是手写 alpha。
*
* 原来这里有 `#B8FFFFFF` / `#B80F172A` 两个常量(浅色/深色各一个手写玻璃)——
* 那等于"我们替系统猜了深色该怎么做",与"用系统方案"直接冲突,
* 而且还要我们自己维护两套。现在只声明**档次**(`Theme.navMaterial` = `COMPONENT_THICK`),
* 深浅两套颜色与模糊半径都由系统按主题给。
*/
/*
* 导航条的**面板材质**:**固定系统档**(`Theme.navMaterial` = `COMPONENT_THICK`),
* **不跟随 `bg_blur`**。
*
* ── 这里曾经"说的和做的不一致",记下来(pi 2026-09-15 抓到的)──
* 我一度把档位接过用户偏好(`navMaterialFor(this.bgPlan.blurPx)` 查表),
* 但**注释留在了更早那一版**:那段注释论证的是"固定档"、还写着"跟随是错的"。
* 于是**注释说 (a)、代码是 (b)** —— 下一个读者会照注释把代码改回去,
* 而且他会引我那句"pi 抓出来了"当权威。**说的与做的不一致、而判据看不见**,
* 正是这一路反复在消的形状,这次落在注释上(而注释正是"理由要写清"那条纪律的证据源)。
*
* ── 为什么最终是固定档(pi 的裁定,五条依据,我原先的理由被推翻)──
* ① WebUI 的 `.narrow-nav`(`index.css:1114`)是**硬编码** `backdrop-filter: blur(18px)`,
* **不读** `--bg-blur`;
* ② WebUI 那个滑杆的语义是"**背景**"(`BackgroundPicker.tsx:183`:`label="模糊"`、
* `hint="虚化细节,避免背景与正文抢注意力"`,`min=0 max=24`),只作用在
* `.app-backdrop{filter:blur(var(--bg-blur))}` 上;
* ③ WebUI 自己留了**分开的**令牌 `--bg-blur-panel`(`index.css:267`,注释写明
* "与壁纸自身的 `--bg-blur` 分开:那层给照片打底,这层给面板")——
* 它的词汇表本身就把两者分开;
* ④ **我原先的理由不成立**:我说"(a) 会让那个滑杆在导航条上变成死控件",
* 而那个滑杆**已经**被壁纸消费了(本文件下面的壁纸层把 `bgPlan.blurPx` 交给 `.blur(...)`)——
* 它从来**不是**导航条的控件,(a) 之下它照样是活的;
* ⑤ §7.12 的「材质(玻璃)」行原本写的就是固定档 ⇒ (a) 是**回到**已登记契约。
* 产品向还有一条:**导航条是 chrome,材质应当稳定**,不该因为用户换了一张壁纸而变厚变薄。
*/
/*
* ★★ 2026-09-21 **底栏改成参数化玻璃**(用户:「玻璃也不透明」「不够炫酷」)。
*
* 底栏是 WebUI 里 `backdrop-filter` 的两个位置之一,
* 且数值最大的一处(`.narrow-nav`:`blur(18px) saturate(1.5)`,
* `index.css:1204`)。`saturate` 才是"玻璃感"的主旋钮 ——
* 透过玻璃的颜色被提亮,而固定材质档没有这个旋钮(观感偏灰)。
*
* `backgroundEffect` 取代 `backgroundBlurStyle`(两者都调会叠加:
* 那不是"更玻璃",而是两层各采样一次背景 ⇒ 更脏更糊 + 滚动掉帧)。
*
* 数值全部来自 `Theme.navGlassEffect()` —— 不在调用点写死。
*/
.backgroundBlurStyle(Theme.navMaterial)
}
.width('100%')
.padding({
left: NAV_BAR_SIDE,
right: NAV_BAR_SIDE,
/*
* ★ 底部要让**两段**:自身留白 `NAV_BAR_BOTTOM` + 系统手势条高度。
*
* 全屏(`setWindowLayoutFullScreen(true)`)之后,屏幕最底那段是系统的
* 手势条(实测 20px ≈ 6vp)—— 不额外让开,自绘的玻璃条会压在它上面,
* 看起来就是"底栏贴着屏幕边",而且手势区会盖住条的下缘。
*
* 示例工程同一口径:`MineView.ets:252` 的
* `.margin({ bottom: this.globalInfoModel.naviIndicatorHeight })`。
*/
bottom: NAV_BAR_BOTTOM + this.windowInsets.navIndicator
})
}
build() {
/*
* 底部四枚图标:通信 / 日历 / 联系人 / 我的(与 WebUI `NarrowNav.tsx` 四入口一致)。
*
* 前三项是内容窗格(按 currentIndex 分派挂内容);第4项「我的」是外壳入口
* —— 点击路由到 `pages/SettingsPage`(见 `NavItem` 的 onClick,与 WideSidebar 的
* 设置按钮同一行为),不在 currentIndex 分派里占位(见 `NavItems.ts` 的 `route` 字段说明)。
*
* 通信不再等于收件箱:它内部有三栏(收件箱 / 发件箱 / 授权 + 徽标),
* 见 `CommPage` 与 `model/CommTabs.ts`。这也是 WebUI 现在的信息架构
* (用户 2026-09-14:「收件发件授权改为一个导航项,通过内部导航区分」)。
*
* 日历**已有入口**(P6 第 1 步:只读月视图)。还没做的是写侧(新建/编辑/删除事件)、
* 农历重复、.ics 导入导出、左右滑动翻页(P6 第 3 步)—— 逐条写在 `CalendarPage.ets` 的文件头,
* 别把"没做的"读成"做了"。
*
* 「会话」原先是个平级 tab,现在**撤掉**了 —— 它不是第三个地方,
* 而是"同一批数据的另一种看法":收件箱那栏按会话折叠(组头就是会话),
* 联系人那栏的**卡片视图**就是会话的进度视角(主题、最新摘要谁说的、
* 权限档与强制力、往返预算)。这两处齐了,平级的「会话」就是重复入口。
*
* 时机是按 pi 的判断排的:**先补视图与折叠,再撤 tab** —— 撤早了,
* 往返预算 / status / from_agent 这些只在会话列表里出现的信息就没地方看了。
* (status 与 from_agent 参考实现也不显示,见 `WorkCard.tsx` 的字段集,
* 判据里有一条"卡片字段两边一致"钉住这件事,避免以后以为漏了。)
*/
/*
* ★★ 2026-09-21 **结构性改**:根从「重叠的 Stack」改成
* 「壁纸 + `Column{内容, 导航条}`」
* (用户:「你看看 webui 是怎么安排的,它的避让方案是让内容框停在导航栏上方」
* 「包括邮箱正文等等,都是这个避让方案」)。
*
* ── WebUI 的做法(用 CDP 实测它的真实几何,430×932)──
*
* .narrow-shell { display: flex; flex-direction: column }
* <children/> ← 内容(flex 子项,**占位**)
* <NarrowNav/> ← `position: static`、`flexShrink: 0`,**真的占 51px**
*
* 实测:`scroller.bottom = 861`、`nav.top = 871`
* ⇒ **内容盒就停在导航栏上方**,两者谁也不用"让"谁。
*
* ── 鸿蒙原来错在哪 ──
*
* 根是 `Stack({ alignContent: Alignment.Bottom })`:`NavBar()` 与内容 `Row`
* 是**并列兄弟**、都被摆到底部 ⇒ **互相重叠**(导航条浮在内容之上)。
* 于是"避让"变成每个滚动容器各自手写 `contentEndOffset(navReserve)` 的手工活。
*
* 代价已经付过:我**漏了 8 处** —— 邮件详情正文、日视图列表、对话树、
* 用户管理页……每处的最后一行都被底栏压住
* (实测 `Scroll` 延伸到 y=2231,而底栏占 1957..2232)。
*
* ★ 这是**同一类错误的第二次**:把该由**布局**承担的事,交给 N 个调用点逐个手写。
* 第一次是按压反馈(112 处 `onClick` 只挂上 2 处)。
* 判据:**凡"每加一个页面就得记得做一次"的事,迟早会漏一半。**
*
* ── 改法 ──
*
* 照 WebUI:竖排 `Column`;内容 `layoutWeight(1)`,`NavBar` 自己占位。
* 壁纸层仍绝对铺满(留在 `Stack` 里,不受这根 Column 影响)。
* 各处的 `contentEndOffset` **保留**:宽屏没有底栏时它本就是 0,
* 而它另外表达"内容末尾别贴死"(例如详情页要躲开回复球)。
*/
Stack() {
this.WallpaperLayer()
Column() {
/*
* 内容按 `currentIndex` 挂载(原来这里是系统 `Tabs` 的 TabContent)。
* 底部**必须让出** NAV_CONTENT_RESERVE 的高度:条是浮在内容之上的,
* 不让出这一段,列表最后一行就永远压在玻璃条底下 —— 看得见、点不到。
* 宽屏没有底部导航条,所以 padding 为 0。
*
* ★ 2026-09-16:宽屏复刻 WebUI `app-shell` 几何 ——
* `padding: var(--pane-gap)`(10) + `gap: var(--pane-gap)`(10) + 面板 `radius: var(--radius-card)`(14)。
* 壁纸从面板缝隙露出(`bgActive` 时)。窄屏贴合全屏(无 padding / 无圆角)。
*/
Row({ space: Theme.paneGap }) {
if (this.isWide) {
WideSidebar({
currentIndex: this.currentIndex,
bgActive: this.bgActive,
windowInsets: this.windowInsets,
accountLabel: this.accountLabel,
isDark: this.isDarkNow,
/*
* 「我的」走 `onSelect(3)` 而不是推页 —— 与底栏同一套窗格机制。
* 原先是 `onSettings: pushUrl('pages/SettingsPage')`,那正是用户
* 2026-09-17 报过的"我的页面没有遵守 nav 导航规则"(侧栏这一支当时漏改)。
*/
onSelect: (index: number) => { this.currentIndex = normalizeNavIndex(index); },
/*
* 侧栏底部的主题快捷开关:对齐 WebUI `ThemePicker.tsx:23-43` 的
* `ThemeToggleButton` —— **在 light/dark 之间直接翻转**,
* 从 `system` 翻转时落到"与当前生效值相反"的**显式值**
* (不是回 system:那可能毫无变化,让人以为按钮坏了;
* 这条理由 WebUI 注释里写明了,见 `themeStore.ts:79-88`)。
*/
onToggleTheme: () => { this.toggleTheme(); },
/* 退出:与「我的」页那个按钮走**同一个** `performLogout` */
onLogout: () => {
performLogout(this.getUIContext().getHostContext(), this.getUIContext()).catch(() => {});
}
})
}
/*
* ★★ 2026-09-19 修:内容列改为 `layoutWeight(1)`(用户报「我的」页右侧内容被截)。
*
* 原来只写 `.width('100%')` —— 在 `Row` 里,`100%` 是**父容器全宽**,
* 而父容器里还住着 60vp 的侧栏 ⇒ 侧栏 + 内容 = 100% + 60vp,**必然溢出 60vp**。
*
* 实测(密度 2.875、屏 3184px):内容列 `Column [229,28][3357,2204]` ⇒ 右边缘
* 3357 **超出屏幕 3184** 整整 173px(= 60vp)。后果不是"整体右移",
* 而是**靠右的内容被顶出屏幕**:「我的」页压暗滑杆的数值文本 `Text('56%')`
* 落在 `x=3250` —— 屏幕外。用户只看得到滑杆、看不到数值。
*
* 顺带解释了那个追了很久的"`56.000000`"假象:dumpLayout 里 `Slider` 节点的
* `text='56.000000'` 是**无障碍文本**(屏幕上看不见),截图里根本没有;
* 真正的问题是那个看得见的 `56%` 被挤出了屏。
* ⇒ 教训:**dump 的 `text` 不等于"屏幕上有这串字"**。判"看得见吗"要看边界
* 是否落在屏幕内(或直接截图),不然会去追一个不存在的渲染 bug。
*
* 为什么日历页没这个症状:它是常驻挂载 + 自己算宽度;而「我的」这一支露出来了。
* 同一处错误在不同 pane 上表现不同,所以更容易被当成"某一页的样式问题"去调。
*/
Column() {
if (this.currentIndex === 0) {
CommPage({ bgActive: this.bgActive, navReserve: this.navReserve })
.transition(Theme.paneRiseIn())
} else if (this.currentIndex === 2) {
ContactsTab({ bgActive: this.bgActive, navReserve: this.navReserve })
.transition(Theme.paneRiseIn())
} else if (this.currentIndex === 3) {
/*
* ★ 第四项「我的」现在是**内容窗格**(不再是 push 出去的 @Entry 页)。
*
* 用户 2026-09-17:「我的页面完全没有遵守 nav 的导航规则」。
* 原形状:点底栏「我的」→ `pushUrl('pages/SettingsPage')` 推开一个独立页面
* ⇒ 底部导航整条消失,要按「返回」才能再切窗格。
* WebUI 里 `account` 只是一个 `viewMode`,与收件箱/日历同级、导航常驻。
* 所以这里按窗格挂载(与前三项同一套机制)。
*/
SettingsPane({ bgActive: this.bgActive, navReserve: this.navReserve })
.transition(Theme.paneRiseIn())
}
/*
* 日历与另外两个 pane 不同:**常驻挂载**,用 `visibility` 控制显示。
*
* 理由两条,都不是美观问题:
* ① `today` 是日历里**唯一随时间变**的输入,而日历是"放着不动的 pane"。
* 卸载重挂会重算(`aboutToAppear`),但"一直开着跨过午夜"不会 ——
* 常驻 + `@Watch(visible)` 才能在它重新可见时重算
* (`docs/DEBTS.json` 的 `calendar-today-recompute`)。
* ② 常驻顺带保住"正在看哪个月",切页签回来不会被重置回本月。
* 常驻 ≠ 常拉:首次**可见**时才发请求(见 `CalendarPage.onVisibleChanged`)。
*/
Column() {
CalendarPage({
bgActive: this.bgActive,
visible: this.currentIndex === 1,
navReserve: this.navReserve
})
}
.width('100%')
.height('100%')
.visibility(this.currentIndex === 1 ? Visibility.Visible : Visibility.None)
/*
* ★★ 2026-09-19 修(用户:「最严重的动画问题你一点也不该改」)。
*
* 这里原先写的是 `.transition(Theme.paneRiseIn())` —— **一帧也不会播**:
* `.transition()` 只在**挂载/卸载**时触发(SDK 原话 "when it appears and
* disappears"),而本窗格是**常驻**的(上面 `visibility` 那段说了两个理由),
* 永不重挂载 ⇒ 切到日历永远硬弹。代码看着“挂了动画”,实际什么都没有。
*
* 改成用 `animateTo` 驱动两个显式属性(见 `calPaneIn` 的注释;
* 这也是 WebUI 那个“重建触发窗口”思路在 ArkUI 上的对应物)。
* `opacity` 与 `translate` 都走**合成器**,不触发 layout/paint ——
* WebUI 那条 `rise-in` 的注释专门说了要“只动 opacity + transform
* (否则 2026-09-15 用户报过动画卡顿)”。
*/
.opacity(this.calPaneIn)
.translate({ y: (1 - this.calPaneIn) * Theme.riseInOffset })
}
/*
* 内容列吃 **剩余** 宽度(不是父容器的 100%)——
* 理由见本列开头的长注释:写 `100%` 会与侧栏的 60vp **相加而溢出屏幕**,
* 把靠右的内容(`「我的」页压暗滑杆的数值标签`)顶到可视区之外。
*/
.layoutWeight(1)
.height('100%')
/*
* ★ 2026-09-17:窄屏壁纸可见化(对齐 WebUI 的 .narrow-shell)。
* WebUI 的正文面(卡片/气泡)不透明,但**容器**(.narrow-shell / .narrow-stack)
* 透明 —— 壁纸从卡片间健、从顶/底边缘透出。鸿蒙这边由内向外是:
* · 内容窗格 (CommPage 等) 已是 `bgActive ? Transparent : pageBg`(判据钉 ≥5 处);
* · 这里是它们的父容器,原来恒不透明 surface ⇒ 内层透明也被盖住 ⇒ 壁纸在窄屏上看不见。
* 所以窄屏 + bgActive 时这里也透明,壁纸才透得过。宽屏仍用 surface(玻璃面板从缝隙露壁纸
* 是既定的 app-shell 观感,不动)。
* 不加 backgroundBlurStyle:卡片是"正文面不透"的那一层(Theme 注释原话),
* 玻璃只留给浮层(NavBar),不是这里。
*/
/*
* ★★ 2026-09-19 修(用户报「界面完全不透明,不显示背景」):
*
* 这一行原来写的是:
* `.backgroundColor(this.isWide ? Theme.surface
* : (this.bgActive ? Color.Transparent : Theme.surface))`
*
* 宽屏那一支**无条件** `Theme.surface` —— `bgActive` 根本没参与判断。
* 而平板(2800×1840,宽高比 1.52 > 1.2)**就是宽屏**
* ⇒ 整个内容列被一块不透明面盖死,壁纸只在底部那条缝里露出来。
*
* 实测现场:真平板 HUAWEI MatePad Pro 上,截图里只有最下沿能看到
* 一丝极光图案,其余全是不透明白底。
*
* ★ 为什么写成这样(推测的成因,记下来免下次重蹈):
* 宽屏那支是从 WebUI `app-shell` 的"面板"几何抄来的
* (padding/gap/radius —— 注释就在旁边),而 `app-shell > *`
* 在壁纸开启时是**半透玻璃**(`index.css:1070`),不是纯白。
* 抄几何时把"面"也一起写成了实心 `surface`,漏了 `bgActive` 这一维。
*
* ★ 修法与窄屏那一支**对齐**(窄屏本来就是对的):
* 壁纸开着就透明,让底下的 `WallpaperLayer()` 透上来。
*/
.attributeModifier(GlassCardModifier.of(this.bgActive))
/*
* ★★ 2026-09-20 修(用户:「你一改了之后,我列表都没法滚动了」)。
*
* 这一句 `.clip(this.isWide)` 才是**真正的阻塞点**(不是我今天新加的那两处)。
*
* WebUI 的 `index.css:968` 在**同一个规则块**里就写着这条教训:
*
* .app-shell > * {
* border-radius: var(--radius-card);
* // ★ 这里**不能**写 overflow: hidden(2026-09-14 用户:
* // 「通信页面完全无法上下滑动」)。
* // 面板自己就是滚动容器,而这条规则的特异性比 Tailwind 的
* // .overflow-y-auto 高 ⇒ 滚动被静默干掉:实测当时**一个可滚动
* // 容器都不存在**(scrollerCount=0)。
*
* 我们这边同一个形状:这一层是内容列的根,`Navigation` 的 `List` 在它里面,
* 而 `clip(true)` 把子树的滚动裁掉了 —— `List [231,452,1151,2202]` 还在
* (高度 1750px,只放得下 6 张卡),但**滚不动**:实测 fling 之后
* 第一封仍是 y=526。
*
* ★ WebUI 的解法就是"**圆角保留、不裁切**"——角落的方角残影用
* `background-clip: padding-box` 处理,代价是溢出内容在圆角处可能露一点,
* 比"完全不能滚动"好得多(原文)。这里照做:**去掉 clip,保留圆角**。
*/
/*
* ★★ 2026-09-21 修「窄屏圆角全没了」(用户:「你的邮件的圆角呢?日历的圆角呢?」)。
*
* 原来写的是 `this.isWide ? Theme.glassRadius : 0` —— **窄屏恒 0**,
* 于是窄屏上「通信 / 日历 / 联系人 / 我的」四个窗格全是**直角**,
* 与底部那条圆角玻璃导航条根本不是一套语汇。
*
* WebUI 那边两条分支**都给圆角**(`index.css`):
* .app-shell > * { border-radius: var(--radius-card); } ← 宽屏
* .narrow-shell > * { border-radius: var(--radius-card); } ← 窄屏
* 差别只在**留白**:宽屏四边都留 `--pane-gap`,窄屏只留左右上
* (下面那条 padding 里 `bottom: 0` 就是它),因为底栏自己带 margin。
*
* 我当初把"窄屏不留白"顺手写成了"窄屏不圆角",两件事被并成了一个三元。
*/
.borderRadius(Theme.glassRadius)
/*
* ★★ 2026-09-21 修(用户:「多个页面圆角下方还是有白框(直角框)」)。
*
* ── 根因(像素级定位,不是猜)──
*
* `uitest dumpLayout` 实测(窄屏 1008px,写信页):
*
* 内容列(有圆角 + 玻璃) [28,140][980,1957] bg=#C7FFFFFF
* Navigation 的包装 [30,142][978,1955] ← **四周各小 2px**
* 里面的白底行 [30,142][978,303] bg=#FFFFFFFF
*
* 那个内层方角在几何上**落在圆角弧的外面**:
* 圆角半径 14,弧心在 (42,1943),而内层方角 (30,1955) 到弧心
* √(12²+12²) ≈ 17.0 > 14 ⇒ **戳出去了**。
* 于是外壳的圆角被咬掉一块,露出内层的直角白边。
* 像素实测(修前):y=1946 时圆角已收窄到 x=45,而 x=30..39 仍是纯白。
*
* ── 为什么修法是"裁"而不是"给内层也加圆角" ──
*
* 对齐 WebUI:`index.css` 的 `.app-shell > *` 是**同一个规则块**里
* 圆角 + 裁切一起给的:
*
* html[data-bg='on'] .app-shell > * {
* border-radius: var(--radius-card);
* overflow: hidden; ← 圆角要真的裁掉溢出,否则方角照露
* }
*
* 而**壁纸关着时它不裁**(那条注释写着:不能无条件写 `overflow: hidden`,
* 那会在面板自己就是滚动容器时把滚动干掉 —— 本仓踩过,
* 见下面 `contentEndOffset` 那段的原委)。
*
* ⇒ 所以这里**跟着 `bgActive` 走**,与 WebUI 逐字对应:
* 壁纸开着(有圆角要保护)⇒ 裁;壁纸关着(无圆角)⇒ 不裁,保住滚动。
*
* ★ 为什么内层那 2px 无法从这一侧消掉:它是 `Navigation` 自己的包装层
* (`__Common__`),不是我们写的 padding —— 够不着,只能从外面裁。
*/
.clip(this.bgActive)
/*
* ★ 这里**不再**让位(原来写的是 `padding({ bottom: NAV_CONTENT_RESERVE })`)。
*
* 让位改到各个滚动容器的**内容末尾**(`contentEndOffset(this.navReserve)`)。
* 两者看起来一样、实际差得远:
* · 旧写法(窗格 padding):**缩短窗格** ⇒ 内容永远到不了条底下 ⇒
* 玻璃条背后只剩一张已经被壁纸层模糊过的壁纸 ⇒ 系统材质无东西可糊 ⇒
* 看起来是一块普通的浅色面板,而不是玻璃。
* (用户 2026-09-17:「底栏不是玻璃质感,滑动内容无法穿过底栏」——同一根因)
* · 新写法(容器末尾 offset):窗格**满高**,内容滑得到条底下(真正穿过),
* 而末尾仍留出那段高度 ⇒ 最后一行照样滚得出来、点得到。
*
* 与 WebUI 同构:`.narrow-shell` 是 `flex-col`,列表**满高**、
* `.narrow-nav` 作为兄弟叠在上面(自带 margin),内容从它底下穿过。
*/
}
.width('100%')
.height('100%')
/*
* ★ 窗格切换的过场动画(用户 2026-09-17:「一方面一点动画都没有」)。
*
* 为什么必须有这一行:`animateTo` 只负责**开一个动画窗口**,
* 被换掉的那棵子树自己不声明 transition 就什么都不会动 ——
* 实测只包 `animateTo` 与硬切**肉眼看不出区别**。
*
* 形状对齐 WebUI 的 `.pane-rise` / `.rise-in`(`index.css:1208`):
* `@keyframes rise-in { from { opacity: 0; transform: translateY(4px) } }`
* ⇒ 4px 上浮 + 淡入,**只动刚出现的那块**。
* 刻意不做整屏淡入/缩放:用户 09-14 否掉过"整屏一起淡"
* (`index.css:1152` 原话「页面级入场仍不做」)。
*
* `TransitionEffect.asymmetric` 两边的时长不一样:
* 进场 180ms(durBase,走完整条缓出曲线);
* 出场 120ms(durFast)—— 旧窗格要**让位**得快,
* 否则两层内容同时半透明地叠在屏幕上,看起来像"闪一下"。
*/
.padding({
/*
* ★★ 2026-09-21:窄屏**也要**左右留白(原来是 `isWide ? paneGap : 0`)。
*
* WebUI `.narrow-shell` 的 padding 是 `var(--pane-gap) var(--pane-gap) 0`
* ——左右上和宽屏一样留 10px,只有底边为 0(底栏自己带 margin)。
* 我们用 `isWide ? paneGap : 0` 把"底边为 0"错推广成了"四边都为 0",
* 于是窄屏面板**贴死屏幕两侧**:既没有圆角的余地,也没有投影的余地,
* 看起来是一块被屏幕裁掉的白板,而不是一张浮起来的卡。
* (这正是用户说"邮箱页面丑死了、跟 WebUI 没法比"的一块。)
*/
left: Theme.paneGap,
right: Theme.paneGap,
/*
* ★ 避让区就在这里:状态栏高度当 padding-top。
*
* 全屏(`setWindowLayoutFullScreen(true)`)之后内容的画布从 y=0 开始 ——
* 而 y=0..statusBar 被时钟/电量占着。把这个高度当 padding-top,
* 实际绘制的就回到避让区下面了(上一次退回,正是因为少了这一行)。
*
* 加在**内容层**而不是根 `Stack`:壁纸层是 `Stack` 的底层兄弟,
* 根上加了 padding 会把壁纸一起缩进去,黑边只是换个地方出现。
*/
/*
* ★★ 2026-09-19 修(用户报「顶部还是被状态条挡住了」):
*
* 原来写的是:`top: this.isWide ? Theme.paneGap : this.windowInsets.statusBar`
*
* 宽屏那一支只留 `paneGap`(10vp),**把状态栏避让整个丢掉了**。
* 平板(2800×1840,宽高比 1.52)**就是宽屏** ⇒ 顶栏被顶进状态栏里。
*
* 实测现场(HUAWEI MatePad Pro):状态栏占 y=0..83px,
* 而我们的 tab 文案(收件箱/发件箱/授权)渲染在 y=83 ——
* 截图里 `收件箱 20` 与系统的 `浏览器 10:06` 挤在同一行。
*
* ★ 与旁边那个背景透明 bug 是**同一个形状**:
* `this.isWide ? A : B` 里,A 那一支是照 WebUI 几何写的,
* 而 WebUI 没有"状态栏避让"这个概念(浏览器里没有状态栏),
* 所以抄几何时把这一维也一起丢了。
*
* ⇒ 避让是**窗口级事实**,与宽窄无关 —— 它不该出现在任何三元的
* "宽屏那一支"里。宽屏只是**额外多留** paneGap(面板投影的余量),
* 不是**代替**避让。
*/
/*
* 避让区 + 面板留白。避让是**窗口级事实**(全屏后内容从 y=0 起画),
* 与宽窄无关;留白则两端都有(WebUI 两条 shell 规则的 padding-top 都是 gap)。
*
* ★★ 2026-09-24 加 `windowDecor`(用户:「右侧三键应当有独立避让」)。
*
* 为何不能只靠 `statusBar`:2in1 形态**没有状态栏** ⇒
* `TYPE_SYSTEM.topRect` 是 **0**,而右上角仍浮着最小化/最大化/关闭三键
* (隐掉标题栏白条后它们仍在,官方:全屏悬浮态**固定 37vp**)。
* 只看 `statusBar` 就等于认定"2in1 顶部无需避让" ⇒ 内容(右上是「授权」页签)
* 被三键压住 —— 截图硬证。
*
* ★ 取**两者较大者**而不是相加:手机上有状态栏、没装饰(37 为 0);
* 2in1 上有装饰、没状态栏(statusBar 为 0)—— 两个量互斥,相加会多让一份。
* 若哪天两者同时非 0,取大也是更安全的那个(宁可多让一点,不可压住)。
*/
top: (this.windowInsets.statusBar > this.windowInsets.windowDecor
? this.windowInsets.statusBar : this.windowInsets.windowDecor) + Theme.paneGap,
/*
* 底边 = 0:窄屏的底栏是**悬浮**的、自己带 `NAV_BAR_BOTTOM` 的 margin,
* 内容要能从它底下穿过(玻璃才有东西可糊,见 `navReserve` 那段)。
* 宽屏没有底栏,才由这里补上留白。
*/
bottom: this.isWide ? Theme.paneGap : 0
/*
* 内容吃 **Column 里 NavBar 之外的剩余高度**。
* 原来这里是 `height('100%')`(占满 Stack)—— 加了 NavBar 占位后
* 那句会把导航条挤出可视区(实测:底栏整个消失)。
*/
})
.layoutWeight(1)
/*
* 底部导航条 —— **Column 的第二个子项**(不是覆盖层)。
*
* 它**占位**,所以内容盒自然停在它上方,不必再逐处 `contentEndOffset`。
* `NavBar` 内部仍保留自己的圆角/外边距/玻璃材质 ——
* 那是"悬浮**观感**",由圆角+阴影表达,不再靠 `position` 浮起来。
* 宽屏没有底栏,这支只有一个子项,行为与原来一致。
*/
if (!this.isWide) {
this.NavBar()
}
}
.width('100%')
.layoutWeight(1)
/*
* ── 主题切换的**旧主题位图**(Stack 的最后一个子元素 ⇒ 最上层)──
*
* 见 `toggleTheme` 的注释:ArkUI 没有 CSS 那种"变量变了各自补间"的机制
* (主题走 `app.setColorMode()`,颜色是**系统资源**、没有中间值可补间)
* —— 所以把旧界面画成一张图盖在上面淡出。
*
* ★ `hitTestBehavior(None)`:动画期间用户可能正好在点某个按钮,
* 不该被这层静态图吃掉(它在动画结束就会被置 `null`)。
*/
if (this.themeFadeImage !== null) {
Image(this.themeFadeImage)
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.opacity(this.themeFadeOpacity)
.hitTestBehavior(HitTestMode.None)
}
/*
* ★★ 2026-09-25 顶栏文案(摘要 / 一言 / 签名轮播)—— 放这里,理由如下。
*
* ── 用户要的位置(原话)──
* 「我要的效果是在退出,最大化,最小化三个按键的左边」
* 即**窗口右上、三键左侧**那一带,不是页签条(我先做错了一次:
* 页签条所在的列表栏只有 608px 宽,塞不下)。
*
* ── 为什么自绘而不 `setWindowTitle` ──
* 官方 `setWindowTitle` 实测**确实**能在那一行显示文字(已验证:
* 截图里 `探针:一言在标题栏 —— 出自本地句库` 就显示在三键左边)。
* 但它有三条硬伤:
* ① 必须**保持窗口装饰可见** ⇒ 标题栏那条浅色横带回来,
* 与用户先前要的「顶栏沉浸」直接冲突(那个是刚修好的);
* ② 是瞬时替换,**做不了**用户要的「消失出现动画」;
* ③ 字号/颜色跟随系统,我们控制不了。
* ⇒ 自绘:装饰仍隐藏(沉浸保留),文字画在**装饰带原来的位置**。
*
* ── 位置怎么算 ──
* 实测(dumpLayout,3120×2080 窗口):
* 装饰带 y 281→351(高 70px)
* 三键区 x 2340→2605
* ⇒ 文案右边界对齐到三键左缘,垂直居中于装饰带。
* 用 `position` 绝对定位(这个 Stack 是整窗根层)。
*
* ★ `hitTestBehavior(None)`:它是装饰性的,
* 绝不能吃掉底下的点击(尤其三键区就在它右边)。
* 点它换下一条的交互也因此去掉 —— 那与"不吃事件"矛盾,
* 而用户要的是自动轮播,不需要手动入口。
*/
/*
* 只在**有装饰带**(2in1/PC)且**有内容**时显示:
* 手机形态 windowDecor=0(没有三键、也就没有那条带),此时不该出现 ——
* 否则它会浮在状态栏上,而手机状态栏本来就窄。
*/
if (this.topbarTexts().length > 0 && this.windowInsets.windowDecor > 0) {
Row() {
Text(this.topbarTexts()[this.topIndex % this.topbarTexts().length])
/*
* ★★ 2026-09-25 字号 12 → 14(用户:「大小也偏小」)。
*
* 实测对照(1px ≈ 1.91vp):
* 三键图标 38px 高、按钮 53px 高;
* 我原来的文案只有 27px 高(fontSize 12)—— 明显比旁边小一档。
* 14vp 的文本行高约 33px,与三键图标同量级,不与两侧失衡。
*/
.fontSize(TOPBAR_FONT_SIZE)
.fontColor(Theme.textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.opacity(this.topOpacity)
}
/*
* ── 垂直居中:**用装饰带自己的高度**,不猜 y 偏移 ──
*
* 实测(dumpLayout):装饰带 y 281..351 = 70px = **37vp**,
* 三键 y 290..343 **居中**于带内(中心 316.5)。
* 我原来写 `.position({ y: 8 })`(凭感觉给 8vp),
* 文案中心落在 309.5 ⇒ **差 7px**,肉眼能看出"没对齐"。
*
* 改成 `height(windowDecor)` + `VerticalAlign.Center`:
* 带多高就多高,文字自己居中 —— 以后带高变了也不用重算那个偏移。
*/
.height(this.windowInsets.windowDecor)
.justifyContent(FlexAlign.Start)
.alignItems(VerticalAlign.Center)
.position({ x: 0, y: 0 })
.width('100%')
.padding({ left: TOPBAR_STRIP_LEFT })
.hitTestBehavior(HitTestMode.None)
}
}
.width('100%')
.height('100%')
/*
* ★ 根 `Stack` **不带避让 padding** —— 壁纸层要铺到屏幕四边(全屏的意义就在这里)。
* 避让加在内容层与底栏上(见各自的 padding),这样黑边才真的消失。
*/
.onAreaChange((oldValue: Area, newValue: Area) => {
this.isWide = (newValue.width as number) >= 768;
this.recomputeNavReserve();
})
/*
* ★★ 2026-09-21 加 id:**主题切换交叉淡出**用它抓整页快照
* (`getComponentSnapshot().get(id)`,见 `Motion.captureForThemeFade`)。
* 挂在根 `Stack` 上 ⇒ 抓到的是"整个界面",包括壁纸层。
*/
.id(THEME_FADE_ROOT_ID)
/*
* ★★ 2in1 快捷键(用户 2026-09-21:「接下来做一下 2in1 上的快捷键,
* 比如快捷键打开发信页面」)。
*
* 用官方的 **`keyboardShortcut`(组件快捷键事件,API 10+)**,
* 而不是自己 `onKeyEvent` 收 Ctrl+字母。理由(官方 API 文档原文):
* · 「即使组件未获焦或是在所在页面未展示,只要已经挂载到**获焦窗口**
* 的组件树上就会响应自定义组合键」
* · 「无论组件是否获焦 —— 只要窗口获焦,快捷键就会响应」
* 自己收 onKeyEvent 则要**先让某个组件获焦**,而邮件列表里焦点落在哪
* 是不确定的(点一下就换),快捷键会时灵时不灵。
*
* 绑定位置选**根 Stack**:它是"整个 window 的组件树"的根,
* 只要窗口在就生效。绑在 FAB 上不行 —— FAB 在别的 `if` 分支里、
* 窄屏/宽屏位置也不同,它会随分支挂载/卸载。
*
* 键位选 `Ctrl+N`:官方文档给了硬约束 ——
* 「控制键 Ctrl、Shift、Alt 及它们的组合加上热键的单个字符」,
* 而禁止绑定的五个系统组合键(Alt+F4 / Alt+Tab / Ctrl+Shift+Esc 等)
* 都不含 Ctrl+N。写邮件在桌面端历来是 Ctrl+N(新窗口/新邮件),
* 迁移成本最低。
*
* ★ 文档还有一条要记住的坑:「多个不同组件设置相同组合键 ⇒
* **只响应节点树上的深度最浅的组件**,其它组件不响应」。
* 所以别再给别处的"写信"按钮补一个同键位绑定 —— 那不是"多一个入口",
* 而是**让这一处失效**(浅的那个永远赢)。
*/
.keyboardShortcut('n', [ModifierKey.CTRL], () => {
/*
* ★ 这里**不能**直接叫 `openCompose()`:根组件是 `MainPage`,
* 而 `openCompose()` 住在 `CommPage` 上(写信要用 `CommPage` 自己的
* `navPathStack`,宽屏 Split 才能在右栏开)。
*
* 两步:
* ① 把通信页切到前台 —— 否则用户此刻若在日历页,
* `CommPage` 因条件挂载还没实例化,没有监听者;
* ② 提"要写信"的意图,由 `ComposeIntent` 负责
* "此刻有人听就当场给、没人听就存着"。
*/
this.currentIndex = 0;
ComposeIntent.request();
})
/*
* ★★ 2026-09-24:**主页回车打开发信页**(用户:「主页回车打开写信」)。
*
* ── 为何在此层(根 Stack)而不是 `CommPage` ──
* `CommPage` 是**条件挂载**的(`if (this.currentIndex === 0)`)——
* 用户在日历页时它不存在,绑在上面就收不到键。
* 而根 Stack 是「整个 window 的组件树」的根,一直在。
*
* 同时它也是**兵底层**:详情页自己也挂了 `onKeyEvent`,
* 而键事件先给深层、未消费才向父冒泡(`common.d.ts:19560`)⇒
* 在详情页里按回车是"打开回复"(详情页消费掉,到不了这里),
* 在列表页按回车才落到这条"打开发信页"。两不打架。
*
* ── 与 Ctrl+N 的关系 ──
* 两者都是"写信入口",走**同一个** `ComposeIntent.request()`
* (不另写一条路)—— 区别只是键位:
* · `Ctrl+N` = 桌面端惯例(组合键走 `keyboardShortcut`,无焦点要求);
* · 单按 `Enter` = 用户这次点名要的(走 `onKeyEvent`)。
*/
.onKeyEvent((e: KeyEvent): boolean => {
if (!isKeyDown(e.type)) {
return false;
}
/*
* ★★ 2026-09-24:**Esc = 返回上一级**(用户:「esc返回上一级」)。
*
* ── 为何在根上做 ──
* 与回车同一个原因:详情/写信页自己的 `onKeyEvent` 因组件不获焦而**从不触发**
* (实测硬证),键全冒到这里 ⇒ 退层的语义只能在这里派发。
*
* ── 顺序:先关最上层容器,再退导航栈 ──
* 写信页是**导航栈上的一层**(`ComposeDestination`)。
* 实测:在写信页按 Esc 没反应 —— 因为 `ComposePage` 自己的 Esc 只在
* "地址候选列表开着"时生效(`onToKey` 里那道 `if (!this.suggestOpen) return`),
* 候选没开时那个键就冒上来了,而根上原来**没有 Esc 分支**。
*
* 这里不看"是哪一页",只看"栈里有没有东西" ——
* 有就 pop 一层(正是用户要的"返回上一级")。
* 用 AppStorage 上的 `KEY_OPEN_MAIL_ID` 只能表示**详情**,
* 而写信也占一层 ⇒ 改成看 `CommPage` 发布的**栈深度**。
*/
if (isEscapeKey(e.keyCode)) {
const depth: number = AppStorage.get<number>(KEY_COMM_STACK_DEPTH) ?? 0;
if (depth > 0) {
PopIntent.request();
return true;
}
/* 栈空(在列表)⇒ 返回键交给系统(别吞掉,那会让用户退不出 App) */
return false;
}
/*
* ★★ 2026-09-24 改成**按状态派发**(实测撞出来的真 bug)。
*
* 详情页自己也挂了 `onKeyEvent`,但官方要求组件**获得焦点**才响应
* (`common.d.ts:19510`),而它那个根 `Stack` 默认不可聚焦、
* 加 `.focusable(true)` 也没人 requestFocus ⇒ **详情页的处理器从不触发**,
* 键直接冒到这里。
* 实测后果:在详情页按回车弹出的是**写信页**(被下面这条分支抢先),
* 而不是用户要的"回复"(截图硬证)。
*
* ⇒ 这里读 `KEY_OPEN_MAIL_ID`(`CommPage.openMail` 发布)判断
* "此刻是不是在看某封邮件",据此派发:
* · 在看 → 回车=**回复**(与详情页右下那个回复球同一个入口);
* · 在列表 → 回车=**写信**。
* WebUI 也是**一处**全局监听 + 按状态分派(`App.tsx`),不是每页各挂一个。
*/
if (!isEnterKey(e.keyCode)) {
return false;
}
const openMailId: string = AppStorage.get<string>(KEY_OPEN_MAIL_ID) ?? '';
if (openMailId.length > 0) {
/*
* 详情开着 ⇒ 回车交给详情去开回复。
*
* 走**同一个** ComposeIntent 两半机制(有人听就当场给、没人听就存着)——
* 详情此时一定挂着(它就是弹回复的那个组件),所以当场就会送到。
* ★ 不走"直接叫某个方法":根组件拿不到 MailDetailView 的实例,
* 而本仓已有的意图格子正好就是为"够不着"设计的。
*/
ReplyIntent.request();
return true;
}
this.currentIndex = 0;
ComposeIntent.request();
return true;
})
}
}