跨端: 鸿蒙端功能同步第一步——日历(只读月视图)上架,入口进底部导航

用户:「要给鸿蒙端做功能同步」。按 API 面盘点(WebUI 62 个 API 函数 vs 鸿蒙 38 个),
最大的用户面缺口是**日历**:纯逻辑(model/Calendar.ts)与判据早就在,一直没页面。

新增:
- api/CalendarApi.ets:GET /calendar/events?from=&to=(与 WebUI 同参;区间按**网格**取,
  不是月首月末 —— 首尾格子会显示邻月,只查当月会让那些格子永远空着)
- pages/CalendarPage.ets:月网格(翻月/回今天)、点某天看当天日程、事件点、今天/选中两态、
  加载失败说出来。**没做**:写侧(增删改)、农历重复、.ics、滑动翻页 —— 逐条写在文件头
- model/Calendar.ts:补 localIsoOf / hhmmAtOffset / deviceOffsetMinutes(偏移是入参 ⇒ 三时区可真跑)
- model/Models.ets:CalendarEvent / CalendarListResponse(字段对齐服务端 JSON)
- NavItems:加「日历」,底部成为 通信/日历/联系人 三项(与 WebUI 同序)
- MainPage:日历是**常驻 pane**(visibility 控制),首次可见才拉数据;today 走
  @Prop @Watch(visible) 在 pane 变可见时重算 ⇒ 结算欠账 calendar-today-recompute
  (DEBTS 15 笔 → 14 笔,余额里不再计这一笔)

判据:harmony-calendar 新增 6 条(网格/表头同源、事件归日走 localIsoOf、三时区钟点、
today 重算路径、翻月走 addMonths、变异自检);harmony-nav ② 分派与 ④ 让位跟着改成结构性判据
(④ 原来那个 400 字符窗口一加 pane 就红 —— 窗口式判据的又一次现身);harmony-logic 两处
「只剩两个平级页签」跟着改成三项。

真机实测(harmony-emu + hvigorw assembleHap + hdc install + uitest click + dumpLayout):
9 月网格星期对齐(周一起始,2026-09-01 落在「二」列)、事件点恰好在有日程的那 6 天
(11/17/18/24/25/30)、点 09-17 列出当天两条日程且钟点是本地时间(DB 里 02:20Z/08:30Z
→ 界面 10:20/16:30)。
This commit is contained in:
pi
2026-09-14 18:38:51 +08:00
parent 79e591aa8b
commit 94ba4b9c58
10 changed files with 677 additions and 37 deletions

View File

@ -0,0 +1,32 @@
/*
* AgentMail 鸿蒙客户端 — 日历 API
* GET /calendar/events?from=&to=
*
* 与 WebUI 同源同参(`client/electron/src/api/client.ts` 的 listCalendarEvents):
* from/to 是 **RFC3339 区间**,服务端按 `event_time` 落在区间内筛。
*
* 区间必须按**网格**算,不是按月首月末算 —— 月视图首尾会显示上/下月的格子,
* 只查当月会让那几个格子永远空着(看着像"那天没事件",其实是没查)。
*/
import { ApiClient } from './ApiClient';
import { CalendarListResponse } from '../model/Models';
export class CalendarApi {
private client: ApiClient;
constructor(client: ApiClient) {
this.client = client;
}
/**
* 列区间内的事件。
*
* `from`/`to` 走 `encodeURIComponent`:RFC3339 里的 `+08:00` 若不编码,
* 查询串里的 `+` 会被服务端解成空格,`time.Parse` 直接失败 ⇒ 静默返回空列表
* (界面表现为"这个月没有日程",而实际是参数被吃掉了)。
*/
async listEvents(from: string, to: string): Promise<CalendarListResponse> {
const query: string = 'from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to);
return this.client.get<CalendarListResponse>('/calendar/events', query);
}
}

View File

