Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/MainPage.ets
JianFeeeee cbb1e1ee62 跨端: 修「深色下玻璃变灰纸板」+「主题选了不生效」(两个真 bug)
用户问「你的玻璃效果呢?」。我上手先取像素,结果是**两个一直存在的真 bug**,
不是观感偏好问题。两个都有实测数据。

## ① 深色下玻璃 alpha 没跟着翻 →「像深色卡片浮在灰纸板上」

### 实测

深色主题 + aurora 壁纸,同一行交替采样(`/tmp/g1.raw`,1008×2232):

    y=670   卡片缝隙 rgb(199,199,199)   卡片内 rgb(27,37,50)
    y=880   卡片缝隙 rgb(201,203,201)   卡片内 rgb(27,36,53)

缝隙是**浅灰 199**、比卡片还亮。而 199 恰好 = `0.78 × 255`
(壁纸层实测 x=4..24 为 rgb(0,1,3),近黑)—— 就是 `glassCardWall` 那层白纱。

### 根因:我把 WebUI 的一句话读漏了后半句

`Theme.glassCard` / `glassCardWall` 是**单一值、不分深浅**。我当初写的理由是
「基色恒为白,主题之间只差 alpha」——但 WebUI 原话是
「**基材恒为白**,主题之间**只差 alpha**」(`index.css:406`、`:1546`)。
我只抄了前半句,把「只差 alpha」误读成「alpha 也不用变」。

WebUI `.dark` 的实际取值:

    --glass-card-a:      0.06     ← 浅色 0.92
    --glass-card-wall-a: 0.04     ← 浅色 0.78

**深浅差 15 倍**:深色下白纱要几乎撤掉让深壁纸透上来,浅色下才用厚白纱盖亮壁纸。
我用同一个 0.92 配两套主题 ⇒ 深色下壁纸被糊成浅灰,**玻璃感与深色同时消失**。

### 修法

`Theme.glassCardFor(wall, dark?)` —— 与 `accentFor`/`textSubtleFor` 同一范式,
深浅从 `Theme.isDarkNow()`(`AppStorage` 那个发布键)读,不靠调用方自觉传。
新增 `glassCardDark #0FFFFFFF` / `glassCardWallDark #0AFFFFFF`。

改后同处实测:缝隙 rgb(3..16),壁纸透上来了;浅色档回归检查未变(244/239,厚白纱仍在)。

## ② 用户选的主题被无条件覆盖 →「选了深色但界面还是浅的」

### 实测

「我的」页选「深色」后:
· 按钮显示选中、服务端也记下 `theme:'dark'`(`GET /me/appearance` 确认);
· 但界面仍浅色,且文字浅色压浅底 —— 实测背景 `rgb(243,243,243)` /
  文字 `rgb(243,243,243)`,**对比度 ≈1.0:1,完全不可读**。

### 根因(两处叠加)

① `EntryAbility.onCreate` 有一句**无条件**的
   `setColorMode(COLOR_MODE_NOT_SET)`(= 跟随系统)—— 每次冷启都把用户的选择重置掉。
   它是目录迁移时抄进来的(`git log -S` 指向 `f9d757b chore: directory migration`),
   **没有注释说明为什么,也没有谁在用**。已删除(连带上游 import)。

② `MainPage.applyAppearance()` 算出了 `isDarkNow`、发布了 `AppStorage`,
   但**从来没调用过 `store.applyTheme(...)`** ⇒ 系统色彩模式根本没被设过。

### 修法

· 删掉 EntryAbility 那句无条件覆盖(附长注释说明为什么由页面负责);
· `applyAppearance()` 补上 `store.applyTheme(ctx, snap.theme)`。

★ **顺序**:`KEY_IS_DARK` 发布必须在 `applyTheme` **之前** ——
  因为 `glassCardFor()` 是从那个键读深浅的,反过来会让卡片用上一轮的深浅画一帧。
  `applyThemeNow()` 里同一处顺序也一并修正。

## 判据

`cross-client-theme` 的两条原本把 `Theme.glassCard`/`glassCardWall` 两个**字面量**钉死,
被我这次正确的改动撞红 —— 我没有放宽它们,而是改成钉**行为**:
· 卡片必须经 `glassCardFor(...)` 取色(入口存在);
· 四个令牌都要在 Theme 里存在;
· **深色档不得与浅色档同值**(同值 = 主题感知是假的)。

变异验证:① 深色档改回与浅色同值 → 3 条红;② 卡片改回不分深浅 → 5 条红;还原 → 21/21 绿。

## 验证状态

✓ 两个修复都在设备上取**像素**验证(不是看截图说"像了")
✓ `cross-client-theme` 21/21、全量套件 545 pass(3 红均为改动前既有:
  `harmony-admin` 两处 = 我上一轮 `SecuritySection` 拆分遗留、`build-stamp` = 产物戳过期)
✓ 编译通过、进程存活、无新 jscrash
2026-09-21 18:46:19 +08:00

