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