@ -144,3 +144,41 @@ export function isoAtOffset(now: Date, offsetMinutes: number): string {
const shifted = new Date(now.getTime() + offsetMinutes * 60000);
return `${shifted.getUTCFullYear()}-${pad2(shifted.getUTCMonth() + 1)}-${pad2(shifted.getUTCDate())}`;
}
/**
* 把服务端的 RFC3339 时间戳归到**本地**那一天 —— 网格 `iso` 键的口径。
*
* 为什么不能 `.slice(0, 10)`:那是**时间戳的前 10 个字符**,也就是 UTC 日期。
* UTC+8 的 09-15 00:30 在 UTC 里是 09-14T16:30Z,切字符串会把事件画到**前一天的格子**里
* —— 而网格、表头、"今天"标记全都正常,是肉眼最难发现的一类错。
*
* 解析失败(服务端不会给,但别让一个坏值把整个月画崩)返回空串,
* 调用侧对空串一律当"没有日期"处理。
*/
export function localIsoOf(timestamp: string): string {
const ms: number = Date.parse(timestamp);
if (Number.isNaN(ms)) {
return '';
}
return isoOfLocal(new Date(ms));
}
/** 设备本地时区相对 UTC 的偏移(分钟,东为正)。页面用它把时间戳显示成本地钟点。 */
export function deviceOffsetMinutes(now: Date): number {
return -now.getTimezoneOffset();
}
/**
* 时间戳在**给定偏移**下的 `HH:MM`。
*
* 偏移做成入参而不是读进程时区:这样"UTC+8 / UTC-8 / UTC 三种下显示都对"这条
* 判据能**真跑**,不必依赖跑判据的机器正好在哪个时区(与 `isoAtOffset` 同一个理由)。
*/
export function hhmmAtOffset(timestamp: string, offsetMinutes: number): string {
const ms: number = Date.parse(timestamp);
if (Number.isNaN(ms)) {
return '';
}
const shifted = new Date(ms + offsetMinutes * 60000);
return `${pad2(shifted.getUTCHours())}:${pad2(shifted.getUTCMinutes())}`;
}

View File

@ -235,3 +235,34 @@ export class DecideResponse {
expires_at: string = '';
warning: string = '';
}
/**
* 日历事件(`GET /calendar/events` 的一项)。
*
* 字段名与类型**严格对齐服务端 JSON**(`server/internal/models/calendar.go` 的
* `CalendarEvent`)—— 鸿蒙侧模型层对齐 docs/API.md 是既定纪律,不是"照着 WebUI 抄"。
* 只声明这一版日历用得上的字段:写全了却没人读,等于给下一个人一份会过期的副本。
*
* `event_time` 是 RFC3339 时间戳(不是日期)—— 归到**本地哪一天**由
* `model/Calendar.ts` 的 `localIsoOf()` 决定,页面不自己切字符串。
*/
export class CalendarEvent {
event_id: string = '';
title: string = '';
description: string = '';
/** 提醒正文(触发时发出去的那封信的开头) */
reminder_text: string = '';
/** 完整三维地址串;空时服务端会退回 agent_name/to_address(本版不展示) */
recipients: string[] = [];
delivery_mode: string = 'separate'; // separate | together
event_time: string = '';
remind_before: number = 0; // 提前多少分钟
recurrence: string = 'none'; // none|daily|weekly|monthly|yearly|lunar_monthly|lunar_yearly
status: string = 'active'; // active | paused | cancelled
permission_mode: string = '';
}
/** `GET /calendar/events` 的响应 */
export class CalendarListResponse {
events: CalendarEvent[] = [];
}

View File

@ -19,14 +19,19 @@ export interface NavItem {
}
/**
* 平级导航项。**只有两项**:通信(内部三栏:收件箱/发件箱/授权)与联系人。
* 平级导航项。**三项**:通信(内部三栏:收件箱/发件箱/授权)、日历、联系人。
*
* 「会话」原先是个平级 tab,已撤 —— 它不是第三个地方,而是"同一批数据的另一种看法"
* (收件箱按会话折叠、联系人卡片视图就是会话的进度视角)。理由与时机见 `MainPage.build()` 的注释。
* 「日历」等 P6 的内容做完再上入口 —— 不留点进去空着的页签(§7.15)。
* (收件箱按会话折叠、联系人卡片视图就是会话的进度视角)。理由见 `MainPage.build()` 的注释。
*
* 「日历」原来写着"等 P6 内容做完再上入口 —— 不留点进去空着的页签"。P6 第一步
* (只读月视图:`pages/CalendarPage.ets`)已经做完,所以入口**现在**上:
* 空页签那条理由不再成立,而日历的纯逻辑(`model/Calendar.ts`)本来就是照着
* 「顶层是 通信/日历/联系人 三个平级 pane」写判据的。
*/
export const NAV_ITEMS: NavItem[] = [
{ key: 'comm', label: '通信', icon: '✉️' },
{ key: 'calendar', label: '日历', icon: '📅' },
{ key: 'contacts', label: '联系人', icon: '👤' }
];

