v69:日程桌面卡片完全照 SyncCalendar 重做(四种尺寸 + 翻页 + 时间轴)

用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」

上一版卡片是"首页概览"(今日日程 + 在做/待办/逾期 + 纪念日),
是照着首页数据自己设计的版式。这一批按用户要求换成 **SyncCalendar 那套日程卡**:

## 四块尺寸(与 SyncCalendar 一一对应)
- 2×2  日期 / 星期+农历 / 当天条数大数字 + +
- 2×4  左半同 2×2,右半「接下来两条」
- 4×4  `‹ 10月20日 / 九月初一 ›  [今|+]` + 当天日程(时间轴 / 列表)
- 6×4  同 4×4,更宽(小时 40vp)

## 翻页(这是"照 SyncCalendar"的核心)
- 卡片按钮走 `postCardAction({action:'message', params:{pageAction}})`,
  `FbFormAbility.onFormEvent` 改偏移 → 重新取数 → 推回卡片;
- **每张卡一份偏移量**,落 `preferences`(`data/FbCardForms.ets`):
  SyncCalendar 只放内存 Map,进程被回收就丢 ⇒ 系统每 30 分钟刷新一次,
  会把用户翻到的"下周三"悄悄拽回今天。家书这边连 `updateDuration` 的定时刷新
  也按各自偏移重建(`onUpdateForm` 先 `getOffset` 再 `push`)。
- 偏移夹取 ±366 天:message 是外部输入,算到离谱年份会 Invalid Date(白卡)。

## 点卡片要落到"卡片显示的那一天"
- 卡片 router 带 `openDate`(**毫秒字符串**,数字参数是 int32,13 位会溢出)
  与 `widgetAction=add`(点的是+);
- `EntryAbility.applyWantModule` 解析后写 `FbAppState.calJumpIso/Create/Seq`;
- 日程页两条入口都接上:冷启动 `aboutToAppear`、热启动 `@Monitor('app.calJumpSeq')`
  (应用开着时点卡片不会重建页面,只靠 aboutToAppear 会"点了没反应")。

## 数据
- 换接口:`/api/v1/calendar?from=<那天>&to=<那天>`(原来打 `/home`)。
  `authProbe: true` + 显式带 token 的硬要求不变(后台 401 不能把用户踢下线)。
- 农历行(`kind=lunar`)抽成抬头那一行小字,其余衍生条目(节气/节假日)照常进列表。
- 时间轴复用 `common/FbTimeline`(与日程页同一套算法),
  卡片侧只做"比例 → vp"的换算(`fbCardTlLines`,纯函数、可断言)。
- 显示方式(时间轴/列表)跟「日程设置」联动;今天才画当前时间红线。
- 应用内改完日程 → `FB_CARD_FORMS.pushAll()` 立刻刷新桌面卡片
  (不必等 30 分钟;推失败自动注销该 formId)。

## 验证
- `assembleHap` BUILD SUCCESSFUL;已装机(5NC0225B27004718)。
- `_fb_arkts_guard.js` **856 / 0**(v66 组按新契约重写:四尺寸登记、
  字段名与数据类配对、数组必须 JSON 字符串、翻页三动作、
  `onUpdateForm` 按偏移重建、冷/热启动两条入口)。
- `_fb_v59d_falsify.py` **146 / 0**(新增 6 条注入:定时刷新丢偏移、
  偏移不落盘、4×4 少「下一天」、「热启动不响应」、不读 openDate、
  字段名拼错、去 authProbe)。
  ⚠️ 其中"热启动"那条第一次注入**打偏了**:它先命中了文档注释里那半句
     `@Monitor('app.calJumpSeq')` ⇒ 真正的装饰器没被改,守卫照样绿。
     已把字面量从注释里去掉(本项目"注释里别抄字面标签"的老坑)。
- 真机跳转 `_fb_v69_card_jump.py`:
  · `openDate=今天+3天` + `widgetAction=add` ⇒ 日程页落在 **10月23日**,
    且「新建日程」浮层打开、日期就是 10月23日(dump 里 26 个浮层节点);
  · 只带 `openDate` ⇒ 同样落在 10月23日,**不**弹新建浮层。
