用户问「你的玻璃效果呢?」。我上手先取像素,结果是**两个一直存在的真 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
4402 lines
213 KiB
Plaintext
4402 lines
213 KiB
Plaintext
/*
|
||
* 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();
|
||
})
|
||
}
|
||
} |