View File

@ -0,0 +1,392 @@
/*
* AgentMail 鸿蒙客户端 — 日历(P6 第一步:只读月视图)
*
* # 这一版做了什么、没做什么(别把"没做的"读成"做了")
*
* 做了:月网格(可翻月、可回"今天")、点某一天看当天日程、事件点标记、
* 今天/选中两态、加载失败**说出来**。
* 没做:新建/编辑/删除事件(写侧)、农历重复、.ics 导入导出、左右滑动翻页(P6 第 3 步)。
* 滑动那条还牵着 `docs/DEBTS.json` 的 `gesture-semantics`("鸿蒙侧出现滑动手势代码时立即建判据")
* —— 没写手势就不该先建那条假判据,所以这一版用按钮翻月。
*
* # 为什么先做只读
*
* 日历真正会**悄悄错**的是"月份网格"这类算术(闰年、月首星期、跨年翻月、今天标哪格),
* 错了在界面上是"看起来正常但日子错位"。这一层已经由 `model/Calendar.ts` +
* `test/harmony-calendar.test.mjs` 钉住;页面只要**如实消费**它。
*
* # 为什么网格与事件必须用同一套日期口径
*
* 网格的格子键是 `iso`(`YYYY-MM-DD`,本地口径),事件来自服务端的 RFC3339 时间戳。
* 两者若各切各的字符串,UTC+8 的凌晨会把事件画到**前一天的格子**里,而所有判据都绿。
* 所以页面**只**用 `localIsoOf()` 把时间戳归一化,不自己 slice。
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { CalendarApi } from '../api/CalendarApi';
import { CalendarEvent } from '../model/Models';
import { Theme } from '../common/Theme';
import {
DayCell,
monthGrid,
monthLabel,
addMonths,
weekdayLabels,
isoOfLocal,
localIsoOf,
hhmmAtOffset,
deviceOffsetMinutes
} from '../model/Calendar';
/** 一格的高度(vp)。7 列等分 + 固定高度才画得成整齐的网格 */
const CELL_HEIGHT: number = 52;
/** 周表头高度(vp) */
const WEEKDAY_HEIGHT: number = 24;
/**
* 周起始:**1 = 周一**,与 WebUI 的 `startOfWeek()`(`lib/calendar.ts`,dow===0 时退到上周一)一致。
* 两端必须同值 —— 否则同一天在两端的格子位置不同,是跨端最容易被一眼看出来的差异。
*/
const WEEK_START: number = 1;
@Component
export struct CalendarPage {
/** 背景开着时要让出页面底(与其它 pane 同一约定) */
@Prop bgActive: boolean = false;
/**
* 这个 pane 是否可见。
*
* `@Watch` 不是装饰:`today` 是**唯一随时间变**的输入,而日历是常驻 pane
* (`docs/DEBTS.json` 的 `calendar-today-recompute`)。跨过午夜后不重算,
* 标记就停在昨天,而所有纯逻辑判据全绿(模型没错,是喂进去的值过期了)。
*/
@Prop @Watch('onVisibleChanged') visible: boolean = true;
@State year: number = 1970;
@State month: number = 1;
@State todayIso: string = '';
@State selectedIso: string = '';
@State events: CalendarEvent[] = [];
@State loading: boolean = false;
@State error: string = '';
private api: CalendarApi | null = null;
private offsetMinutes: number = 0;
/** 首次**可见**时才拉数据(常驻挂载 ≠ 常拉) */
private loadedOnce: boolean = false;
aboutToAppear(): void {
const now: Date = new Date();
this.offsetMinutes = deviceOffsetMinutes(now);
this.year = now.getFullYear();
this.month = now.getMonth() + 1;
this.refreshToday();
this.selectedIso = this.todayIso;
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.api = new CalendarApi(ApiClient.getInstance(ctx));
}
// 挂载时就可见(如直接落在日历)才立刻拉;隐藏着的不替用户发请求
if (this.visible) {
this.onVisibleChanged('visible');
}
}
/**
* pane 变可见:重算今天 + 首次拉数据。
*
* 今天必须在这里重算 —— 日历是常驻 pane,跨午夜后不重算,标记就停在昨天,
* 而所有纯逻辑判据仍然全绿(模型没错,是喂进去的值过期了)。
*/
onVisibleChanged(_propName: string): void {
if (!this.visible) {
return;
}
this.refreshToday();
if (!this.loadedOnce) {
this.loadedOnce = true;
this.loadEvents();
}
}
private refreshToday(): void {
this.todayIso = isoOfLocal(new Date());
}
/**
* 查询区间:**按网格的最坏情况**取(上月 1 日 → 下月 1 日)。
*
* 月视图首尾会显示邻月的格子,只查当月会让那几个格子永远空着 ——
* 那不是"那天没日程",是没查。(WebUI 的 `listCalendarEvents` 注释里同一句话。)
* `Date.UTC` 的月份溢出语义在这里是**有意使用**的:它是区间的算术,不是"哪一天"的键。
*/
private rangeFrom(): string {
return new Date(Date.UTC(this.year, this.month - 2, 1)).toISOString();
}
private rangeTo(): string {
return new Date(Date.UTC(this.year, this.month, 1)).toISOString();
}
async loadEvents(): Promise<void> {
const a: CalendarApi | null = this.api;
if (a === null) {
return;
}
this.loading = true;
this.error = '';
try {
const resp = await a.listEvents(this.rangeFrom(), this.rangeTo());
this.events = resp.events;
} catch (e) {
/*
* 失败必须**说出来**:静默吞掉会让"这个月没有日程"和"服务端没答上来"
* 长得一模一样(本仓库反复防的就是这种"看着正常")。
*/
this.error = e instanceof ApiError ? `日程加载失败(HTTP ${(e as ApiError).code})` : '日程加载失败';
this.events = [];
}
this.loading = false;
}
/** 翻月走 `addMonths`(纯逻辑判据钉过跨年两个方向),不自己算 month+1 */
private shiftMonth(delta: number): void {
const ym: number[] = addMonths(this.year, this.month, delta);
this.year = ym[0];
this.month = ym[1];
this.loadEvents();
}
private goToday(): void {
const now: Date = new Date();
this.refreshToday();
this.year = now.getFullYear();
this.month = now.getMonth() + 1;
this.selectedIso = this.todayIso;
this.loadEvents();
}
private rows(): DayCell[][] {
return monthGrid(this.year, this.month, WEEK_START, this.todayIso);
}
/** 这一天有几条日程(画事件点用)。数量很小,线性扫即可,不引入第二份索引。 */
private eventCountOf(iso: string): number {
if (iso.length === 0) {
return 0;
}
let n: number = 0;
for (let i = 0; i < this.events.length; i++) {
if (localIsoOf(this.events[i].event_time) === iso) {
n = n + 1;
}
}
return n;
}
/** 选中那天的日程,按时间升序 */
private selectedEvents(): CalendarEvent[] {
const out: CalendarEvent[] = [];
for (let i = 0; i < this.events.length; i++) {
const e: CalendarEvent = this.events[i];
if (localIsoOf(e.event_time) === this.selectedIso) {
out.push(e);
}
}
out.sort((a: CalendarEvent, b: CalendarEvent) => Date.parse(a.event_time) - Date.parse(b.event_time));
return out;
}
private cellFg(cell: DayCell): ResourceColor {
if (cell.iso === this.selectedIso) {
return Theme.accentFg;
}
if (cell.iso === this.todayIso) {
return Theme.accent;
}
if (!cell.inMonth) {
return Theme.textSubtle;
}
return Theme.textPrimary;
}
private statusLabel(status: string): string {
if (status === 'paused') {
return '已暂停';
}
if (status === 'cancelled') {
return '已取消';
}
return '';
}
private statusFg(status: string): ResourceColor {
return status === 'active' ? Theme.textSubtle : Theme.warnFg;
}
@Builder
DayCellView(cell: DayCell) {
Column() {
if (cell.label > 0) {
Text(`${cell.label}`)
.fontSize(Theme.fontBody)
.fontWeight(cell.iso === this.todayIso ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.cellFg(cell))
if (this.eventCountOf(cell.iso) > 0) {
Column()
.width(4)
.height(4)
.borderRadius(2)
.backgroundColor(cell.iso === this.selectedIso ? Theme.accentFg : Theme.accent)
.margin({ top: 3 })
}
}
}
.layoutWeight(1)
.height(CELL_HEIGHT)
.justifyContent(FlexAlign.Center)
/* 选中态用**底色 + 文字反白**(日历里的"选中某天"与导航条的"选中某项"不是同一件事:
导航项上色即可,因为文字本身就在报位置;格子里只有一个数字,没有底色看不出选的是哪格) */
.backgroundColor(cell.iso === this.selectedIso ? Theme.accent : Color.Transparent)
.borderRadius(Theme.radiusControl)
.onClick(() => {
if (cell.label > 0) {
this.selectedIso = cell.iso;
}
})
}
@Builder
EventRow(e: CalendarEvent) {
Row() {
Text(hhmmAtOffset(e.event_time, this.offsetMinutes))
.fontSize(Theme.fontSmall)
.fontColor(Theme.textMuted)
.width(46)
Column() {
Text(e.title)
.fontSize(Theme.fontBody)
.fontColor(Theme.textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (e.reminder_text.length > 0) {
Text(e.reminder_text)
.fontSize(Theme.fontTiny)
.fontColor(Theme.textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
if (this.statusLabel(e.status).length > 0) {
Text(this.statusLabel(e.status))
.fontSize(Theme.fontTiny)
.fontColor(this.statusFg(e.status))
.margin({ left: 6 })
}
}
.width('100%')
.padding(10)
.backgroundColor(Theme.surfaceMuted)
.borderRadius(Theme.radiusControl)
}
build() {
Column() {
/* 头部:翻月 + 回今天 */
Row() {
Button('‹')
.fontSize(Theme.fontBody)
.backgroundColor(Color.Transparent)
.fontColor(Theme.textPrimary)
.onClick(() => this.shiftMonth(-1))
Text(monthLabel(this.year, this.month))
.fontSize(Theme.fontBody)
.fontColor(Theme.textPrimary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
Button('›')
.fontSize(Theme.fontBody)
.backgroundColor(Color.Transparent)
.fontColor(Theme.textPrimary)
.onClick(() => this.shiftMonth(1))
Button('今天')
.fontSize(Theme.fontSmall)
.backgroundColor(Theme.accentSoft)
.fontColor(Theme.accentStrong)
.onClick(() => this.goToday())
}
.width('100%')
.padding({ left: 6, right: 6, top: 4, bottom: 4 })
/* 周表头:顺序**必须**来自 weekdayLabels(WEEK_START),不许在这里硬编码 */
Row() {
ForEach(weekdayLabels(WEEK_START), (w: string) => {
Text(w)
.fontSize(Theme.fontTiny)
.fontColor(Theme.textMuted)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.height(WEEKDAY_HEIGHT)
}, (w: string) => `w${w}`)
}
.width('100%')
/* 月网格:行 ← monthGrid,列 ← 7 格(嵌套 ForEach 的项名必须不同) */
ForEach(this.rows(), (row: DayCell[], rowIndex: number) => {
Row() {
ForEach(row, (cell: DayCell) => {
this.DayCellView(cell)
}, (cell: DayCell) => `c${cell.iso}#${cell.label}`)
}
.width('100%')
}, (row: DayCell[], rowIndex: number) => `r${rowIndex}`)
if (this.error.length > 0) {
Text(this.error)
.fontSize(Theme.fontSmall)
.fontColor(Theme.danger)
.padding(8)
}
/* 选中那天的日程 */
Row() {
Text(this.selectedIso)
.fontSize(Theme.fontSmall)
.fontColor(Theme.textMuted)
Blank()
if (this.loading) {
Text('加载中…')
.fontSize(Theme.fontTiny)
.fontColor(Theme.textSubtle)
}
}
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 2 })
if (this.selectedEvents().length === 0) {
Column() {
Text('这一天没有日程')
.fontSize(Theme.fontSmall)
.fontColor(Theme.textSubtle)
}
.width('100%')
.padding(16)
} else {
List({ space: 8 }) {
ForEach(this.selectedEvents(), (e: CalendarEvent) => {
ListItem() {
this.EventRow(e)
}
}, (e: CalendarEvent) => e.event_id)
}
.layoutWeight(1)
.width('100%')
.padding({ left: 10, right: 10 })
}
}
.width('100%')
.height('100%')
.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)
}
}

View File

@ -1,9 +1,10 @@
/*
* AgentMail 鸿蒙客户端 — 主框架(底部 Tab 导航)
*
* 底部两项:**通信**(内部三栏:收件箱 / 发件箱 / 授权,见 `CommTabs.ts`)与 **联系人**。
* 平级的「会话」已并入这两处(见文件末尾的说明);「日历」等它的内容(P6)一起上,
* 不留一个点进去空着的入口。
* 底部三项:**通信**(内部三栏:收件箱 / 发件箱 / 授权,见 `CommTabs.ts`)、**日历**、**联系人**。
* 平级的「会话」已并入前两处(见文件末尾的说明)。
* 「日历」原先写着"等 P6 内容做完再上入口",P6 第一步(只读月视图,见 `pages/CalendarPage.ets`)
* 已完成,所以入口现在上;日历是整个客户端里**常驻挂载**的那一个 pane(见 build() 里的说明)。
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme';
@ -48,6 +49,7 @@ import {
emptyHint
} from '../model/CommTabs';
import { MailDetailParams, ComposeParams } from '../model/RouteParams';
import { CalendarPage } from './CalendarPage';
import {
NAV_BAR_BOTTOM,
NAV_BAR_HEIGHT,
@ -1756,14 +1758,15 @@ struct MainPage {
build() {
/*
* 底部两项:**通信** / 联系人。
* 底部三项:**通信** / 日历 / 联系人。
*
* 通信不再等于收件箱:它内部有三栏(收件箱 / 发件箱 / 授权 + 徽标),
* 见 `CommPage` 与 `model/CommTabs.ts`。这也是 WebUI 现在的信息架构
* (用户 2026-09-14:「收件发件授权改为一个导航项,通过内部导航区分」)。
*
* 日历**还没有**入口:它的内容(P6:网格 + 事件读写 + 左右滑动翻页)还没做,
* 先放一个点进去空着的入口不如等它一起上 —— 这条选择记在计划文档 §7.15。
* 日历**已有入口**(P6 第 1 步:只读月视图)。还没做的是写侧(新建/编辑/删除事件)、
* 农历重复、.ics 导入导出、左右滑动翻页(P6 第 3 步)—— 逐条写在 `CalendarPage.ets` 的文件头,
* 别把"没做的"读成"做了"。
*
* 「会话」原先是个平级 tab,现在**撤掉**了 —— 它不是第三个地方,
* 而是"同一批数据的另一种看法":收件箱那栏按会话折叠(组头就是会话),
@ -1786,9 +1789,26 @@ struct MainPage {
Column() {
if (this.currentIndex === 0) {
CommPage({ bgActive: this.bgActive })
} else {
} else if (this.currentIndex === 2) {
ContactsTab({ bgActive: this.bgActive })
}
/*
* 日历与另外两个 pane 不同:**常驻挂载**,用 `visibility` 控制显示。
*
* 理由两条,都不是美观问题:
* ① `today` 是日历里**唯一随时间变**的输入,而日历是"放着不动的 pane"。
* 卸载重挂会重算(`aboutToAppear`),但"一直开着跨过午夜"不会 ——
* 常驻 + `@Watch(visible)` 才能在它重新可见时重算
* (`docs/DEBTS.json` 的 `calendar-today-recompute`)。
* ② 常驻顺带保住"正在看哪个月",切页签回来不会被重置回本月。
* 常驻 ≠ 常拉:首次**可见**时才发请求(见 `CalendarPage.onVisibleChanged`)。
*/
Column() {
CalendarPage({ bgActive: this.bgActive, visible: this.currentIndex === 1 })
}
.width('100%')
.height('100%')
.visibility(this.currentIndex === 1 ? Visibility.Visible : Visibility.None)
}
.width('100%')
.height('100%')