This commit is contained in:
2026-10-20 21:59:23 +08:00
parent 030a9c7fbb
commit 719c68202b
12 changed files with 2070 additions and 605 deletions
@@ -1,22 +1,23 @@
/**
* 家书 FamilyBook · 服务卡片提供方(鸿蒙端 FormExtensionAbility)
*
* 用户 2026-10-20:「做几个和 syncCalendar 差不多的卡片。」
* 参照 `SyncCalendar` 的 `EntryFormAbility`:
* · `onAddForm` —— 添加到桌面时先塞一份"空壳",再异步推真实数据;
* · `onUpdateForm` —— 系统定时刷新(`form_config.json` 里 `updateDuration: 1` ⇒ 30 分钟);
* · `onFormEvent` —— 卡片上的「刷新」按钮(`action: 'message'`);
* · `onRemoveForm` —— 移出桌面。
* 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」
* ⇒ 生命周期与翻页照 `SyncCalendar/entry/src/main/ets/entryformability/EntryFormAbility.ets`:
* · `onAddForm` —— 添加到桌面时先塞"空壳",再异步推真实数据,并**登记**这张卡;
* · `onUpdateForm` —— 系统定时刷新(`updateDuration: 1` ⇒ 30 分钟);
* · `onFormEvent` —— 卡片上的 ‹ / › / 今 三个按钮(`action: 'message'`);
* · `onRemoveForm` —— 移出桌面(注销 + 清掉它的翻页偏移)。
*
* 🔴 三个必须记住的约束:
* ① **生命周期回调返回后进程只活约 10 秒**(官方文档),所以回调里**不能做长任务**。
* 这里的做法与 SyncCalendar 一致:立刻返回/用本地缓存,真实的网络请求
* 放在 `.then()` 里异步推 —— 就算被回收,也只是"这次没刷成",
* 下一次系统刷新(30 分钟)还会重试。**卡片数据不是关键数据,允许慢一拍。**
* ② `onAddForm` **必须同步返回**一个 `FormBindingData`(不能是 Promise),
* 否则桌面直接显示"卡片加载失败"。
* ③ 数据构建失败**绝不能抛**:抛出去的表现是桌面上一张白卡,
* 用户完全不知道发生了什么。⇒ 失败也返回"带一句人话"的壳(见 `FbCardData.hint`)。
* 🔴 四个必须记住的约束:
* ① **生命周期回调返回后进程只活约 10 秒**(官方文档)⇒ 回调里不能做长任务。
* 这里的做法与 SyncCalendar 一致:立刻返回/用本地缓存,真实网络请求放在
* `.then()` 里异步推 —— 就算被回收,也只是"这次没刷成",30 分钟后还会重试。
* ② `onAddForm` **必须同步返回** `FormBindingData`(不能是 Promise),否则桌面
* 直接显示"卡片加载失败"。
* ③ 数据构建失败**绝不能抛**:抛出去的表现是桌面一张白卡,用户完全不知道发生了什么。
* ⇒ 失败也返回"带一句人话"的壳(`FbCardData.hint`)。
* ④ **翻页 = 每张卡一份偏移量**(`FB_CARD_FORMS`),推数据时按各自偏移构建 ——
* 系统定时刷新**不能**把用户翻走的日期拽回今天。
*/
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
@@ -24,6 +25,7 @@ import { Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbCardData, fbCardBuild } from '../data/FbCardData';
import { FB_CARD_FORMS } from '../data/FbCardForms';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbForm';
@@ -32,14 +34,14 @@ export default class FbFormAbility extends FormExtensionAbility {
/**
* 重新算一遍并推给指定卡片。
*
* ⚠️ 三种尺寸(2×2 / 2×4 / 4×4)共用这一份数据:多算一次就多打一次接口,
* 没有必要 —— 它们只是**排版不同**,数字必须是同一个。
* ⚠️ 四块卡(2×2 / 2×4 / 4×4 / 6×4)共用这份数据:它们只是**排版不同**,
* 数字与条目必须是同一个 —— 多算一遍既慢又可能口径不一致。
*/
private push(formId: string): void {
fbCardBuild(this.context).then((data: FbCardData): void => {
private push(formId: string, dayOffset: number): void {
fbCardBuild(this.context, dayOffset).then((data: FbCardData): void => {
const binding: formBindingData.FormBindingData = formBindingData.createFormBindingData(data);
formProvider.updateForm(formId, binding).then((): void => {
hilog.info(DOMAIN, TAG, '卡片 %{public}s 已更新(%{public}s)', formId, data.state);
hilog.info(DOMAIN, TAG, '卡片 %{public}s 已更新(偏移 %{public}d)', formId, data.dayOffset);
}).catch((err: BusinessError): void => {
// 卡片可能已经被用户删了 —— 打一条日志就够,不要重试
hilog.warn(DOMAIN, TAG, '卡片 %{public}s 更新失败:%{public}s', formId, err.message);
@@ -62,10 +64,14 @@ export default class FbFormAbility extends FormExtensionAbility {
const formId: string = this.formIdOf(want);
hilog.info(DOMAIN, TAG, '添加卡片:%{public}s', formId);
if (formId !== '') {
// 异步补真实数据(不 await:见文件头约束 ①)
this.push(formId);
// 异步:登记 + 补真实数据(不 await:见文件头约束 ①)
FB_CARD_FORMS.register(this.context, formId).then((): void => {
this.push(formId, 0);
}).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '卡片登记失败:%{public}s', err.message);
});
}
// 先给一份"正在取"的壳 —— 卡片上会显示这天/这周的空状态,而不是白屏
// 先给一份"正在取"的壳 —— 卡片上会显示空态,而不是白屏
const shell: FbCardData = new FbCardData();
shell.state = 'loading';
shell.hint = '正在取数据…';
@@ -74,7 +80,13 @@ export default class FbFormAbility extends FormExtensionAbility {
onUpdateForm(formId: string): void {
hilog.info(DOMAIN, TAG, '系统定时刷新:%{public}s', formId);
this.push(formId);
// ⚠️ 必须按**这张卡自己的偏移**重建,否则定时刷新会把用户翻走的日期拽回今天
FB_CARD_FORMS.getOffset(this.context, formId).then((off: number): void => {
this.push(formId, off);
}).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '读卡片偏移失败:%{public}s', err.message);
this.push(formId, 0);
});
}
onFormEvent(formId: string, message: string): void {
@@ -82,11 +94,33 @@ export default class FbFormAbility extends FormExtensionAbility {
⚠️ 不解析 JSON:不同版本系统给的形态可能是 `{params:{...}}` 或直接 `{...}`,
子串匹配更稳(与 SyncCalendar 同一个取舍)。 */
hilog.info(DOMAIN, TAG, '卡片事件:%{public}s %{public}s', formId, message);
this.push(formId);
FB_CARD_FORMS.getOffset(this.context, formId).then((cur: number): void => {
let next: number = cur;
if (message.indexOf('prev') >= 0) {
next = cur - 1;
} else if (message.indexOf('next') >= 0) {
next = cur + 1;
} else if (message.indexOf('today') >= 0) {
next = 0;
}
FB_CARD_FORMS.setOffset(this.context, formId, next).then((off: number): void => {
this.push(formId, off);
}).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '卡片翻页失败:%{public}s', err.message);
this.push(formId, cur);
});
}).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '读卡片偏移失败(按今天处理):%{public}s', err.message);
this.push(formId, 0);
});
}
onRemoveForm(formId: string): void {
// 本卡片不落任何"每张卡"的状态(没有翻页偏移之类),所以只需记一条日志
hilog.info(DOMAIN, TAG, '移除卡片:%{public}s', formId);
FB_CARD_FORMS.unregister(this.context, formId).then((): Promise<void> => {
return FB_CARD_FORMS.clearOffset(this.context, formId);
}).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '注销卡片失败:%{public}s', err.message);
});
}
}