2026-10-20 15:27:32 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 家书 FamilyBook · 服务卡片提供方(鸿蒙端 FormExtensionAbility)
|
|
|
|
|
|
*
|
2026-10-20 21:59:23 +08:00
|
|
|
|
* 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」
|
|
|
|
|
|
* ⇒ 生命周期与翻页照 `SyncCalendar/entry/src/main/ets/entryformability/EntryFormAbility.ets`:
|
|
|
|
|
|
* · `onAddForm` —— 添加到桌面时先塞"空壳",再异步推真实数据,并**登记**这张卡;
|
|
|
|
|
|
* · `onUpdateForm` —— 系统定时刷新(`updateDuration: 1` ⇒ 30 分钟);
|
|
|
|
|
|
* · `onFormEvent` —— 卡片上的 ‹ / › / 今 三个按钮(`action: 'message'`);
|
|
|
|
|
|
* · `onRemoveForm` —— 移出桌面(注销 + 清掉它的翻页偏移)。
|
2026-10-20 15:27:32 +08:00
|
|
|
|
*
|
2026-10-20 21:59:23 +08:00
|
|
|
|
* 🔴 四个必须记住的约束:
|
|
|
|
|
|
* ① **生命周期回调返回后进程只活约 10 秒**(官方文档)⇒ 回调里不能做长任务。
|
|
|
|
|
|
* 这里的做法与 SyncCalendar 一致:立刻返回/用本地缓存,真实网络请求放在
|
|
|
|
|
|
* `.then()` 里异步推 —— 就算被回收,也只是"这次没刷成",30 分钟后还会重试。
|
|
|
|
|
|
* ② `onAddForm` **必须同步返回** `FormBindingData`(不能是 Promise),否则桌面
|
|
|
|
|
|
* 直接显示"卡片加载失败"。
|
|
|
|
|
|
* ③ 数据构建失败**绝不能抛**:抛出去的表现是桌面一张白卡,用户完全不知道发生了什么。
|
|
|
|
|
|
* ⇒ 失败也返回"带一句人话"的壳(`FbCardData.hint`)。
|
|
|
|
|
|
* ④ **翻页 = 每张卡一份偏移量**(`FB_CARD_FORMS`),推数据时按各自偏移构建 ——
|
|
|
|
|
|
* 系统定时刷新**不能**把用户翻走的日期拽回今天。
|
2026-10-20 15:27:32 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-10-24 23:44:46 +08:00
|
|
|
|
import { FormExtensionAbility, formBindingData, formProvider, formInfo } from '@kit.FormKit';
|
2026-10-20 15:27:32 +08:00
|
|
|
|
import { Want } from '@kit.AbilityKit';
|
|
|
|
|
|
import { BusinessError } from '@kit.BasicServicesKit';
|
|
|
|
|
|
import { hilog } from '@kit.PerformanceAnalysisKit';
|
2026-10-25 09:29:41 +08:00
|
|
|
|
import { fbCardDataByKind, fbTaskCardComplete, FbTaskCardData } from '../data/FbCardMediaData';
|
2026-10-20 21:59:23 +08:00
|
|
|
|
import { FB_CARD_FORMS } from '../data/FbCardForms';
|
2026-10-24 04:25:15 +08:00
|
|
|
|
import { FB_BG_SYNC } from '../common/FbBackgroundSync';
|
2026-10-20 15:27:32 +08:00
|
|
|
|
|
|
|
|
|
|
const DOMAIN: number = 0x0000;
|
|
|
|
|
|
const TAG: string = 'FbForm';
|
|
|
|
|
|
|
2026-10-24 23:44:46 +08:00
|
|
|
|
/** 卡片名 → 数据类型(v90 起不止日程卡)。
|
|
|
|
|
|
* ⚠️ 名字来自 `form_config.json` 的 `name` 字段(Want 里的 `form_name`),
|
|
|
|
|
|
* 两边改名必须同步 —— 改一边的表现是"新卡永远拿日程数据",编译不报错。 */
|
|
|
|
|
|
function kindOfFormName(name: string): string {
|
|
|
|
|
|
if (name.indexOf('FbMovieCard') === 0) {
|
|
|
|
|
|
return 'movies';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (name.indexOf('FbTaskCard') === 0) {
|
|
|
|
|
|
return 'tasks';
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'calendar';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 15:27:32 +08:00
|
|
|
|
export default class FbFormAbility extends FormExtensionAbility {
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 重新算一遍并推给指定卡片。
|
|
|
|
|
|
*
|
2026-10-24 23:44:46 +08:00
|
|
|
|
* ⚠️ v90 起按类型分流:日程卡吃 `dayOffset`(翻页);
|
|
|
|
|
|
* 观影/任务卡没有"看哪一天"的概念,`dayOffset` 固定传 0。
|
2026-10-20 15:27:32 +08:00
|
|
|
|
*/
|
2026-10-24 23:44:46 +08:00
|
|
|
|
private push(formId: string, kind: string, dayOffset: number): void {
|
|
|
|
|
|
fbCardDataByKind(this.context, kind, dayOffset).then((data: Object): void => {
|
2026-10-20 15:27:32 +08:00
|
|
|
|
const binding: formBindingData.FormBindingData = formBindingData.createFormBindingData(data);
|
|
|
|
|
|
formProvider.updateForm(formId, binding).then((): void => {
|
2026-10-24 23:44:46 +08:00
|
|
|
|
hilog.info(DOMAIN, TAG, '卡片 %{public}s(%{public}s) 已更新(偏移 %{public}d)',
|
|
|
|
|
|
formId, kind, dayOffset);
|
2026-10-20 15:27:32 +08:00
|
|
|
|
}).catch((err: BusinessError): void => {
|
|
|
|
|
|
// 卡片可能已经被用户删了 —— 打一条日志就够,不要重试
|
|
|
|
|
|
hilog.warn(DOMAIN, TAG, '卡片 %{public}s 更新失败:%{public}s', formId, err.message);
|
|
|
|
|
|
});
|
|
|
|
|
|
}).catch((err: BusinessError): void => {
|
|
|
|
|
|
hilog.error(DOMAIN, TAG, '卡片数据构建失败:%{public}s', err.message);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 从 Want 里取卡片 id(`ohos.extra.param.key.form_identity`) */
|
|
|
|
|
|
private formIdOf(want: Want): string {
|
|
|
|
|
|
if (want.parameters === undefined) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
const raw: Object | undefined = want.parameters['ohos.extra.param.key.form_identity'];
|
|
|
|
|
|
return typeof raw === 'string' ? raw : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-24 23:44:46 +08:00
|
|
|
|
/** 从 Want 里取卡片名(`ohos.extra.param.key.form_name`,即 form_config 的 name) */
|
|
|
|
|
|
private formNameOf(want: Want): string {
|
|
|
|
|
|
if (want.parameters === undefined) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
const raw: Object | undefined = want.parameters[formInfo.FormParam.NAME_KEY];
|
|
|
|
|
|
return typeof raw === 'string' ? raw : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 15:27:32 +08:00
|
|
|
|
onAddForm(want: Want): formBindingData.FormBindingData {
|
|
|
|
|
|
const formId: string = this.formIdOf(want);
|
2026-10-24 23:44:46 +08:00
|
|
|
|
const kind: string = kindOfFormName(this.formNameOf(want));
|
|
|
|
|
|
hilog.info(DOMAIN, TAG, '添加卡片:%{public}s(%{public}s)', formId, kind);
|
2026-10-20 15:27:32 +08:00
|
|
|
|
if (formId !== '') {
|
2026-10-20 21:59:23 +08:00
|
|
|
|
// 异步:登记 + 补真实数据(不 await:见文件头约束 ①)
|
2026-10-24 23:44:46 +08:00
|
|
|
|
FB_CARD_FORMS.register(this.context, formId, kind).then((): void => {
|
|
|
|
|
|
this.push(formId, kind, 0);
|
2026-10-20 21:59:23 +08:00
|
|
|
|
}).catch((err: BusinessError): void => {
|
|
|
|
|
|
hilog.warn(DOMAIN, TAG, '卡片登记失败:%{public}s', err.message);
|
|
|
|
|
|
});
|
2026-10-20 15:27:32 +08:00
|
|
|
|
}
|
2026-10-20 21:59:23 +08:00
|
|
|
|
// 先给一份"正在取"的壳 —— 卡片上会显示空态,而不是白屏
|
2026-10-24 23:44:46 +08:00
|
|
|
|
const shell: Record<string, string> = {};
|
|
|
|
|
|
shell['state'] = 'loading';
|
|
|
|
|
|
shell['hint'] = '正在取数据…';
|
2026-10-20 15:27:32 +08:00
|
|
|
|
return formBindingData.createFormBindingData(shell);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onUpdateForm(formId: string): void {
|
|
|
|
|
|
hilog.info(DOMAIN, TAG, '系统定时刷新:%{public}s', formId);
|
2026-10-24 04:25:15 +08:00
|
|
|
|
/* 🔴🔴 v83(用户 2026-10-23「要有定期同步的功能,家书app在后台,也能自动同步数据」):
|
|
|
|
|
|
**借卡片定时刷新这个时机跑一轮后台同步。**
|
|
|
|
|
|
|
|
|
|
|
|
为什么只有这里能做到"约 30 分钟一次":
|
|
|
|
|
|
`workScheduler` 的周期被系统按"应用活跃分组"压制(活跃≥2h / 常用≥24h),
|
|
|
|
|
|
请求更短不报错但**不生效**;而卡片配了 `updateDuration: 1`(单位 30 分钟)后,
|
|
|
|
|
|
系统会每 30 分钟回调一次本方法,**不受活跃分组压制**(每天上限 50 次)。
|
|
|
|
|
|
⇒ 这是那条"打开 app 数据就是最新的"真正落地的通道。
|
|
|
|
|
|
|
|
|
|
|
|
⚠️ **必须先推卡片、再同步**:
|
|
|
|
|
|
推送只依赖本地缓存/一次轻量请求,几秒内就能完成;
|
|
|
|
|
|
同步要跑 12 个模块,很可能在"进程只活 10 秒"的窗口里被砍。
|
|
|
|
|
|
顺序反了就会变成"同步把时间用光、卡片这次没刷新"——
|
|
|
|
|
|
用户看到的是"卡片一直转圈",比慢一步更糟。
|
|
|
|
|
|
⚠️ **不 await**:`onUpdateForm` 返回后进程只剩约 10 秒,
|
|
|
|
|
|
await 只会把两者一起拖死。同步自己内部有节流与预算(见 FbBackgroundSync)。
|
|
|
|
|
|
⚠️ 节流:系统刷新可能漂移或重入,`runThrottledByCard` 内部用时间戳挡了一道,
|
|
|
|
|
|
并且**先写时间戳再跑**,避免卡片进程与主进程并发拉同一批数据。 */
|
2026-10-20 21:59:23 +08:00
|
|
|
|
// ⚠️ 必须按**这张卡自己的偏移**重建,否则定时刷新会把用户翻走的日期拽回今天
|
2026-10-24 23:44:46 +08:00
|
|
|
|
// 🔴 v90:偏移量只对日程卡有意义;观影/任务卡按类型直接推。
|
|
|
|
|
|
FB_CARD_FORMS.kindOf(this.context, formId).then((kind: string): void => {
|
|
|
|
|
|
if (kind !== 'calendar') {
|
|
|
|
|
|
this.push(formId, kind, 0);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
FB_CARD_FORMS.getOffset(this.context, formId).then((off: number): void => {
|
|
|
|
|
|
this.push(formId, kind, off);
|
|
|
|
|
|
}).catch((err: BusinessError): void => {
|
|
|
|
|
|
hilog.warn(DOMAIN, TAG, '读卡片偏移失败:%{public}s', err.message);
|
|
|
|
|
|
this.push(formId, kind, 0);
|
|
|
|
|
|
});
|
2026-10-20 21:59:23 +08:00
|
|
|
|
}).catch((err: BusinessError): void => {
|
2026-10-24 23:44:46 +08:00
|
|
|
|
hilog.warn(DOMAIN, TAG, '读卡片类型失败(按日程处理):%{public}s', err.message);
|
|
|
|
|
|
this.push(formId, 'calendar', 0);
|
2026-10-20 21:59:23 +08:00
|
|
|
|
});
|
2026-10-24 04:25:15 +08:00
|
|
|
|
/* ⚠️ 这一句必须放在**推送之后**(原因见上面的 ①:卡片刷新只有几秒的窗口,
|
|
|
|
|
|
不能被 12 个模块的同步挤掉)。两条都是"发起即返回",不会互相阻塞。 */
|
|
|
|
|
|
FB_BG_SYNC.runThrottledByCard(this.context).catch((err: BusinessError): void => {
|
|
|
|
|
|
hilog.warn(DOMAIN, TAG, '卡片驱动的后台同步失败(不影响卡片本身):%{public}s', err.message);
|
|
|
|
|
|
});
|
2026-10-20 15:27:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-25 09:29:41 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 🔴 v91:任务卡上点「完成」—— 把任务标成 done,重算后**推给所有任务卡**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 推所有任务卡而不是只推这一张:还有别的任务卡(2×2 的待办数、别的 4×4)
|
|
|
|
|
|
* 也显示这条任务,只推一张会出现"这张没了、那张还挂着"。
|
|
|
|
|
|
* ⚠️ message 里的 id 用正则抠:`{"taskDone":"12"}` / 嵌套 `{params:{…}}`
|
|
|
|
|
|
* 两种形态都能命中(与既有"子串匹配"取舍一致)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private markTaskDone(formId: string, message: string): void {
|
|
|
|
|
|
const m: RegExpMatchArray | null = message.match(/"taskDone"\s*:\s*"?(\d+)/);
|
|
|
|
|
|
const id: number = m !== null ? parseInt(m[1], 10) : 0;
|
|
|
|
|
|
if (id <= 0 || isNaN(id)) {
|
|
|
|
|
|
hilog.warn(DOMAIN, TAG, '任务卡「完成」消息里没有可用 id:%{public}s', message);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
fbTaskCardComplete(this.context, id).then((data: FbTaskCardData): void => {
|
|
|
|
|
|
const binding: formBindingData.FormBindingData = formBindingData.createFormBindingData(data);
|
|
|
|
|
|
// 先回这张卡(用户手指点的那张,必须立刻看到变化)
|
|
|
|
|
|
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
|
|
|
|
|
|
hilog.warn(DOMAIN, TAG, '任务卡 %{public}s 回推失败:%{public}s', formId, err.message);
|
|
|
|
|
|
});
|
|
|
|
|
|
// 其余任务卡重算重推(数量不多,构建走同一份缓存口径)
|
|
|
|
|
|
FB_CARD_FORMS.pushAll(this.context).catch((err: BusinessError): void => {
|
|
|
|
|
|
hilog.warn(DOMAIN, TAG, '任务完成后全量重推失败:%{public}s', err.message);
|
|
|
|
|
|
});
|
|
|
|
|
|
}).catch((err: BusinessError): void => {
|
|
|
|
|
|
hilog.error(DOMAIN, TAG, '完成任务 %{public}d 流程失败:%{public}s', id, err.message);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 15:27:32 +08:00
|
|
|
|
onFormEvent(formId: string, message: string): void {
|
|
|
|
|
|
/* 卡片上的按钮都走 `action: 'message'`,`message` 是 JSON 字符串。
|
|
|
|
|
|
⚠️ 不解析 JSON:不同版本系统给的形态可能是 `{params:{...}}` 或直接 `{...}`,
|
2026-10-24 23:44:46 +08:00
|
|
|
|
子串匹配更稳(与 SyncCalendar 同一个取舍)。
|
|
|
|
|
|
⚠️ v90:只有日程卡有 ‹ / › / 今 三个按钮;观影/任务卡不发 message 事件,
|
2026-10-25 09:29:41 +08:00
|
|
|
|
收到也直接忽略(不把 message 撞进偏移量里)。
|
|
|
|
|
|
🔴 v91:任务卡多了「完成」按钮(`taskDone`),**必须最先分流**——
|
|
|
|
|
|
它不认卡片类型也不认偏移量,认不出类型时也不能丢。 */
|
2026-10-20 15:27:32 +08:00
|
|
|
|
hilog.info(DOMAIN, TAG, '卡片事件:%{public}s %{public}s', formId, message);
|
2026-10-25 09:29:41 +08:00
|
|
|
|
if (message.indexOf('taskDone') >= 0) {
|
|
|
|
|
|
this.markTaskDone(formId, message);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-24 23:44:46 +08:00
|
|
|
|
FB_CARD_FORMS.kindOf(this.context, formId).then((kind: string): void => {
|
|
|
|
|
|
if (kind !== 'calendar') {
|
|
|
|
|
|
return;
|
2026-10-20 21:59:23 +08:00
|
|
|
|
}
|
2026-10-24 23:44:46 +08:00
|
|
|
|
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, kind, off);
|
|
|
|
|
|
}).catch((err: BusinessError): void => {
|
|
|
|
|
|
hilog.warn(DOMAIN, TAG, '卡片翻页失败:%{public}s', err.message);
|
|
|
|
|
|
this.push(formId, kind, cur);
|
|
|
|
|
|
});
|
2026-10-20 21:59:23 +08:00
|
|
|
|
}).catch((err: BusinessError): void => {
|
2026-10-24 23:44:46 +08:00
|
|
|
|
hilog.warn(DOMAIN, TAG, '读卡片偏移失败(按今天处理):%{public}s', err.message);
|
|
|
|
|
|
this.push(formId, kind, 0);
|
2026-10-20 21:59:23 +08:00
|
|
|
|
});
|
|
|
|
|
|
}).catch((err: BusinessError): void => {
|
2026-10-24 23:44:46 +08:00
|
|
|
|
hilog.warn(DOMAIN, TAG, '读卡片类型失败:%{public}s', err.message);
|
2026-10-20 21:59:23 +08:00
|
|
|
|
});
|
2026-10-20 15:27:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onRemoveForm(formId: string): void {
|
|
|
|
|
|
hilog.info(DOMAIN, TAG, '移除卡片:%{public}s', formId);
|
2026-10-20 21:59:23 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-10-20 15:27:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|