跨端: 顶栏不再自己铺白条 + 日历改左右两栏 + 常驻窗格动画真的会播(用户三处实测指出)

用户三条反馈,逐条对应:

① 「底栏数字为什么显示在图标下面?」
   WebUI 的徽标是 `absolute top-1 right-[22%]`(脱离文档流、浮在图标右上角),
   我写成了 `Column` 的第三个子节点 ⇒ 参与竖向布局、掉到文字下面。
   改用 `Stack({ alignContent: Alignment.TopEnd })` 锚在**图标**上。
   (顺带撞了 skill 里明写的坑:Stack 没有 `.justifyContent()`。)

② 「一个横着过去的白条,我真的服了」/「期望:融进背景」
   WebUI 的顶栏**自身没有底色** —— 只有 `border-b border-gray-200`
   (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
   底色由所在面板给;壁纸开启时那层面板是玻璃色(`index.css:876`)。
   鸿蒙三个窗格顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
   顶上都是一条不通明白带。改成与**页面底**同一口径
   (`bgActive ? Transparent : surface`)+ 补下边框。

③ 「日历页面和webui布局完全不同」
   WebUI 是**左右两栏**(`CalendarView.tsx:452-457`):左 `flex-1` 网格、
   右 `400px` 常驻面板(日程 / 编辑器 / 小时网格三态互斥)。
   鸿蒙原来是**单栏竖堆**。重搭为两栏,`paneWide` 由 `.onAreaChange`
   量本页**自己的**宽度(不是屏幕宽度 —— 宽屏下这一页已被侧栏占掉一截);
   编辑器改占右栏位置(不再整页盖掉正在看的那个月)。

④ 「最严重的动画问题你一点也不该改」
   日历是**常驻挂载**(`visibility` 控制,因为它里面 today 要随时间重算、
   也要保住"正在看哪个月"),而 `.transition()` 只在**挂载/卸载**时触发
   (SDK 原话 \"when it **appears and disappears**\")⇒ 挂在它上面的
   `.transition(paneRiseIn())` **一帧也不会播**,切过去是硬弹。
   WebUI 踩过同一个坑并把错法写进了 `index.css:1305-1320`
   (「只挂了类,却没让触发窗口出现 ⇒ 类挂着、动画永远不播」),
   它的解法是 `html.view-switch` 重放窗口。ArkUI 对应物是
   `animateTo` + 显式 `calPaneIn` 属性(`opacity` + `translate`)。
   ★ reset 必须在 `animateTo` **外**:写进回调里会与同帧的 1 相抵,
     渲染层只看得见最终值 ⇒ 动画退化成一个瞬移。

顺带修:
· 宽屏侧栏 = 3 项(通信/日历/**联系**)+ 底部一簇(头像/主题/退出),
  与底栏的四项(含「我的」)**不是同一份清单** —— WebUI 的 Sidebar 与
  NarrowNav 本就不同(`Sidebar.tsx:26-44` vs `NarrowNav.tsx:37-40`+143)。
  新增 `NAV_SIDEBAR_ITEMS` / `ME_PANE_INDEX` / `SIDEBAR_ITEM_*`。
· 退出登录抽成 `api/Logout.ets` 的 `performLogout()`:侧栏底簇与「我的」页
  两个入口必须做同一件事(尤其"先注销推送 token"那一步),复制一份就会不一致。
· 徽标 `'plain'` 档底色:WebUI 是石板灰 `--c-chrome-600`(#475569),
  我写成与未读共用红色 ⇒ 「联系」的徽标看起来像"有未读"。
· 主题快捷开关(侧栏底簇):对齐 `ThemeToggleButton` —— 从 `system` 翻转时
  落到**当前生效值的反面**(不是回 system;那可能毫无变化、让按钮看起来坏了)。
· 「浓度」→「压暗」+ 数值带单位(原先屏上印 `56.000000`;WebUI 是 `suffix="%"`)。

判据(8 个套件全绿:logic 28 / nav 18 / widescreen 7 / window 9 /
arkts 5 / contacts 5 / calendar 30 / system-api 5):
· `harmony-widescreen` ② 重写:**回读 `Sidebar.tsx` 数 `short:` 的个数**
  要求鸿蒙同数,并断言底部一簇三键真的被调用(`this.onToggleTheme()` ——
  第一版写成 `/onToggleTheme/`,变异测试当场证明它不咬:属性**声明**还在,
  正则照样匹上)。新增 ⑧:三档色调各自底色,期望值从 `--c-chrome-600` 读出。
· `harmony-nav` 动画条重写:改判**机制真的存在且被驱动**
  (旧断言 `.visibility(...).transition(...)` 锁的正是那个 bug ——
  判据引自己写的注释当依据,就会把错误锁死)。新增 reset-在-animateTo-外
  这条断言(否则动画退化成瞬移)。
· `harmony-nav` 设备条:`navItemsOf` 的宽屏过滤从"左边缘靠左 1/6"
  改成"**整个盒子在侧栏轨道内**" —— 旧条件把日历网格的格子
  (实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。
  新增 `navRailItemsOf`:导航轨贴顶、底部簇在屏底,按位置切一刀。
· `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS`
  **各自的数组**里取 label —— 原先把全文件 `label:` 一网打尽,
  得到 7 个(4+3 混在一起),任何一边改对了它都会红。

设备实测(HATriple 三折叠,3184×2232):侧栏 3 项 + 底簇 / 底栏徽标回到
图标右上角 / 日历左右两栏与 WebUI 并排同构。

server/go.mod:补 2da38bb 漏提交的 lunar-go 依赖。
This commit is contained in:
2026-09-19 12:30:05 +08:00
parent 652316674a
commit 36ef15a8f2
15 changed files with 1216 additions and 331 deletions

View File

@ -15,6 +15,8 @@ import { MailApi, InboxResponse } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SseService, SseEvent } from '../api/SseService';
import { AppearanceStore } from '../common/AppearanceStore';
import { AppearanceApi } from '../api/AppearanceApi';
import { performLogout } from '../api/Logout';
import { Configuration, ConfigurationConstant, EnvironmentCallback } from '@kit.AbilityKit';
import { image } from '@kit.ImageKit';
import { AppearanceSnapshot, isDarkMode, scrimOpacity } from '../model/Appearance';
@ -828,7 +830,28 @@ struct SentTab {
}
}
.width('100%').height(56).padding({ left: 16, right: 16 })
.backgroundColor(Theme.surface)
/*
* 顶栏底:**不自己铺一层实心白** —— 与 WebUI 一致。
*
* ★★ 2026-09-19 修(用户:「一个横着过去的白条,我真的服了」+
* 「期望:融进背景」)。
*
* WebUI 的顶栏**自身没有底色** —— 它只有 `border-b border-gray-200`
* (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
* 底色由它所在的**面板**提供。壁纸开启时那层面板是玻璃色
* (`index.css:876` `html[data-bg='on'] .bg-white`),顶栏就跟着变玻璃。
*
* 鸿蒙这边顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
* 顶上都是**一条不通明的白带**,横贯屏幕、与下面的玻璃内容脱开 ——
* 就是用户说的那条白条。
*
* 改成与**页面底**同一套口径(`bgActive ? Transparent : surface`):
* · 壁纸关:仍是不透明白(与下面内容同色,看不出这条带的边界);
* · 壁纸开:透明,壁纸从顶栏透上来 —— 这才是"融进背景"。
*/
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
/* 下边框对齐 WebUI:顶栏与内容的分界靠这条线,不靠底色差 */
.border({ width: { bottom: 1 }, color: Theme.border })
if (this.loading) {
Column() { LoadingProgress().width(32).height(32) }
@ -1090,7 +1113,28 @@ struct PermissionTab {
}
}
.width('100%').height(56).padding({ left: 16, right: 16 })
.backgroundColor(Theme.surface)
/*
* 顶栏底:**不自己铺一层实心白** —— 与 WebUI 一致。
*
* ★★ 2026-09-19 修(用户:「一个横着过去的白条,我真的服了」+
* 「期望:融进背景」)。
*
* WebUI 的顶栏**自身没有底色** —— 它只有 `border-b border-gray-200`
* (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
* 底色由它所在的**面板**提供。壁纸开启时那层面板是玻璃色
* (`index.css:876` `html[data-bg='on'] .bg-white`),顶栏就跟着变玻璃。
*
* 鸿蒙这边顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
* 顶上都是**一条不通明的白带**,横贯屏幕、与下面的玻璃内容脱开 ——
* 就是用户说的那条白条。
*
* 改成与**页面底**同一套口径(`bgActive ? Transparent : surface`):
* · 壁纸关:仍是不透明白(与下面内容同色,看不出这条带的边界);
* · 壁纸开:透明,壁纸从顶栏透上来 —— 这才是"融进背景"。
*/
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
/* 下边框对齐 WebUI:顶栏与内容的分界靠这条线,不靠底色差 */
.border({ width: { bottom: 1 }, color: Theme.border })
if (this.loading) {
Column() { LoadingProgress().width(32).height(32) }
@ -1634,7 +1678,28 @@ struct ContactsTab {
.onClick(() => { this.switchView(); })
}
.width('100%').height(56).padding({ left: 16, right: 8 })
.backgroundColor(Theme.surface)
/*
* 顶栏底:**不自己铺一层实心白** —— 与 WebUI 一致。
*
* ★★ 2026-09-19 修(用户:「一个横着过去的白条,我真的服了」+
* 「期望:融进背景」)。
*
* WebUI 的顶栏**自身没有底色** —— 它只有 `border-b border-gray-200`
* (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
* 底色由它所在的**面板**提供。壁纸开启时那层面板是玻璃色
* (`index.css:876` `html[data-bg='on'] .bg-white`),顶栏就跟着变玻璃。
*
* 鸿蒙这边顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
* 顶上都是**一条不通明的白带**,横贯屏幕、与下面的玻璃内容脱开 ——
* 就是用户说的那条白条。
*
* 改成与**页面底**同一套口径(`bgActive ? Transparent : surface`):
* · 壁纸关:仍是不透明白(与下面内容同色,看不出这条带的边界);
* · 壁纸开:透明,壁纸从顶栏透上来 —— 这才是"融进背景"。
*/
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
/* 下边框对齐 WebUI:顶栏与内容的分界靠这条线,不靠底色差 */
.border({ width: { bottom: 1 }, color: Theme.border })
/*
* ★ 会话视图:点联系人卡片后**在本 pane 内**展示那条会话的邮件。
@ -2103,6 +2168,18 @@ struct MainPage {
@StorageProp('agentmail.nav.pending') navPending: number = 0;
@StorageProp('agentmail.nav.contacts') navContacts: number = 0;
@State bgPlan: BackgroundPlan = new BackgroundPlan();
/**
* 侧栏底部头像要显示的账号标签(取前两字,对齐 WebUI `Sidebar.tsx:190`
* 的 `(user?.display_name || user?.username || '?').slice(0, 2)`)。
*
* 与各 pane 的 `accountName` 分开存:那些是**收件箱筛选器**当前选中的账号
* (可能是"全部邮箱"),而头像要的是**当前活跃账号**——多账号下不同。
* 放在 `MainPage` 而不是侧栏自己查:侧栏是无状态展示组件,
* 而账号列表只有主框架持有(与徽标走同一套"父算子读")。
*/
@State accountLabel: string = '';
/** 当前是否深色(侧栏主题按钮的图标);由 `applyAppearance` 算出来 */
@State isDarkNow: boolean = false;
/**
* 背景是否开着 —— 传给每个页面,让它们把**页面底**让出来(变成透明)。
*
@ -2127,6 +2204,29 @@ struct MainPage {
* 只挂在 ① 上的话,转屏后手势条高度变了而 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;
/**
@ -2168,6 +2268,35 @@ struct MainPage {
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`:直接切系统色彩模式只改本机、换设备不跟着走,
* 而外观是账号级偏好。
*/
private toggleTheme(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const next: string = this.isDarkNow ? 'light' : 'dark';
const store: AppearanceStore = AppearanceStore.getInstance();
const snap: AppearanceSnapshot = store.current();
snap.theme = next;
/* 本机先生效(不等人看着);服务端写失败只影响"换设备带不带着走" */
store.applyTheme(ctx, next);
store.saveLocal(ctx, snap);
this.isDarkNow = next === 'dark';
const client: ApiClient = ApiClient.getInstance(ctx);
new AppearanceApi(client).put(snap, store.wallpaper !== null).catch(() => {});
}
/**
* 订阅系统环境变化(深浅色切换),**重算我们自己算出来的那部分**。
*
@ -2258,6 +2387,18 @@ struct MainPage {
*/
const systemMode: number = ctx.resourceManager.getConfigurationSync().colorMode;
const dark: boolean = isDarkMode(snap.theme, systemMode);
/* 侧栏底部那个主题按钮的图标跟着它走(WebUI `ThemeToggleButton` 同口径) */
this.isDarkNow = dark;
/*
* 侧栏头像的前两字:当前**活跃账号**的展示名(不是 `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), store.wallpaper !== null, dark, snap.bgBlur);
this.bgActive = this.bgPlan.kind !== 'none';
@ -2397,10 +2538,16 @@ struct MainPage {
if (this.navBadgeOf(key).length > 0) {
Text(this.navBadgeOf(key))
.fontSize(9).fontColor(Color.White)
.backgroundColor(navBadgeTone(key, this.navPending) === 'perm' ? Theme.warnFg : Theme.danger)
.backgroundColor(navBadgeTone(key, this.navPending) === 'perm'
? Theme.warnFg
: (navBadgeTone(key, this.navPending) === 'plain' ? Theme.badgePlain : Theme.danger))
.borderRadius(8)
.padding({ left: 4, right: 4 })
.margin({ top: 2 })
/*
* 偏移对齐 WebUI `absolute top-1 right-[22%]`:压在**图标右上角**,
* 而不是排在文字下面(那次正是把绝对定位写成了 Column 的子节点)。
*/
.translate({ x: 4, y: -2 })
}
}
@ -2422,18 +2569,42 @@ struct MainPage {
* 选中态:**图标与文字一起换色**,不引入背景/指示条 ——
* 与 WebUI 同一套表达(用户 2026-09-14:「选中对应的文字和图标变色即可」)。
*/
AmIcon({
iconName: item.iconKey,
iconSize: 24,
iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg
})
/*
* ★★ 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 })
/* 徽标:见 NavBadge(口径与 WebUI Sidebar 的 badge/badgeTone 一致) */
this.NavBadge(item.key)
}
/*
* 命中区:**显式给下限**(44vp),不靠"看起来够大"。
@ -2475,25 +2646,27 @@ struct MainPage {
return;
}
/*
* ★ 换窗格要**真的**有过渡:`animateTo` 只开了一个动画窗口,
* 被换掉的那棵子树自己不声明 `.transition(...)` 的话什么都不会动
* (实测:只包 `animateTo` 和硬切看不出差别)。
* 过场动画挂在**内容容器**上(见 build 里各分支的 `.transition(Theme.paneRiseIn())`),
* 这里只负责把 index 变掉;系统在动画窗口内跑那条 transition。
* ★★ 2026-09-19:日历那一支要先把入场进度**瞬回 0**,再在动画窗口里推到 1。
*
* ★★ 2026-09-18 更正:这里原先用 `durBase(180)` + `easeOutSoft(0.22,1,0.36,1)`,
* 并写着"与 WebUI 的 .pane-rise 同一根尺子" —— **那两个数都不是动画值**。
* 实测 WebUI:`.rise-in`/`.pane-rise` 都是 **150ms + cubic-bezier(0.22, 0.61, 0.36, 1)**;
* 180ms 与 `(0.22,1,0.36,1)` 只用在**壁纸淡入**与**控件变色 transition** 上。
* 所以旧代码的窗格入场比 WebUI 慢 30ms 且曲线偏软。
* 现在两边都用 `durRise` + `easeRise`,与 `paneRiseIn()` 内那份保持一致
* (同一场过场被两个地方描述,数值必须同源 —— 所以都引令牌,不写数)。
* ★ 顺序是这个修法的**全部关键**: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;
});
})
}
@ -2630,12 +2803,26 @@ struct MainPage {
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); }
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()).catch(() => {});
}
})
}
Column() {
@ -2679,7 +2866,22 @@ struct MainPage {
.width('100%')
.height('100%')
.visibility(this.currentIndex === 1 ? Visibility.Visible : Visibility.None)
.transition(Theme.paneRiseIn())
/*
* ★★ 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 })
}
.width('100%')
.height('100%')