4402 lines
213 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';
import { MailDetailView } from './MailDetailPage';
import { MailApi, InboxResponse } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SseService, SseEvent } from '../api/SseService';
import { AppearanceStore } from '../common/AppearanceStore';
import { MailStore, MailSnapshot, AccountError, INBOX_PAGE_SIZE } from '../common/MailStore';
import { AppearanceApi } from '../api/AppearanceApi';
import { performLogout } from '../api/Logout';
import { ComposeIntent } from '../common/ComposeIntent';
import { Configuration, ConfigurationConstant, EnvironmentCallback, common } from '@kit.AbilityKit';
import { image } from '@kit.ImageKit';
import { AppearanceSnapshot, isDarkMode, scrimOpacity } from '../model/Appearance';
import { BackgroundPlan, PresetLayer, TRANSPARENT, resolveBackground } from '../model/Wallpaper';
import { MailSummary, Contact, PermissionRequest, DecideResponse, SentResponse, PendingResponse } from '../model/Models';
import {
MailLike,
PermissionGroup,
groupPermissions,
MailSplit,
SessionGroup,
groupMailsBySession,
isFlatGroup,
partialLoadNotice,
sumUnreadTotals,
splitByPermission,
budgetState,
budgetLabel,
nextContactView,
contactViewTitle,
lastFromIsHuman,
permissionLabel,
permissionChipText,
permissionHint,
enforcementLabel,
shortTimeOf
} from '../model/MailGrouping';
import {
COMM_TABS,
commTabLabel,
commTabIndex,
commTabFromIndex,
normalizeCommTab,
badgeCount,
badgeText,
badgeTone,
emptyTitle,
emptyHint
} from '../model/CommTabs';
import { MailDetailParams, ComposeParams } from '../model/RouteParams';
import { PushService, PushRoute } from '../api/PushService';
/*
* ⚠️ **`import` 必须在这一行的位置**:ArkTS 要求所有 import 都在**任何其它语句之前**
* (`arkts-no-misplaced-imports`),与它们之间隔的是常量、表还是别的语句无关。
*
* 这一笔我犯过:`7647c24` 里我把一张常量表(`NAV_MATERIAL_OF`)插在了这两组 import
* **之前**,于是 `hvigorw assembleHap` 直接报错(`f31bc02`/`7647c24` 上都红)。
* **归属说明**:那条错是我造成的,`git show 7647c24:…/MainPage.ets` 可复核
* (最后一条 import 在第 80 行,而第 63 行已是 `const NAV_MATERIAL_OF`)。
* 此处原先写的是"由 pi 实测" —— 那句话不准确:是本机**某个** pi 会话/别人测的,
* 我无法独立复核"是谁",这类**归属断言**和"锚短哈希"同族(复核方没法复现"谁做的"),
* 所以改成只陈述**可复核的事实**:错误存在、在哪个提交、怎么复核。
*
* 那张表和它的 import 后来都随"导航条改回固定档"一起删了(见下面 `NavBar` 的注释);
* 这条注释留在 **import 区**而不是跟着表走,因为要守的是"import 在最前"这个位置本身。
*/
import { CalendarPage } from './CalendarPage';
import { ComposeView } from './ComposePage';
import { SettingsPane } from './SettingsPage';
import { LengthMetrics, ComponentContent } from '@kit.ArkUI';
import { Insets, KEY_WINDOW_INSETS } from '../model/WindowInsets';
import {
LIST_FADE_LENGTH,
NAV_BAR_BOTTOM,
NAV_BAR_HEIGHT,
NAV_BAR_RADIUS,
NAV_BAR_SIDE,
NAV_CONTENT_RESERVE,
NAV_ITEM_MIN_HIT,
NAV_ITEMS,
HEADER_BACK_HIT,
navBadgeCount,
navBadgeText,
navBadgeTone,
NavItem,
normalizeNavIndex, TAB_BAR_HEIGHT } from '../model/NavItems';
/** 一页取多少封。取满了就要如实提示"可能还有更多"(服务端 total 是未读数,不是总封数)。 */
const MAIL_DETAIL_ROUTE: string = 'mail-detail';
/**
* 写信页签的路由名(宽屏在**右栏**内嵌打开,见 `ComposeDestination`)。
*
* WebUI `App.tsx:179` 宽屏下写信只是把 `main` 那一格换成 `<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';
/** WebUI 邮件列表统一使用 MM/DD HH:mm,避免把 ISO 原文塞进窄列表。 */
function compactMailTime(iso: string): string {
const value: Date = new Date(iso);
if (Number.isNaN(value.getTime())) {
return '';
}
const month: string = (value.getMonth() + 1).toString().padStart(2, '0');
const day: string = value.getDate().toString().padStart(2, '0');
const hour: string = value.getHours().toString().padStart(2, '0');
const minute: string = value.getMinutes().toString().padStart(2, '0');
return month + '/' + day + ' ' + hour + ':' + minute;
}
/**
* Navigation 目标页:按官方示例在 NavDestination.onReady 读取路径参数。
* 系统 Navigation 自动决定 Stack / Split;这里不读窗口宽度、不手搓分栏。
*/
/**
* 右栏占位(`Navigation` **split 模式**下、栈空时显示的那一块)。
*
* ★★ 2026-09-20 补(用户:「你自己看看跟 webui 相比,观感真的差很多」)。
*
* 宽屏下详情是**常驻右栏**;没选邮件时 WebUI 有引导(`MailView.tsx:121-129`):
* 信封图标 + 「选择一封邮件查看,或点击左侧「新建」写邮件」
* 而我们什么都不渲染 ⇒ 两栏并排时右栏是**一大片空白**
* (实测同 1107vp 视口并排:WebUI 那栏中央有图标+文案,我们那栏全空)。
*
* ★ 为什么用 `splitPlaceholder` 而不是"在 `NavDestination` 里加 `else` 分支":
* `NavDestination` **只在 push 之后才挂载**,栈空时它根本不存在 ——
* 我第一版就是写在它的 `else` 里,**一次都不会显示**(已撤销)。
* `splitPlaceholder(ComponentContent)` 是系统给"右栏默认页"的专用入口
* (`navigation.d.ts`,API 20+,我们是 23),由 `Navigation` 在栈空时自己渲染。
*
* ★ 必须是**全局** `@Builder`:`splitPlaceholder` 要的是
* `wrapBuilder<[]>(fn)`,而它只接受全局 @Builder 函数
* (成员方法会报 "The wrapBuilder's parameter should be '@Builder' function")。
* 内容本身是静态的,不需要 `this`,所以全局正好。
*
* 文案**逐字对齐 WebUI**(同一句话,不自己改写)—— 本仓纪律:
* 两端对同一件事说同一句话(见 `harmony-logic` 里「空态主句逐字一致」那条)。
*/
@Builder
function DetailPlaceholder() {
Column() {
AmIcon({ iconName: 'mail', iconSize: 40, iconColor: Theme.textSubtleFor() })
Text('选择一封邮件查看,或点击左侧「新建」写邮件')
.fontSize(14).fontColor(Theme.textMuted)
.margin({ top: 12 })
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
/*
* ★★ 2026-09-21 补:**宽屏右栏默认页也要透明**(用户:「邮件看着没有玻璃效果」)。
*
* `splitPlaceholder` 那块内容由 `Navigation` 直接渲染
* (布局树里的 `SplitPlaceholderContentNode`,实测 x=1152..3154)。
* 不设背景时它用**系统默认底**(近白不透明),于是整片右栏把壁纸挡死 ——
* 实测宽屏 3184px 下:右栏中心 `(242,244,244)`,而屏幕边缘的壁纸是
* `(192,194,192)`。差这么远,说明中间隔着的不透明层就是它。
*
* 这与 2026-09-19 修的 `CommPage` 那个 `Navigation` 是**同一个形状**
* (见那段注释里"那条缝是决定性的证据")—— 系统容器不设背景就不会透。
*
* ★ 全局 `@Builder` 里**拿不到 `this`**(`splitPlaceholder` 只接受全局 Builder),
* 所以不能用 `bgActive ? Transparent : surface`。
* 这里改成**恒透明**:右栏默认页本来就没有"实体面"的语义
* (它是一块空态提示),壁纸关着时底下是 `WallpaperLayer` 之外的
* `pageBg`,观感仍然是浅色底 —— 不会因为透明变成异色。
*/
.backgroundColor(Color.Transparent)
}
@Component
struct MailDetailDestination {
@State mailId: string = '';
@State accountId: string = '';
/** 底部悬浮条高度(窄屏非 0)——透传给详情页,让它的回复球抬过条 */
@Prop navReserve: number = 0;
/** 壁纸是否开启。宽屏下本栏要**自己**有圆角(WebUI `app-shell > *` 的对应物) */
@Prop bgActive: boolean = false;
private pathStack: NavPathStack = new NavPathStack();
handleReady(ctx: NavDestinationContext): void {
const params: MailDetailParams = ctx.pathInfo.param as MailDetailParams;
this.mailId = params?.mail_id ?? '';
this.accountId = params?.account_id ?? '';
this.pathStack = ctx.pathStack;
}
build() {
NavDestination() {
if (this.mailId.length > 0) {
MailDetailView({
initialMailId: this.mailId,
initialAccountId: this.accountId,
embedded: true,
navReserve: this.navReserve,
onBack: (): void => { this.pathStack.pop(); }
})
} else {
Column() {
LoadingProgress().width(32).height(32)
}
.width('100%').height('100%').justifyContent(FlexAlign.Center)
}
}
.hideTitleBar(true)
/*
* ★★ 2026-09-20 修(用户:「邮件展示左侧没有圆角」)。
*
* WebUI 宽屏的真结构是**三个独立圆角面板并排**(`App.tsx:262`):
*
* <div className="app-shell"> ← padding/gap = 10px
* <Sidebar/> {list} {main} ← 每个子元素各自 border-radius:14px
* </div>
*
* 所以列表栏和详情栏**各有自己的四个圆角**,中间的缝里透出壁纸。
*
* 我们这边是一个 `Navigation`,由系统 Split 成 navBar + content 两半,
* 圆角只加在**外壳**(那个 `Row` 里的内容列)上 ⇒ 内部这两半变成直角,
* 实测详情栏白区左缘在 y=145 与 y=2195 都是 `x=1152`(**完全垂直、无圆角**)。
*
* 修法:给**右栏**(`NavDestination`)自己补上圆角 + 裁切,
* 与 WebUI 的 `app-shell > *` 逐条对应;左栏(navBar 内容)在它的根容器上补。
*/
.borderRadius(this.bgActive ? Theme.glassRadius : 0)
/*
* ★ 这里同样**不写** `.clip()` —— 理由见左栏那处(WebUI `index.css:968` 的
* 「不能写 overflow: hidden」那条,我照搬圆角时把裁切也一起搬了,导致列表滚不动)。
*/
/*
* ★★ 2026-09-21 补:**同时去掉 NavDestination 的系统白底**。
*
* 上面那个 `.borderRadius` 一直"看着生效了",其实只裁了内容 ——
* ArkUI 的 `borderRadius` **不裁 `backgroundColor`**。而 `NavDestination`
* 自带一层不透明的 system background,它比外壳**四周各小 2px**
* (实测 `[30,142]` vs 外壳 `[28,140]`)⇒ 圆角内侧露出 2px 直角白边。
*
* 就是用户报的「圆角下方还是有白框(直角框)」。`Color.Transparent`
* 让外壳那层玻璃显出来 —— 与 `ComposeDestination` 同一处修法。
*/
.backgroundColor(Color.Transparent)
.onReady((ctx: NavDestinationContext) => { this.handleReady(ctx); })
}
}
/**
* 写信窗格的内嵌壳(宽屏右栏)。
*
* ★★ 2026-09-20 加(用户:「写邮件 webui 的宽屏样式不是在右侧打开吗」)。
*
* 与 `MailDetailDestination` **完全同构**(那是既有先例):
* 同一个 `NavDestination` 机制,宽屏并排在右栏、窄屏 push 覆盖全屏 ——
* 由 `Navigation.mode(Auto)` 按宽度自动决定,这里不自己判断宽窄。
*
* ★ 为什么是路由而不是一个 `@State showCompose`:
* 路由让"返回"这件事自动正确(系统返回键、手势、头部返回都弹同一个栈),
* 而一个布尔状态要自己接三条返回路径 —— 那正是 2026-09-17 那批
* 「返回直接回到登录页」bug 的来源。
*/
@Component
struct ComposeDestination {
@State to: string = '';
@State replyTo: string = '';
@State sessionAlias: string = '';
@State accountId: string = '';
@Prop navReserve: number = 0;
@Prop bgActive: boolean = false;
private pathStack: NavPathStack = new NavPathStack();
handleReady(ctx: NavDestinationContext): void {
const params: ComposeParams = ctx.pathInfo.param as ComposeParams;
this.to = params?.to ?? '';
this.replyTo = params?.reply_to ?? '';
this.sessionAlias = params?.session_alias ?? '';
this.accountId = params?.account_id ?? '';
this.pathStack = ctx.pathStack;
}
build() {
NavDestination() {
ComposeView({
embedded: true,
initialTo: this.to,
initialReplyTo: this.replyTo,
initialSessionAlias: this.sessionAlias,
initialAccountId: this.accountId,
onBack: (): void => { this.pathStack.pop(); }
})
}
/*
* ★★ 共享元素转场的 **in 端**(与通信页右下那个加号同一个 id)。
*
* 系统按两端各自的 frame 与圆角插值 ⇒ "球长成整页"这件事不需要我算。
* 起点圆角 28(球的半径)→ 终点 0(整幅面板)由两端各自声明。
*/
.geometryTransition('compose-morph')
.hideTitleBar(true)
/*
* ★★ 2026-09-21 修(用户:「写邮件页面和其他多个页面圆角下方还是有白框(直角框)」)。
*
* `MailDetailDestination` 早就补了圆角,这里**漏了** —— 而它的注释还写着
* 「与 `MailDetailDestination` 同一处圆角修法」,说的是"打算照做",
* 实际只搬了圆角、漏了下面那件更要紧的事。
*
* ── 实测(`uitest dumpLayout`,窄屏 1008px,写信页)──
*
* Column(外壳,有圆角) [28,140][980,1957] bg=#C7FFFFFF
* NavDestination [30,142][978,1955] bg=#FFFFFFFF ← 直角、全白
*
* 那个 `#FFFFFFFF` 是 **NavDestination 自己的系统底色**,而它比外壳
* **四周各小 2px**(30 vs 28、142 vs 140 …)。于是外壳那圈 14vp 的圆角
* 内侧露出 2px 的**直角白边** —— 像素实测:y=1946 时圆角已收窄到 x=45,
* 而 x=30..39 仍是纯白;y=1952 时 x=30..48 仍是纯白。
*
* 肉眼就是用户说的「圆角下方一个白框(直角框)」,宽屏在右栏更明显。
*
* ── 修法 ──
*
* 两件事都要做,只做一件都盖不住:
* ① `borderRadius` —— 让**自己**是圆的(原来这里就有);
* ② `backgroundColor(Color.Transparent)` —— 去掉那层系统白底。
* 只给圆角不改底色没用:圆角只裁自己的**内容**,
* 而那块白是**底色**,圆角外照样画得出来。
*
* ★ 为什么 ① 原来没生效:`.borderRadius()` 在 ArkUI 里**不裁背景色**,
* 它裁的是内容与子节点。白底是 `backgroundColor`,不受圆角约束 ⇒
* 必须把底色去掉,让外壳那层(`#C7FFFFFF` 玻璃)显出来。
*/
.borderRadius(this.bgActive ? Theme.glassRadius : 0)
.backgroundColor(Color.Transparent)
.onReady((ctx: NavDestinationContext) => { this.handleReady(ctx); })
}
}
@Component
struct InboxTab {
/** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */
@Prop bgActive: boolean = false;
/**
* 底部悬浮条要占的高度(vp,窄屏才非 0)。
*
* 加在**列表内容的末尾段落**(`contentEndOffset`),让最后一行滚得出来;
* **不是**缩短本窗格 —— 窗格要满高,内容才滑得到条底下,
* 系统材质才有东西可糊(否则玻璃看着像普通的浅色面板)。
*/
@Prop navReserve: number = 0;
/** 选中邮件回调:宽屏内联到 Navigation 右栏,窄屏 push 到目标页 */
onOpenMail: (mailId: string, accountId: string) => void = (): void => {};
/** 打开写信(见 `openCompose()` 的注释:宽屏要在右栏开,不能自己 pushUrl) */
onOpenCompose: (accountId: string) => void = (): void => {};
/*
* 当前是否深色(`MainPage` 发布)—— 用来选品牌浅底的深浅变体。
*
* ★ 2026-09-19:深色主题下「未读」行/选中账号的**浅蓝底**在深色页面上刺眼,
* 根因是 `Theme.accentSoft` 是写死的浅色(WebUI 靠 `.dark` 段反转发解决,
* ArkTS 的静态常量没有那层机制)。窗格自己算不出深浅色 ⇒ 走 AppStorage 读。
*/
@StorageProp('agentmail.appearance.isDark') isDarkNow: boolean = false;
@State mails: MailSummary[] = [];
/** 按会话折叠后的列表(单封的组平铺渲染) */
@State groups: SessionGroup[] = [];
/** 已展开的组(键 = SessionGroup.key) */
@State expandedKeys: string[] = [];
@State loading: boolean = false;
/** 这一页**取回来**的封数 */
@State loaded: number = 0;
/** 未读总数:服务端 total(CountUnread),权威 */
@State unread: number = 0;
/** 本页取满时的"可能还有更多"提示,空串表示不用提示 */
@State notice: string = '';
@State error: string = '';
@State accountName: string = '';
@State accountFilter: string = 'all'; // 'all' 或 accountId
@State showAccountPicker: boolean = false;
@State accountList: AccountInfo[] = [];
/**
* **当前正在看的邮件**(点开的那一封)—— 用来给列表里对应的那张卡加高亮。
*
* ★★ 2026-09-20 补(用户:「邮件展示左侧没有圆角」→ 顺着逐项比对时发现)。
*
* 之前鸿蒙**完全没有"当前选中邮件"这个概念**:点开一封邮件后,左侧列表
* 那一行和旁边几行长得一模一样 —— 你不知道自己正在读哪一封、读完了该往哪回。
* 而 WebUI 一直有(`MailList.tsx:104/116/229` 把 `currentMail.mail_id` 传下去,
* 卡片据此上 `bg-blue-50 border-blue-200`)。
*
* 存 `mail_id`(不是索引/组键):SSE 推送会让列表重排,索引会错位;
* `mail_id` 才是这一封的稳定身份。
*
* ★ 由 `CommPage` 拥有、以 `@Prop` 下发 —— 因为"点开了哪一封"是
* `CommPage.openMail()` 那个动作产生的(收件箱/发件箱都能触发),
* 两个 tab 各存一份必然会分叉。单点写、多点读。
*/
@Prop currentMailId: string = '';
private sseService: SseService | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.sseService = SseService.getInstance();
// 加载账号列表
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
acctMgr.load().then(() => {
const active = acctMgr.getActiveAccount();
if (active !== null) {
this.accountName = active.displayName;
}
this.accountList = acctMgr.getAccounts();
if (this.accountList.length <= 1) {
this.accountFilter = 'all';
}
});
this.loadData();
// 全局 SSE 监听(所有账号的事件都会收到);保存同一函数引用以便页面退出时移除。
this.sseService.addListener(this.onSseEvent);
}
}
aboutToDisappear(): void {
if (this.sseService !== null) {
this.sseService.removeListener(this.onSseEvent);
}
}
private onSseEvent = (event: SseEvent): void => {
if (event.type === 'new_mail') {
let sourceName: string = '';
for (let i = 0; i < this.accountList.length; i++) {
if (this.accountList[i].id === event.accountId) {
sourceName = this.accountList[i].displayName;
break;
}
}
this.getUIContext().getPromptAction().showToast({ message: sourceName.length > 0 ? '新邮件:' + sourceName : '新邮件到达' });
this.loadData();
}
};
/**
* 拉收件箱。
*
* ★★ 2026-09-20:**实现搬进了 `common/MailStore.ets`**(用户裁定做 A:补 store 层)。
*
* 这里原来有 **107 行**:遍历账号 → 逐账号拉 → 合并 → 派生 `cc_count` →
* 按权限分流 → 分组 → 算未读 → 出提示。而 `SentTab.load()`(40 行)与
* `ContactsTab.load()` 又把同一套"多账号遍历"各写了一遍 ——
* 这正是用户点名的「代码难维护」与「行为对不齐」两个痛点的**同一处根因**。
*
* 现在页面只负责"让 store 去拉,然后把结果接进 `@State`":
* 取数/聚合/派生的规则只有一处,两个页面不可能再分叉。
*
* ★ 为什么还要 `@State` 镜像而不是直接读 store 字段:
* ArkUI 的 `@State` 只观察**它持有的那个值**;store 是普通类实例,
* 改内部字段不会触发刷新(本仓 `appearance-defaults` 判据钉过同类问题)。
* 所以照 `MailStore.revision` 的约定:拉完把快照接进 `@State`。
*/
async loadData(): Promise<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 });
}
build() {
Column() {
/*
* WebUI 的列表头只有一张紧凑卡:标题 +「会话组 · 邮件数」+ 账号切换。
* 不再重复堆「通信 / 收件箱 / 全部邮箱 / 已加载 / 未读」五层说明文字。
*/
/*
* ★ 卡片要真缩到 10vp,不能靠 `width('100%') + margin`:
* ArkUI 的 margin 加在宽度**外面**,100% 宽度再配左右 margin 会顶出父容器
* (实测卡片左缘只留 ~1px,列表卡却有 10vp 缩进)。改成外层带 padding 的容器。
*/
Column() {
Row() {
Text('收件箱').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Text(this.groups.length + ' 组 · ' + this.loaded + ' 封')
.fontSize(12).fontColor(Theme.textMuted).margin({ left: 10 })
Blank()
if (this.accountList.length > 1) {
Row() {
Text(this.accountFilter === 'all' ? '全部邮箱' : this.accountName)
.fontSize(11).fontColor(Theme.textMuted)
AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.textSubtleFor() })
.rotate({ angle: 90 })
.animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft })
.margin({ left: 3 })
}
.height(32)
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
}
if (this.unread > 0) {
Text(this.unread > 99 ? '99+' : this.unread.toString())
.fontSize(10).fontWeight(FontWeight.Bold).fontColor(Theme.accentFg)
.backgroundColor(Theme.danger).borderRadius(9)
.constraintSize({ minWidth: 18 }).height(18)
.textAlign(TextAlign.Center).margin({ left: 8 })
}
}
.width('100%').height(46)
.padding({ left: 14, right: 12 })
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
}
.width('100%')
.padding({ left: 10, right: 10 })
.margin({ top: 10 })
/*
* 聚合失败横幅(对齐 WebUI `MailList.tsx:85-96` 的 `account-errors`)。
*
* 文案/形状照 WebUI:
* 「有 {n} 个账号没取到:{账号}({原因});…」
* 琥珀底 + 琥珀边 + 11 号字(`bg-amber-50 border-amber-200 text-amber-800`)。
*
* ★★ 为什么值得单独占一块地方(WebUI 原注释):
* 「聚合时**某个账号取不到**必须说出来:静默丢掉它,列表会少一整份邮件,
* 而界面看起来完全正常 —— 这正是"聚合"最容易骗人的失败方式」
*
* 本仓的情况更糟一点:`MailStore` **一直在收集**这些失败
* (`snap.accountErrors`,两处 load 都写),但**界面从来没有读过它** ⇒
* 这个安全网当前是断的。是逐页对齐时比对出来的,不是猜的。
*/
if (this.accountErrors.length > 0) {
Text('有 ' + this.accountErrors.length + ' 个账号没取到:' + this.accountErrorText())
.fontSize(11).fontColor(Theme.warnFgDark)
.width('100%')
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.backgroundColor(Theme.warnBg)
.borderRadius(6)
.margin({ top: 6 })
}
// 账号选择器下拉
if (this.showAccountPicker && this.accountList.length > 1) {
Column() {
Text('全部邮箱')
.fontSize(14).fontColor(this.accountFilter === 'all' ? Theme.accentFor() : Theme.textPrimary)
.fontWeight(this.accountFilter === 'all' ? FontWeight.Bold : FontWeight.Normal)
.width('100%').height(40).padding({ left: 16 })
.backgroundColor(this.accountFilter === 'all' ? Theme.accentSoftFor(this.isDarkNow) : Theme.surface)
.onClick(() => {
this.accountFilter = 'all';
this.accountName = '全部邮箱';
this.showAccountPicker = false;
this.loadData();
})
ForEach(this.accountList, (acct: AccountInfo) => {
Row() {
Column() {
Text(acct.displayName)
.fontSize(14)
.fontColor(this.accountFilter === acct.id ? Theme.accentFor() : Theme.textPrimary)
.fontWeight(this.accountFilter === acct.id ? FontWeight.Bold : FontWeight.Normal)
Text(acct.username)
.fontSize(11).fontColor(Theme.textSubtleFor())
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%').height(48).padding({ left: 16 })
.backgroundColor(this.accountFilter === acct.id ? Theme.accentSoftFor(this.isDarkNow) : Theme.surface)
.attributeModifier(PressEffectModifier.of())
.onClick(() => {
this.accountFilter = acct.id;
this.accountName = acct.displayName;
this.showAccountPicker = false;
this.loadData();
})
}, (acct: AccountInfo) => acct.id)
}
.width('100%')
.attributeModifier(GlassCardModifier.of(this.bgActive))
.border({ width: { bottom: 1 }, color: Theme.border })
/*
* ★ 2026-09-19 补(用户:「元素的出现消失动画呢?」)。
*
* 这个下拉原来是**硬弹**出来的:它挂在 `if (this.showAccountPicker)` 上,
* 条件一变就整块出现/消失,中间一帧过渡都没有 —— 而它正是
* "出现/消失"最典型的一处(用户点一下按钮,东西凭空冒出来)。
*
* 用 `menuIn()`:下移 4vp + 缩到 0.985 + 淡入,与 WebUI
* `@keyframes menu-in` 逐值对齐(`Theme.menuIn` 的注释里有出处)。
* 方向是**从上往下**(`translateY(-4px)` → 0)—— 下拉从触发点下方展开,
* 从上方"落"下来;与 `paneRiseIn` 的**上浮**方向相反,别混。
*
* `.transition()` 在这里**会播**(与日历那个常驻窗格不同):
* 它是 `if` 包着的,条件为真时才挂载 —— 正是 transition 的触发条件。
*/
.transition(Theme.menuIn())
}
if (this.loading) {
Column() {
LoadingProgress().width(40).height(40)
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.error.length > 0) {
Column() {
Text(this.error).fontSize(14).fontColor(Theme.dangerFor())
Button('重试').margin({ top: 12 }).onClick(() => { this.loadData(); })
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.mails.length === 0) {
Column() {
Text('收件箱为空').fontSize(16).fontColor(Theme.textSubtleFor())
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
List({ space: 6 }) {
ForEach(this.groups, (g: SessionGroup) => {
ListItem() {
if (isFlatGroup(g)) {
// 单封不成组:套一个可折叠的组头只是多一次点击(与 WebUI isFlatGroup 同结论)
this.MailRow(g.mails[0])
} else {
/*
* ── 会话组容器 ──
*
* ★★ 2026-09-21 修「多个邮件为什么没有堆叠效果」(用户)。
*
* 原来组头与子邮件是**平铺的兄弟**、各自一张完整的卡、间距一样 ——
* 于是一组三封看起来就是"三张互不相干的卡",看不出
* "这是同一条会话里的三封"。
*
* WebUI 的形状(`MailList.tsx:173-232`):
* <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.of(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;
}
@Builder
MailRow(mail: MailLike) {
Row() {
this.MailItem(mail)
}
.width('100%').height(64)
/*
* 底 + 边**成对**决定这张卡的身份,口径逐项对齐 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)
.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') {
Circle({ width: 8, height: 8 }).fill(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 = '';
/** 深浅色(`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;
aboutToAppear(): 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 })
}
@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)
.backgroundColor(Theme.surfaceMuted)
.borderRadius(Theme.radiusCard)
.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() {
this.SentGroupHeader(g)
if (isFlatGroup(g)) {
this.SentRow(g.mails[0])
}
}
.width('100%')
}
.width('100%')
if (!isFlatGroup(g) && this.isExpanded(g.key)) {
ForEach(g.mails, (m: MailLike) => {
ListItem() {
Column() {
this.SentRow(m)
}
.width('100%')
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
/* 与 `MailRow` 同一口径:选中(正在读的那一封)上品牌边。
WebUI 组内条目也是这么标的(`MailList.tsx:229`
把 `currentMailID` 传进 `MailItem` 的 `active`)。 */
.border({
width: 1,
color: this.currentMailId === m.mail_id ? Theme.accentEdgeFor(this.isDarkNow) : Theme.border
})
.margin({ bottom: 6 })
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.openMail(m); })
}
.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.of(this.bgActive))
}
}
/*
* ─────────────────────── 授权(通信页第三栏) ───────────────────────
*
* 只放**待人点头**的事:`GET /permission/pending`(不从收件箱筛,理由见 `MailApi`)。
* 决策走 `POST /permission/decide`,`note` 会随决策送达模型 ——
* 所以拒绝时**能填备注**,而且填了必须真的发出去。
*
* 两件必须如实说的事(WebUI 侧踩过坑,见 `decidePermission` 的返回类型):
* ① 请求**已过期**时服务端会带 `expired`:这时决策落到了一个没人在等的请求上,
* 得当场告诉人(否则会以为"批了,Agent 继续干活了");
* ② `warning` 同理,服务端让显示什么就显示什么。
*/
@Component
struct PermissionTab {
/** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */
@Prop bgActive: boolean = false;
/** 底部悬浮条高度(窄屏非 0)——理由见 `InboxTab` 的 `navReserve` */
@Prop navReserve: number = 0;
@State requests: PermissionRequest[] = [];
/**
* 已决策的**历史**,按会话分组(对齐 WebUI `PermissionList.tsx:182` 的「历史 {n}」)。
*
* ★ 来源是 **inbox**(不是 `/permission/pending`)—— 后者 SQL 带
* `WHERE pr.result IS NULL`,永远拿不到已决策的。
* 详见 `load()` 里那段取舍说明。
*/
@State permGroups: PermissionGroup[] = [];
@State loading: boolean = false;
@State error: string = '';
/** 正在填备注的那条(空串 = 没有) */
@State noteFor: string = '';
@State noteText: string = '';
@State busyId: string = '';
aboutToAppear(): void {
this.load();
}
async load(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.loading = true;
this.error = '';
try {
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
await acctMgr.load();
const accounts: AccountInfo[] = acctMgr.getAccounts();
const all: PermissionRequest[] = [];
/* 已决策的历史(从 inbox 取,见下面 `settled` 的注释) */
const settled: MailLike[] = [];
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 resp: PendingResponse = await new MailApi(c).pendingPermissions();
for (let j = 0; j < resp.requests.length; j++) {
all.push(resp.requests[j]);
}
/*
* ★★ 2026-09-21 补**已决策的历史**。
*
* 用户可见差异(登记在 `docs/DEBTS.json` 的 `harmony-permission-history`,
* 其到期条件正是「做『授权栏与 WebUI 对齐』时」):
* `GET /permission/pending` 的 SQL 带 `WHERE pr.result IS NULL`
* ⇒ **只拿得到待决的**,于是"这条会话批过哪些事"在鸿蒙上完全看不到,
* 而 WebUI 能看到(`PermissionList.tsx:182` 的「历史 {n}」)。
*
* ── 为什么不改走 WebUI 的 inbox 分组 ──
*
* 核实过:WebUI 从 inbox 分组(`groupPermissions`),但它的
* `PermissionRow` **只渲染** `subject` / `created_at` / `permission_result`
* / `permission_expires_at`(逐字段 grep 过)。
* 而**待决**那一段我们要显示 `question`/`options`/`context`/`kind` ——
* 那四个字段在 `permission_requests` **表**里,
* inbox 回包(`models.Mail`)**没有它们**(模型里逐条核过)。
*
* ⇒ 待决继续走专用端点(信息更全、能直接决策),
* 历史走 inbox 补上。两条来源合起来,与 WebUI 的可见信息量一致。
* 代价:多一次请求/账号。这是**有意的取舍**,不是漏了优化。
*/
const inbox: InboxResponse = await new MailApi(c).inbox('all', INBOX_PAGE_SIZE);
for (let j = 0; j < inbox.mails.length; j++) {
const m: MailSummary = inbox.mails[j];
/* 只要**已决策**的权限请求(待决的已由上面那个端点给了) */
if (m.mail_type === 'permission_request' && m.permission_result.length > 0) {
m.source_account_id = acct.id;
settled.push(m);
}
}
} catch (e) {
// 单账号失败不空整栏
}
}
this.requests = all;
this.permGroups = groupPermissions(settled);
} catch (e) {
const ae = e as ApiError;
this.error = ae.message.length > 0 ? ae.message : '加载失败';
} finally {
this.loading = false;
}
}
async decide(req: PermissionRequest, decision: string, note: string): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.busyId = req.mail_id;
try {
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
await acctMgr.load();
const accounts: AccountInfo[] = acctMgr.getAccounts();
let done: boolean = false;
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 resp: DecideResponse = await new MailApi(c).decidePermission(req.mail_id, decision, note);
done = true;
/*
* 过期/警告要当场说清 —— 不能只说"已同意"。
* 过期意味着**审批不会让那次调用继续**(请求方已经不等了),
* 人必须知道这一点,否则会以为 Agent 会接着跑。
*/
if (resp.expired) {
this.getUIContext().getPromptAction().showToast({
message: '这条请求已经过期 —— 审批不会让那次调用继续,Agent 需要重新请求',
duration: 6000
});
} else if (resp.warning.length > 0) {
this.getUIContext().getPromptAction().showToast({ message: resp.warning, duration: 6000 });
} else {
this.getUIContext().getPromptAction().showToast({
message: decision === 'deny' ? '已拒绝' + (note.length > 0 ? '(备注已随决策送出)' : '') : '已同意'
});
}
break;
} catch (e) {
// 换下一个账号试:决策只在一个账号的网关上有效
}
}
if (!done) {
this.getUIContext().getPromptAction().showToast({ message: '决策失败:没找到这条请求所在的账号' });
}
this.noteFor = '';
this.noteText = '';
await this.load();
} finally {
this.busyId = '';
}
}
/** 顶栏右侧:待决策徽标(橙=有人被卡住,不是"有东西要读") */
@Builder
PendingTrailing() {
if (this.requests.length > 0) {
Text(this.requests.length + ' 待决策')
.fontSize(12).fontColor(Theme.accentFg)
.backgroundColor(Theme.warnFg)
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.margin({ right: 8 })
}
}
@Builder
RequestCard(req: PermissionRequest) {
Column() {
Row() {
Text(req.agent_name.length > 0 ? req.agent_name : '(未知 Agent)')
.fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
/*
* ★★ 2026-09-20 修:**这里原来读的是一个不存在的字段**。
*
* `req` 是 `PermissionRequest`,而服务端那个 struct
* (`models.go:239-255`)**没有 `session_alias`** ——
* `repo.ListPendingPermissionsFor` 的 SELECT 也没查它。
* WebUI 的类型里同样没有(`types/index.ts:233` 只有 `session_id`/`agent_name`)。
*
* ⇒ `req.session_alias` 恒为 `undefined`,
* 一旦有待办,`.length` 当场抛 TypeError(整页白屏)。
* 这个 bug **一直没暴露只是因为当前待办数一直是 0** ——
* 实测 `/permission/pending` 返回 `{"requests":[]}`。
*
* 改成服务端**确实有**的 `agent_name`(授权请求一定由 Agent 发出,
* 这是卡片上最有辨识度的一格)。
* 与 WebUI 同口径:那边授权卡标题也只显示 Agent 与问题,不显示会话别名。
*/
Text(req.agent_name.length > 0 ? req.agent_name : '(未知 Agent)')
.fontSize(10).fontColor(Theme.accentFor())
}
.width('100%')
// 问题是这张卡的主角:人要照着它决定点头还是摇头
Text(req.question.length > 0 ? req.question : '(无问题描述)')
.fontSize(13).fontColor(Theme.textPrimary)
.margin({ top: 6 })
if (req.context.length > 0) {
Text(req.context)
.fontSize(11).fontColor(Theme.textSubtleFor())
.maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
}
/* 同 ①:WebUI `PermissionList.tsx:251` 也格式化,不印 ISO */
Text(compactMailTime(req.created_at)).fontSize(10).fontColor(Theme.textSubtleFor()).margin({ top: 6 })
if (this.noteFor === req.mail_id) {
TextInput({ placeholder: '备注(会随决策一起送给 Agent)', text: this.noteText })
.fontSize(12).height(40).margin({ top: 8 })
.onChange((v: string) => { this.noteText = v; })
}
Row() {
Button('同意')
.fontSize(13).height(36).layoutWeight(1)
.backgroundColor(Theme.approve).fontColor(Theme.accentFg)
.onClick(() => { this.decide(req, 'allow', ''); })
Text('拒绝')
.fontSize(13).height(36).layoutWeight(1)
.textAlign(TextAlign.Center)
.backgroundColor(Theme.dangerBg).fontColor(Theme.dangerFor())
.borderRadius(Theme.radiusControl)
// 拒绝先展开备注框,而不是直接拒:拒绝往往要说明理由,而理由是给模型看的
.onClick(() => {
if (this.noteFor === req.mail_id) {
this.decide(req, 'deny', this.noteText);
} else {
this.noteFor = req.mail_id;
this.noteText = '';
}
})
.margin({ left: 8 })
}
.width('100%').margin({ top: 10 })
if (this.noteFor === req.mail_id) {
Text(this.noteText.length > 0 ? '再点一次「拒绝」即送出(带备注)' : '可填备注,再点一次「拒绝」送出')
.fontSize(10).fontColor(Theme.textSubtleFor()).margin({ top: 4 })
}
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(12)
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.margin({ bottom: 8 })
}
build() {
Column() {
/* 同 SentTab:统一走 AppHeader(圆框 + 与底栏同族的几何与材质) */
AppHeader({
title: '授权',
showBack: false,
active: this.bgActive,
topInsetPx: 0
}) {
this.PendingTrailing()
}
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.requests.length === 0 && this.permGroups.length === 0) {
Column() {
AmIcon({ iconName: 'shield', iconSize: 36, iconColor: Theme.textSubtleFor() }).margin({ bottom: 8 })
Text(emptyTitle('permissions')).fontSize(15).fontColor(Theme.textMuted)
Text(emptyHint('permissions')).fontSize(12).fontColor(Theme.textSubtleFor()).margin({ top: 6 })
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
List({ space: 8 }) {
/* 待决(可决策) */
ForEach(this.requests, (req: PermissionRequest) => {
ListItem() {
this.RequestCard(req)
}
.width('100%')
}, (req: PermissionRequest) => req.request_id)
/*
* ── 已决策的历史(对齐 WebUI `PermissionList.tsx:182` 的「历史 {n}」)──
*
* ★★ 2026-09-21 新增。这一段在鸿蒙侧**此前完全看不到**
* (`harmony-permission-history`,其到期条件就是"做授权栏对齐")。
*
* 只读、不可操作(决策已经发生,重放没有意义)—— 与 WebUI 一致:
* 它把 `settled` 归到折叠的分组里,默认收起。
* 这里是**平铺一行一行**的历史摘要(会话别名 + 决策 + 时间),
* 不逐条列邮件正文:那是详情页的事。
*/
ForEach(this.permGroups, (g: PermissionGroup) => {
ListItem() {
Row() {
Column() {
Text(g.alias.length > 0 ? g.alias : '(未命名会话)')
.fontSize(13)
.fontFamily('monospace')
.fontColor(Theme.textMuted)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text('历史 ' + g.settled.length.toString() + ' 条')
.fontSize(11).fontColor(Theme.textSubtleFor())
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.attributeModifier(GlassCardModifier.of(this.bgActive, false))
.borderRadius(Theme.radiusCard)
}
.width('100%')
}, (g: PermissionGroup) => 'hist-' + 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.of(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;
/*
* 徽标数的 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(); });
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();
// 必须摘掉:留着会叫醒一个已销毁的页面(跳转落空,还容易误判成"推送坏了")
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;
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;
}
}
const pend: PendingResponse = await api.pendingPermissions();
pending += pend.requests.length;
} catch (e) {
// 单个账号失败不影响其他账号的徽标
}
}
this.unreadCount = unread;
this.pendingCount = pending;
/*
* ★ 同时**发布到 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` 动画」)。
*
* 时长取 `Theme.durMorph`(220) —— WebUI FLIP 的原值。
*/
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 });
}
@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.refreshCounts();
})
}, (key: string) => key)
}
/*
* ★★ 2026-09-20(用户:「顶部的收件箱发件箱和授权为什么不做成悬浮玻璃?」)
*
* 这一行原来是 `backgroundColor(Theme.surface)` —— 系统卡片色,**实体**。
* 于是它是整页唯一一块实心白:上一轮把列表容器、卡片、底部导航条
* 都玻璃化了,唯独漏了这条页签条 ⇒ 壁纸从四周透出来,只有它在顶上白着一横条。
*
* 现在与**底部浮动条同一族**:圆角 + 左右留白 + 系统材质。
* 三种做法各自的取舍见 `model/NavItems.ts` 的 `TAB_BAR_*` 常量注释
* (为什么选悬浮而不是 WebUI 的"通栏无底色")。
*/
/*
* 宽度:**与窗格齐平**(`100%`)。
*
* 原来这里是 `calc(100% - 2×TAB_BAR_SIDE)`(左右各内缩 16vp)。
* 实测 WebUI:`.comm-pane` 与 `tabstrip` 的 `x`/`w` **完全相同**(都是 80/320)
* —— 它靠**窗格的 `overflow:hidden`** 把顶角裁圆,所以页签条自己直角、通栏。
* 我们内缩 + 自己带角 ⇒ 右端出现两道弧(用户:「你又在内部套了一个胶囊」)。
* ⇒ 改成与窗格齐平,右端只剩窗格那一道边。
*/
.width('100%')
.backgroundColor(Color.Transparent)
.backgroundBlurStyle(this.bgActive ? Theme.navMaterial : BlurStyle.NONE)
/*
* ★ 只给**左上角**圆角,右上角不要(用户:「鸿蒙布局还略有不同的,
* 你直接改成右边没圆角就行了」)。
*
* 右上角的弧与窗格右上角的弧**重叠成两道**(页签条一道、窗格一道),
* 中间那弯月牙形的空玻璃就是"奇怪"的来源。
* 去掉右边那一角,右端就只剩窗格自己那一道边。
*/
.borderRadius({ topLeft: Theme.glassRadius, topRight: 0 })
}
openMail(mailId: string, accountId: string): void {
/* 记下"正在看哪一封" ⇒ 列表里那一行高亮(对齐 WebUI 的 currentMail)。
放在 push 之前:即使 push 失败,用户也确实点了这一封。 */
this.currentMailId = mailId;
const params: MailDetailParams = {
mail_id: mailId,
account_id: accountId
};
this.navPathStack.pushPath({ name: MAIL_DETAIL_ROUTE, param: params });
}
/**
* 右栏占位(`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 })
} 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.of(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.of(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`,
* 而真正挡住的是外面这层壳。
*/
.attributeModifier(PaneModifier.of(this.bgActive))
}
}
@Component
struct ContactsTab {
@Prop bgActive: boolean = false;
/** 底部悬浮条高度(窄屏非 0)——理由见 `InboxTab` 的 `navReserve` */
@Prop navReserve: number = 0;
@State contacts: Contact[] = [];
@State loading: boolean = false;
@State error: string = '';
/*
* 打开的会话(空串 = 没开)—— 与 WebUI `ContactPanel` 的 `selectSession` 同一行为:
* 点一条“跟谁在聊”就是打开**这条会话的邮件列表**。
*
* ★ 2026-09-17 用户:「联系人页面连点都点不开」。
* 根因:`ContactItem` / `WorkCard` 两个 builder **根本没有 `onClick`** ——
* 卡片画出来了、但没有任何点击路径(判据当时只钉了“字段与 WebUI 一致”,
* 没钉“点了会发生什么”)。现在补上:点卡片 → 在**本 pane 内**打开会话,
* 底部导航保留(与 WebUI 的 pane 模型一致,不跳 @Entry 页)。
*/
@State openSessionId: string = '';
@State openSessionTitle: string = '';
@State sessionMails: MailSummary[] = [];
@State sessionLoading: boolean = false;
/**
* 联系人自己的导航栈 —— 与 `CommPage` 同一模式(每个有「列表→详情」的窗格自带一个
* `Navigation`)。这样点联系人卡片打开邮件详情时,**底部导航一直可见**
* (详情挂在窗格内部),而不是推一个盖住导航的 @Entry 页。
* 这也正是用户说的「我的页面完全没有遵守 nav 的导航规则」那条的同源问题。
*/
private navPathStack: NavPathStack = new NavPathStack();
/**
* 视图:'list'(跟谁在聊)/ 'card'(在聊什么、进展如何)。
*
* 这不是装饰:WebUI 里「会话」从来不是一个入口,它是**两处已有视图** ——
* 列表视图是 `ContactRow`,卡片视图是 `WorkCard`(标题「工作列表」)。
* 鸿蒙侧原来把会话单列成一个 tab,等于把"卡片视图"放错了位置。
* 顺序:先在这里补上卡片视图,再把平级「会话」tab 撤掉(撤早了会丢信息:
* 轮次预算 / status / from_agent 就没地方看了)。
*/
@State contactView: string = 'list';
/**
* 待归档确认的会话 id(空 = 没有确认框)。
*
* ★ 归档是**破坏性**操作(Agent 侧会话归档 + 邮箱界面同时移除),
* 所以先确认再发请求 —— 与 WebUI `contactStore.pendingArchive` 同构。
* 用 `session_id` 当这个"待确认"的键,而不是 `address`:
* address 会随别名变化,拿它当身份迟早对不上(见 `MailApi.archiveContact`)。
*
* ★ 两种视图**共用同一个确认框**(WebUI 的原话:换个视图就换套确认 UI
* 只会让人对「自己点了什么」更没底)。所以这块 UI 只写一次。
*/
@State pendingArchiveId: string = '';
/** 归档请求在飞:防连点(一次点击就可能删掉一条会话,重复提交没有意义) */
@State archiving: boolean = false;
private mailApi: MailApi | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.mailApi = new MailApi(ApiClient.getInstance(ctx));
this.loadData();
}
}
async loadData(): Promise<void> {
const m: MailApi | null = this.mailApi;
if (m === null) {
return;
}
this.loading = true;
try {
const resp = await m.contacts();
this.contacts = resp.contacts;
/* 联系人数也发布给导航栏徽标(口径见 model/NavItems.ts 的 navBadgeCount) */
/* 键名与 MainPage 的 KEY_NAV_BADGE_CONTACTS 同值('agentmail.nav.contacts')——
* 两个 struct 不能共享私有常量,所以这里写字面量并在两处注释里互指。 */
AppStorage.setOrCreate('agentmail.nav.contacts', resp.contacts.length);
} catch (e) {
const ae = e as ApiError;
this.error = ae.message.length > 0 ? ae.message : '加载失败';
} finally {
this.loading = false;
}
}
/** 切换视图:切换规则本身在 MailGrouping.nextContactView(判据直接执行那一层) */
switchView(): void {
this.contactView = nextContactView(this.contactView);
}
/**
* 写信给**指定地址**(联系人卡片上的「写信」)。
*
* `openCompose()` 是"写一封全新的",`to` 为空;这个版本把该联系人的三维地址
* 预填进去 —— 与 WebUI `startCompose({ to: c.address })` 同构。
* 复用同一条 `ComposePage` 路由与同一套 `ComposeParams`,不另开页面。
*/
composeTo(c: Contact): void {
const ctx = this.getUIContext().getHostContext();
let accountId: string = '';
if (ctx !== undefined) {
accountId = AccountManager.getInstance(ctx).getActiveId();
}
const params: ComposeParams = {
to: c.address,
reply_to: '',
session_alias: '',
account_id: accountId
};
this.getUIContext().getRouter().pushUrl({ url: 'pages/ComposePage', params: params });
}
/** 请求归档:只打开确认框,不发请求(破坏性操作先确认) */
requestArchive(c: Contact): void {
this.pendingArchiveId = c.session_id;
}
cancelArchive(): void {
this.pendingArchiveId = '';
}
/**
* 确认归档:发 `POST /contacts/archive`,成功后**本地即时移除**不等 SSE
* (与 WebUI 同做法:等 SSE 会让按钮看起来没反应)。
*
* 失败时把服务端的话原样显示 —— 归档半途失败最需要的是"到底成了没有",
* 而不是一句笼统的"操作失败"。
*/
async confirmArchive(c: Contact): Promise<void> {
const m: MailApi | null = this.mailApi;
if (m === null || this.archiving) {
return;
}
this.archiving = true;
try {
await m.archiveContact(c.session_id);
this.contacts = this.contacts.filter((x: Contact) => x.session_id !== c.session_id);
if (this.openSessionId === c.session_id) {
this.openSessionId = '';
}
this.pendingArchiveId = '';
} catch (e) {
const ae = e as ApiError;
this.error = ae.message.length > 0 ? ae.message : '归档失败';
this.pendingArchiveId = '';
} finally {
this.archiving = false;
}
}
/**
* 打开一条会话的邮件列表(点联系人卡片就走这里)。
*
* 与 WebUI `ContactPanel.open()` 同构:`selectSession(c.session_id)` 后
* 内容栏切到会话。这里把“会话的邮件”拉下来就地展示在**本 pane 内** ——
* 不推 @Entry 页,因为 WebUI 的会话是**同一个 pane 的另一种内容**,
* 底部导航一直可见(推页会把导航盖掉,那是另一种信息架构)。
*/
async openSession(c: Contact): Promise<void> {
const m: MailApi | null = this.mailApi;
if (m === null) {
return;
}
this.openSessionId = c.session_id;
this.openSessionTitle = c.subject.length > 0
? c.subject
: (c.session_alias.length > 0 ? c.session_alias : c.agent_name);
this.sessionMails = [];
this.sessionLoading = true;
try {
this.sessionMails = await m.sessionMails(c.session_id);
} catch (e) {
const ae = e as ApiError;
this.getUIContext().getPromptAction().showToast({ message: ae.message.length > 0 ? ae.message : '打开会话失败' });
} finally {
this.sessionLoading = false;
}
}
/** 回到联系人列表 */
closeSession(): void {
this.openSessionId = '';
this.openSessionTitle = '';
this.sessionMails = [];
}
/** 点一封邮件 → 推进本窗格的导航栈(与收件箱同一条详情路径) */
openMail(mailId: string, accountId: string): void {
const params: MailDetailParams = { mail_id: mailId, account_id: accountId };
this.navPathStack.pushPath({ name: MAIL_DETAIL_ROUTE, param: params });
}
@Builder
DestinationBuilder(name: string, param: Object) {
if (name === MAIL_DETAIL_ROUTE) {
MailDetailDestination({ navReserve: this.navReserve, bgActive: this.bgActive })
}
}
build() {
/* 本窗格自带 Navigation(与 CommPage 同一模式):列表 → 邮件详情,底部导航始终可见 */
Navigation(this.navPathStack) {
Column() {
Row() {
Text(contactViewTitle(this.contactView)).fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
/*
* ★★ 2026-09-21 补(逐页对齐 WebUI 时发现的漏项)。
*
* WebUI `ContactPanel.tsx:68` 在标题右边有一个**计数**:
* <span className="ml-2 text-xs text-gray-400">{contacts.length}</span>
* 我们这里没有 —— 于是"有几个人/几条工作"只能靠往下数。
*
* 取值用 `contacts.length`(与 WebUI 同一个来源),**不是**卡片视图的
* 别的计数:两个视图共用同一份 `contacts`,所以计数不随视图变。
*
* 字号/颜色照 WebUI:`text-xs`(12) + `gray-400` → `fontSmall` + `textSubtleFor`。
* ★ 用 `textSubtleFor()` 而不是写死灰:深色下三级文字要提亮
* (本仓 2026-09-18 实测过「三级文字浅色也一样不够」)。
*/
Text(this.contacts.length.toString())
.fontSize(Theme.fontSmall).fontColor(Theme.textSubtleFor())
.margin({ left: 8 })
Blank()
/*
* 40×40 命中区 + 图标居中:尺寸加在**外层 Stack** 上,不加在 `AmIcon` 上。
* 内层容器固定 `iconSize`(18) 且靠左上 —— 直接链 `.width(40)` 会让
* 18vp 的图标贴在 40×40 命中区左上角(同 `LoginPage` 品牌卡、
* `AdminUsersPage` 刷新键,2026-09-18 一起修)。
*/
Stack({ alignContent: Alignment.Center }) {
AmIcon({
iconName: this.contactView === 'list' ? 'cardView' : 'listView',
iconSize: 18,
iconColor: Theme.textMuted
})
}
.width(40).height(40)
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.switchView(); })
}
.width('100%').height(56).padding({ left: 16, right: 8 })
/*
* 顶栏底:**不自己铺一层实心白** —— 与 WebUI 一致。
*
* ★★ 2026-09-19 修(用户:「一个横着过去的白条,我真的服了」+
* 「期望:融进背景」)。
*
* WebUI 的顶栏**自身没有底色** —— 它只有 `border-b border-gray-200`
* (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
* 底色由它所在的**面板**提供。壁纸开启时那层面板是玻璃色
* (`index.css:876` `html[data-bg='on'] .bg-white`),顶栏就跟着变玻璃。
*
* 鸿蒙这边顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
* 顶上都是**一条不通明的白带**,横贯屏幕、与下面的玻璃内容脱开 ——
* 就是用户说的那条白条。
*
* 改成与**页面底**同一套口径(`bgActive ? Transparent : surface`):
* · 壁纸关:仍是不透明白(与下面内容同色,看不出这条带的边界);
* · 壁纸开:透明,壁纸从顶栏透上来 —— 这才是"融进背景"。
*/
.attributeModifier(GlassCardModifier.of(this.bgActive))
/* 下边框对齐 WebUI:顶栏与内容的分界靠这条线,不靠底色差 */
.border({ width: { bottom: 1 }, color: Theme.border })
/*
* ★ 会话视图:点联系人卡片后**在本 pane 内**展示那条会话的邮件。
* 不另开 @Entry 页 —— 与 WebUI 的 pane 模型一致(底部导航一直可见)。
*/
if (this.openSessionId.length > 0) {
this.SessionMailsView()
} else 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.contacts.length === 0) {
Column() {
Text('暂无联系人').fontSize(16).fontColor(Theme.textSubtleFor())
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.contactView === 'card') {
// 卡片视图:竖向堆叠(320~400vp 放不下多列),每项一张卡
List({ space: 8 }) {
ForEach(this.contacts, (c: Contact, idx: number) => {
ListItem() {
/*
* 待归档的那一条**整块换成确认框**(与 WebUI 同做法):
* 归档是破坏性操作,换个视图就换套确认 UI 只会让人对
* 「自己点了什么」更没底 —— 所以卡片视图与列表视图共用这一个分支。
*/
if (this.pendingArchiveId === c.session_id) {
this.ArchiveConfirmCard(c)
} else {
this.WorkCard(c)
}
}
.width('100%')
/* 点卡片 = 打开这条会话(WebUI `ContactPanel` 的 onOpen) */
.attributeModifier(PressEffectModifier.of())
.onClick(() => {
// 确认态下卡片的点击不打开会话:那块区域已经是"确认框"了
if (this.pendingArchiveId !== c.session_id) {
this.openSession(c);
}
})
}, (_c: Contact, idx: number) => idx.toString())
}
.width('100%').layoutWeight(1)
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
.contentEndOffset(this.navReserve)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
} else {
// 列表视图:同样**每项一张卡**(不再用贯通分隔线)—— 与卡片视图同一语义
List({ space: 6 }) {
ForEach(this.contacts, (c: Contact, idx: number) => {
ListItem() {
/* 与卡片视图共用同一个确认框分支(见上) */
if (this.pendingArchiveId === c.session_id) {
this.ArchiveConfirmCard(c)
} else {
this.ContactItem(c, idx)
}
}
/*
* ★★ 2026-09-19 修(用户报「联系人界面存在严重问题」):
*
* 这里原来写 `.height(85)`,而**内容需要 95vp**:
*
* agent 名 14 + 3 + 主题 12 + 3 + 档位行 11 + 6
* + 动作行 26 + padding(10+10) = **95vp**
*
* 加上 `.clip(true)` ⇒ 多出来的 **10vp(该平板 ≈ 21px)**
* 被硬切掉 —— 切掉的正好是**「写信 / 归档」那一行的下半截**。
*
* 真机现场(HUAWEI MatePad Pro,密度 2.125):
* 卡片实测高 181px = 85vp(吻合),
* 截图里两个按钮只剩上半截,看起来像"渲染坏了"。
*
* ★ 讽刺的是:**旁边那段注释早就写明了这个道理**,
* 只是它只管了「确认态」那一支 ——
* 「确认框比 85 高,会被裁掉而看不见按钮,高度交给内容自己定」。
* 而普通态同样装不下,只是差得少(10vp)、不容易一眼看出来。
*
* ⇒ 两个分支的不变式其实是同一条:**高度必须由内容决定**。
* 不要把一个"按当时字号的估算值"钉成常量 ——
* 字号、行高、动作行的存在与否都会变,而常量不会跟着变。
*/
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.clip(true)
/* 点卡片 = 打开这条会话(WebUI `ContactPanel` 的 onOpen) */
.attributeModifier(PressEffectModifier.of())
.onClick(() => {
// 确认态下卡片的点击不打开会话:那块区域已经是"确认框"了
if (this.pendingArchiveId !== c.session_id) {
this.openSession(c);
}
})
}, (_c: Contact, idx: number) => idx.toString())
}
.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.of(this.bgActive))
}
.navDestination(this.DestinationBuilder)
.mode(NavigationMode.Auto)
/*
* ★★ 2026-09-20 修:联系人栏宽度**随视图模式变**(用户:「联系人页宽度」)。
*
* WebUI `ContactPanel.tsx:59-62` 原文:
* // 卡片要放两行摘要 + 预算条,320px 会挤;列表视图保持紧凑
* view === 'card' ? 'lg:w-[400px]' : 'lg:w-[320px]'
*
* 而鸿蒙原来**写死 320** —— 卡片视图与列表视图同宽。
* 卡片视图比列表视图多一行(`mail_count 封 · 时间`)**再加一条预算胶囊**
* (见 `CardItem` 里 `budgetLabel(...)` 那一块),320 装不下,
* WebUI 早就为此单独放宽到 400,我们没跟上。
*
* 范围上限也一起抬到 400(否则 `navBarWidth(400)` 会被 range 夹回 360 ——
* 那正是"改了宽度但没变"的典型症状,值被另一处静默覆盖)。
*/
.navBarWidth(this.contactView === 'card' ? 400 : 320)
.navBarWidthRange([280, 400])
.minContentWidth(360)
.hideTitleBar(true)
.width('100%').height('100%')
/* 右栏占位:栈空时显示引导(否则宽屏两栏并排时右栏是一大片空白)。
用系统入口 `splitPlaceholder`,**不是** NavDestination 的 else —— 后者栈空时不挂载。 */
.splitPlaceholder(new ComponentContent(
this.getUIContext(), wrapBuilder<[]>(DetailPlaceholder)))
/* 同 CommPage 的 Navigation:不设背景时系统默认不透明白,会把里面
已经透明的窗格盖住。联系人页是同一形状,同一修法。 */
.attributeModifier(PaneModifier.of(this.bgActive))
}
/**
* 会话视图:一条会话下的全部邮件(点联系人卡片后显示)。
*
* 与 WebUI 的会话内容区同构:顶部一行返回 + 会话标题,下面是那几封邮件。
* 每封点开推进本窗格的 `Navigation`(与收件箱同一条详情路径)。
*/
@Builder
SessionMailsView() {
Column() {
/* 返回行:与 WebUI 的 `BackButton label="会话"` 同一语义 */
Row() {
/* 返回键用图标,不用 `‹`(本仓禁 Unicode 符号当图标 —— 见 Icons.ets) */
Stack({ alignContent: Alignment.Center }) {
AmIcon({ iconName: 'chevronLeft', iconSize: 20, iconColor: Theme.accentFor() })
}
.width(HEADER_BACK_HIT).height(HEADER_BACK_HIT)
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.closeSession(); })
Text(this.openSessionTitle)
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text(this.sessionMails.length + ' 封')
.fontSize(11).fontColor(Theme.textMuted)
}
.width('100%').height(44).padding({ left: 4, right: 12 })
.backgroundColor(Theme.surface)
if (this.sessionLoading) {
Column() { LoadingProgress().width(32).height(32) }
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.sessionMails.length === 0) {
Column() {
Text('这条会话没有邮件').fontSize(14).fontColor(Theme.textSubtleFor())
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
List({ space: 6 }) {
ForEach(this.sessionMails, (m: MailSummary) => {
ListItem() {
Column() {
Row() {
Text(m.from_name.length > 0 ? m.from_name : '—')
.fontSize(12).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Blank()
Text(compactMailTime(m.created_at))
.fontSize(10).fontColor(Theme.textSubtleFor())
}
.width('100%')
Text(m.subject)
.fontSize(14)
.fontWeight(m.status === 'unread' ? FontWeight.Bold : FontWeight.Normal)
.fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%').margin({ top: 4 })
Text(m.body_preview)
.fontSize(11).fontColor(Theme.textSubtleFor())
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%').margin({ top: 2 })
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
}
.width('100%')
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.openMail(m.mail_id, m.source_account_id); })
}, (m: MailSummary) => m.mail_id)
}
.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%').layoutWeight(1)
}
/**
* 工作卡片 —— 对应 WebUI 的 `WorkCard`(卡片视图)。
*
* 列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:主题是主角,
* 最新一封说了什么、谁说的,以及**往返预算还剩多少**(预算是任务的属性,
* 快跑满的任务需要人介入 —— 这就是为什么卡片视图要先于删 tab 落地)。
*/
@Builder
WorkCard(c: Contact) {
Column() {
Row() {
Text(c.agent_name)
.fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(c.path)
.fontSize(11).fontColor(Theme.textSubtleFor())
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ left: 6 }).layoutWeight(1)
if (c.unread_count > 0) {
Text(c.unread_count + '')
.fontSize(10).fontColor(Theme.accentFg)
.backgroundColor(Theme.accent)
.borderRadius(9).width(18).height(18)
.textAlign(TextAlign.Center)
}
}
.width('100%')
Row() {
/* WebUI `ContactPanel.tsx:247` 这里是 `<ChevronRightIcon className="w-3 h-3">`
—— 一个 SVG 图标,不是 `›` 这个字符。逐项对齐。 */
AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.accentFor() })
Text(c.session_alias.length > 0 ? c.session_alias : '(未命名会话)')
.fontSize(11).fontColor(Theme.accentFor())
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ left: 4 }).layoutWeight(1)
}
.width('100%').margin({ top: 2 })
// 主题是这张卡片的主角:它回答「这条线索在干什么」
Text(c.subject.length > 0 ? c.subject : '(无主题)')
.fontSize(13).fontColor(Theme.textPrimary)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 6 })
if (c.last_preview.length > 0) {
Row() {
AmIcon({
iconName: lastFromIsHuman(c.agent_name, c.last_from) ? 'person' : 'bot',
iconSize: 12,
iconColor: Theme.textMuted
})
.margin({ right: 4 })
Text(c.last_preview)
.fontSize(11).fontColor(Theme.textMuted)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%').margin({ top: 6 }).alignItems(VerticalAlign.Top)
}
Row() {
Text(c.mail_count + ' 封 · ' + shortTimeOf(c.last_activity))
.fontSize(11).fontColor(Theme.textSubtleFor())
Blank()
/*
* 权限档位徽标:**档位 + 强制力标记**,点它弹出"平台实际做到了什么"。
*
* 只显示档位会让人以为 plan 档真的管住了对方;WebUI 把这句话放在悬停提示里,
* 而手指没有悬停 —— 所以鸿蒙这边拆成两步:标记形状当场可辨(● 平台强制 /
* ◉ 覆盖不完整 / ○ 仅提示),点一下用 toast 说完整那句话(文案两边逐字一致)。
*/
if (permissionChipText(c.permission_mode, c.permission_enforcement).length > 0) {
Text(permissionChipText(c.permission_mode, c.permission_enforcement))
.fontSize(10).fontColor(Theme.permFg(c.permission_mode))
.backgroundColor(Theme.permBg(c.permission_mode)).borderRadius(4)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.margin({ right: 6 })
.onClick(() => {
this.getUIContext().getPromptAction().showToast({
message: permissionHint(c.permission_mode, c.permission_enforcement)
+ '(强制力:' + enforcementLabel(c.permission_enforcement) + ')',
duration: 6000
});
})
}
// 往返预算:上限为 0 = 不限,不显示(与 WebUI BudgetChip 同判据)
if (budgetLabel(c.max_rounds, c.used_rounds).length > 0) {
Text(budgetLabel(c.max_rounds, c.used_rounds))
.fontSize(10)
.fontColor(Theme.budgetFg(budgetState(c.max_rounds, c.used_rounds)))
.backgroundColor(Theme.budgetBg(budgetState(c.max_rounds, c.used_rounds)))
.borderRadius(9)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
}
}
.width('100%').margin({ top: 8 })
/* 次要动作:写信 / 归档(常显,理由见 CardAction 的注释) */
Row({ space: 8 }) {
this.CardAction('写信', 'compose', 'normal', () => { this.composeTo(c); })
this.CardAction('归档', 'archive', 'danger', () => { this.requestArchive(c); })
}
.width('100%').margin({ top: 8 })
}
.width('100%')
.padding(12)
.borderRadius(Theme.radiusControl)
.backgroundColor(Theme.surface)
.border({ width: 1, color: Theme.border })
}
/**
* 卡片上的次要动作按钮(写信 / 归档)—— 两视图共用。
*
* ★ WebUI 用 `.reveal`(默认隐藏、悬停显形)承载这两个动作。**鸿蒙不能照抄**:
* `client/electron/src/index.css` 那段的注释里已经踩过这个坑 ——
* 「触摸设备没有 hover,于是这些按钮永远是透明的,却仍然接收点击……一个看不见
* 却按得动的破坏性按钮比没有按钮更糟」。WebUI 的修法是**只在真的支持悬停的设备上
* 才隐藏**(`@media (hover: hover) and (pointer: fine)`)。
* 鸿蒙的输入就是手指 ⇒ 这两个按钮**必须常显**,没有"显形"这一步。
*
* `tone='danger'` 只用于归档:它改的是服务端状态,红色让人在按之前先看一眼。
*/
@Builder
CardAction(label: string, iconName: string, tone: string, onTap: () => void) {
Row() {
AmIcon({
iconName: iconName,
iconSize: 12,
iconColor: tone === 'danger' ? Theme.dangerFor() : Theme.textMuted
})
Text(label)
.fontSize(11)
.fontColor(tone === 'danger' ? Theme.dangerFor() : Theme.textMuted)
.margin({ left: 4 })
}
.height(26)
.padding({ left: 10, right: 10 })
.borderRadius(Theme.radiusControl)
.border({ width: 1, color: Theme.border })
.backgroundColor(Theme.surface)
.attributeModifier(PressEffectModifier.of())
.onClick(onTap)
}
/**
* 归档确认框 —— 列表视图与卡片视图**共用这一个**。
*
* WebUI 的原话:归档是破坏性操作,换个视图就换套确认 UI 只会让人对
* 「自己点了什么」更没底。文案两边逐字一致(`ArchiveConfirm`):
* 「归档 <address>?」/「对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除」
*/
@Builder
ArchiveConfirmCard(c: Contact) {
Column() {
Text('归档 ' + c.address + '?')
.fontSize(13).fontColor(Theme.textPrimary)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text('对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除')
.fontSize(11).fontColor(Theme.textSubtleFor())
.margin({ top: 3 })
.width('100%')
Row() {
Row() {
AmIcon({ iconName: 'check', iconSize: 12, iconColor: Theme.accentFg })
Text('确认归档').fontSize(12).fontColor(Theme.accentFg).margin({ left: 4 })
}
.height(30).padding({ left: 12, right: 12 })
.borderRadius(Theme.radiusControl)
.backgroundColor(Theme.danger)
.opacity(this.archiving ? 0.5 : 1)
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.confirmArchive(c); })
Row() {
AmIcon({ iconName: 'close', iconSize: 12, iconColor: Theme.textMuted })
Text('取消').fontSize(12).fontColor(Theme.textMuted).margin({ left: 4 })
}
.height(30).padding({ left: 12, right: 12 })
.borderRadius(Theme.radiusControl)
.border({ width: 1, color: Theme.border })
.backgroundColor(Theme.surface)
.margin({ left: 8 })
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.cancelArchive(); })
}
.width('100%').margin({ top: 10 })
}
.width('100%')
.padding(12)
.borderRadius(Theme.radiusControl)
.backgroundColor(Theme.dangerBg)
.border({ width: 1, color: Theme.danger })
}
@Builder
ContactItem(c: Contact, idx: number) {
Row() {
Column() {
Row() {
Text(c.agent_name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Blank()
if (c.unread_count > 0) {
Text(c.unread_count + '')
.fontSize(11).fontColor(Theme.accentFg)
.backgroundColor(Theme.danger)
.borderRadius(10).width(20).height(20)
.textAlign(TextAlign.Center)
}
}
.width('100%')
Text(c.subject.length > 0 ? c.subject : c.session_alias)
.fontSize(12).fontColor(Theme.textMuted)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 3 })
Row() {
// 列表视图(跟谁在聊):只要档位,不塞强制力标记 —— 详细说明在卡片视图那颗可点的徽标上
Text(permissionLabel(c.permission_mode).length > 0 ? permissionLabel(c.permission_mode) : '—')
.fontSize(10).fontColor(Theme.permFg(c.permission_mode))
.backgroundColor(Theme.permBg(c.permission_mode)).borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
Blank()
/*
* 列表视图也要"写了多少 / 什么时候"——WebUI `ContactRow` 的
* `{mail_count} 封 · {time}` 与卡片视图同源。原来这里放的是 last_preview,
* 于是同一个联系人在两种视图里给出的关键信息不一致。
*/
Text(c.mail_count + ' 封 · ' + shortTimeOf(c.last_activity))
.fontSize(11).fontColor(Theme.textSubtleFor())
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%').margin({ top: 3 })
/*
* 动作行与卡片视图**同语义**(写信 / 归档),
* 只是列表视图行更矮,所以动作也画得紧凑些。
*/
Row({ space: 8 }) {
this.CardAction('写信', 'compose', 'normal', () => { this.composeTo(c); })
this.CardAction('归档', 'archive', 'danger', () => { this.requestArchive(c); })
}
.width('100%').margin({ top: 6 })
}
/*
* ★★ 2026-09-19:把 `height('100%')` 拿掉。
*
* 它与外层 ListItem 的硬高度是**一对**:
* ListItem `.height(85)` + 这里 `height('100%')` ⇒ 刚好 85。
* 我只把 ListItem 那半去掉之后,这里就变成"填满整个列表"——
* 实测 ListItem 高 **1563px**(一整屏就一张卡)。
*
* ⇒ 两半必须一起改:**让内容决定高度**。
* `layoutWeight(1)` 保留(横向撑满),纵向不再写 height。
*/
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.SpaceBetween)
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.alignItems(VerticalAlign.Center)
}
}
@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 = '';
/** 当前是否深色(侧栏主题按钮的图标);由 `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);
aboutToAppear(): void {
// 主界面也要应用外观:只在设置页生效的话"一进主界面就变回去"(WebUI 侧踩过)
this.applyAppearance();
this.watchEnvironment();
}
aboutToDisappear(): void {
this.unwatchEnvironment();
}
/**
* 算一遍「内容末尾要让多少位」——**唯一**的算法,两个触发点共用。
*
* 宽屏: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)。
*/
top: this.windowInsets.statusBar + 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)
}
}
.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();
})
}
}