*
← 「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`):
*
* ← 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(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(KEY_OPEN_MAIL_ID, mailId);
this.publishStackDepth();
}
/**
* 详情栏空了(返回/切栏)⇒ 清掉发布键。
*
* 与 `openMail` 成对:只写不清的话,回到列表再按回车会以为"还在详情",
* 回车会去开回复而不是写信。
*/
private closeDetail(): void {
this.currentMailId = '';
AppStorage.setOrCreate(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}`),所以窄屏滑上来的详情层
* 把列表整块(含加号)盖住 —— 详情自己的回复按钮才露得出来。
* 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` 下 `{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 {
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`
* 里面先 `` 再 `{short}`。
* 也就是说 WebUI **一直有文字**(四段:通信 / 日历 / 联系人 / 我的)。
* 所以“去掉文字”既不是对齐 WebUI,也不是用户当时想要的最终形态 ——
* 用户 2026-09-17 原话:「还是在导航栏加上文字吧,没有文字还是不好看」。
*
* 选中态:**图标与文字一起换色**,不引入背景/指示条 ——
* 与 WebUI 同一套表达(用户 2026-09-14:「选中对应的文字和图标变色即可」)。
*/
/*
* ★★ 2026-09-19 修 bug(用户:「底栏数字为什么显示在图标下面?」)。
*
* 图标外面再包一层 `Stack`,**只为把徽标锚在图标上**。
*
* 徽标原先写成这个 `Column` 的**第三个子节点** —— 于是它参与竖向布局,
* 被排在图标、文字**之后**,看起来就是"数字掉到图标下面"。
* WebUI 不是那样:`NarrowNav.tsx:87-89` 是
* ``
* —— `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 }
* ← 内容(flex 子项,**占位**)
* ← `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(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(KEY_OPEN_MAIL_ID) ?? '';
if (openMailId.length > 0) {
/*
* 详情开着 ⇒ 回车交给详情去开回复。
*
* 走**同一个** ComposeIntent 两半机制(有人听就当场给、没人听就存着)——
* 详情此时一定挂着(它就是弹回复的那个组件),所以当场就会送到。
* ★ 不走"直接叫某个方法":根组件拿不到 MailDetailView 的实例,
* 而本仓已有的意图格子正好就是为"够不着"设计的。
*/
ReplyIntent.request();
return true;
}
this.currentIndex = 0;
ComposeIntent.request();
return true;
})
}
}