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:
@@ -1,26 +1,23 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 桌面服务卡片数据(鸿蒙端)
|
||||
* 家书 FamilyBook · **日程**桌面卡片数据(鸿蒙端)
|
||||
*
|
||||
* 用户 2026-10-20:「做几个和 syncCalendar 差不多的卡片。」
|
||||
* ⇒ 参照 `SyncCalendar` 的 `CardDataService`:卡片**不直连数据库**,
|
||||
* 而是把"卡片要显示的那几十个字段"先算成一份**扁平的**数据对象
|
||||
* (每个字段都是 string / number),再由 `formBindingData` 推给卡片渲染。
|
||||
* 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」
|
||||
* ⇒ 结构照 `D:\Yongquan\code\SyncCalendar` 的 `CardDataService` + 四块卡:
|
||||
* · 2×2 日期 / 星期农历 / 当天条数;
|
||||
* · 2×4 左边同 2×2,右边「今天接下来两条」;
|
||||
* · 4×4 日期翻页条 + 当天日程(时间轴 / 列表,跟应用内设置联动);
|
||||
* · 6×4 同 4×4,横向更宽。
|
||||
*
|
||||
* 为什么不让卡片自己去请求接口:
|
||||
* ① 卡片的 ArkTS 是**受限子集**(不能随便 await、不能拿 Context 去读 preferences),
|
||||
* 它只认 `LocalStorage` 里那几个标量;
|
||||
* ② 逻辑放在这里 ⇒ 2×2 / 2×4 / 4×4 三种尺寸共用**同一份计算**,
|
||||
* 不会出现"大卡说 5 条日程、小卡说 3 条"这种自相矛盾。
|
||||
* 与 SyncCalendar 的**两处有意差异**(都是家书的数据形状决定的,不照抄):
|
||||
* ① 它读端上本地库,家书读服务端 `GET /api/v1/calendar?from=&to=`;
|
||||
* ② 它的数据里没有"衍生条目",家书的 `/calendar` 会给农历 / 节气 / 节假日
|
||||
* ⇒ 农历行抽成 `lunarText`(抬头那一行),其余衍生条目照常进列表。
|
||||
*
|
||||
* 🔴 数据来源只用 **`GET /api/v1/home` 一个接口**:
|
||||
* 它一次给齐 日期/星期/农历/日子标记/今日日程/统计/最近纪念日,
|
||||
* 与网页版首页**同源**(服务端 `home()` 的注释写明了这一点)。
|
||||
* 多打两个接口(calendar + days)只会让卡片刷新变慢、且口径可能与首页不一致。
|
||||
*
|
||||
* ⚠️ 令牌与服务器地址:
|
||||
* 卡片进程(FormExtensionAbility)与主应用**共享同一个沙箱**,
|
||||
* 所以直接复用 `FbTokenStore` / `FbServerStore` 的 preferences 即可,
|
||||
* **不要在卡片里另存一份**(那样用户在应用里"退出登录",卡片还带着老令牌)。
|
||||
* 🔴 三条硬约束(沿用上一版卡片,别丢):
|
||||
* ① 卡片进程生命周期回调**只活约 10 秒** ⇒ 回调里不同步做长任务;
|
||||
* ② `FormBindingData` **只认标量** ⇒ 数组一律 `JSON.stringify` 成字符串;
|
||||
* ③ 卡片是**后台**刷新,请求必须 `authProbe: true` 且显式带 token ——
|
||||
* 否则一次 401 会触发全局"踢回登录页",用户在应用里用得好好的却被卡片踢下线。
|
||||
*/
|
||||
|
||||
import { common } from '@kit.AbilityKit';
|
||||
@@ -29,68 +26,121 @@ import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { FB_SERVER_STORE } from './FbServerStore';
|
||||
import { FB_TOKEN_STORE } from './FbTokenStore';
|
||||
import { FB_CAL_SETTINGS } from './FbCalSettings';
|
||||
import { fbApi, FbApiResult } from './FbHttp';
|
||||
import { parseHome } from './FbParse';
|
||||
import { FbCalEvent, FbHomeData, FbMark, FbRecentDay } from '../model/Types';
|
||||
import { parseCalendar } from './FbParse';
|
||||
import { FbCalEvent } from '../model/Types';
|
||||
import { FbTimelineUtil, FbTimelineData, FbTimeBlock, FbViewRange } from '../common/FbTimeline';
|
||||
|
||||
const DOMAIN: number = 0x0000;
|
||||
const TAG: string = 'FbCard';
|
||||
|
||||
/** 卡片上的一条日程(扁平化:卡片只认标量) */
|
||||
export class FbCardEvent {
|
||||
/** ForEach 的 key(**必须唯一**,否则同名的两条只渲染第一条) */
|
||||
/** 卡片上的一行日程(扁平标量,卡片侧 `JSON.parse` 后 ForEach) */
|
||||
export class FbCardRow {
|
||||
/** ForEach 的 key(**必须唯一**:同名两条只渲染一条,踩过) */
|
||||
key: string = '';
|
||||
/** `09:00` / `全天` */
|
||||
time: string = '';
|
||||
/** `10:30`(全天为空) */
|
||||
endTime: string = '';
|
||||
title: string = '';
|
||||
/** 日历本色标(直接当左侧色条用) */
|
||||
/** 所属日历本色标(左侧色条) */
|
||||
color: string = '#A84A28';
|
||||
/** 所属日历本名(4×4 列表右侧小字) */
|
||||
calName: string = '';
|
||||
/** 正在进行中(仅"今天"的有时间日程) */
|
||||
isNow: boolean = false;
|
||||
}
|
||||
|
||||
/** 卡片上的一条纪念日倒计时 */
|
||||
export class FbCardDay {
|
||||
/** 时间轴上的一块(比例形式;视图侧按小时高度换算成 vp) */
|
||||
export class FbCardBlock {
|
||||
key: string = '';
|
||||
title: string = '';
|
||||
/** 还有几天(0 = 就是今天) */
|
||||
days: number = 0;
|
||||
/** 7 天内 ⇒ 卡片上用品牌色强调 */
|
||||
soon: boolean = false;
|
||||
timeText: string = '';
|
||||
color: string = '#A84A28';
|
||||
/** 距 0 点的比例 0~1 */
|
||||
topRatio: number = 0;
|
||||
heightRatio: number = 0;
|
||||
/** 冲突组序号(同一簇重叠日程) */
|
||||
groupIndex: number = 0;
|
||||
/** 组内列号(同列互不重叠) */
|
||||
laneIndex: number = 0;
|
||||
isNow: boolean = false;
|
||||
}
|
||||
|
||||
/** 时间轴渲染的一"行":要么是一段空白,要么是一个冲突组(组内按列并排) */
|
||||
export class FbCardTlBlock {
|
||||
key: string = '';
|
||||
title: string = '';
|
||||
timeText: string = '';
|
||||
color: string = '#A84A28';
|
||||
isNow: boolean = false;
|
||||
/** 组内上方的空白高度(vp) */
|
||||
padTopVp: number = 0;
|
||||
/** 色块高度(vp) */
|
||||
heightVp: number = 0;
|
||||
}
|
||||
|
||||
export class FbCardTlGroup {
|
||||
key: string = '';
|
||||
heightVp: number = 0;
|
||||
lanes: FbCardTlBlock[][] = [];
|
||||
}
|
||||
|
||||
export class FbCardTlLine {
|
||||
key: string = '';
|
||||
/** 空白行高度(kind=0 时有效) */
|
||||
blankVp: number = 0;
|
||||
/** kind=1 时有效 */
|
||||
group: FbCardTlGroup | null = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片整体数据。
|
||||
*
|
||||
* ⚠️ **字段名就是 `LocalStorage` 的键名**(卡片页 `@LocalStorageProp('xxx')`),
|
||||
* 两侧改名必须同步 —— 改一边的表现是"卡片永远显示默认值",不会报错。
|
||||
* 两侧改名必须同步 —— 改一边的表现是"卡片永远显示默认值",编译不报错。
|
||||
*/
|
||||
export class FbCardData {
|
||||
/**
|
||||
* 状态:`ok` 正常 / `login` 未登录 / `noserver` 没填服务器 /
|
||||
* `offline` 连不上(此时若有缓存则显示缓存) / `loading` 刚添加还没拉到。
|
||||
*/
|
||||
/** `ok` / `login` / `noserver` / `offline` / `loading` */
|
||||
state: string = 'loading';
|
||||
/** `10 月 20 日` */
|
||||
todayLabel: string = '';
|
||||
/** `星期二` */
|
||||
weekday: string = '';
|
||||
/** `九月初一`(空 = 算不出,卡片不画) */
|
||||
lunarShort: string = '';
|
||||
/** 日子标记拼成一行:`寒露 · 重阳节` */
|
||||
marks: string = '';
|
||||
/** 今日日程条数 */
|
||||
eventCount: number = 0;
|
||||
/** 在做 / 待办 / 逾期(三个数字与首页概述卡同源) */
|
||||
doing: number = 0;
|
||||
todo: number = 0;
|
||||
overdue: number = 0;
|
||||
/** `FbCardEvent[]` 的 JSON(卡片侧 `JSON.parse` 成数组再 ForEach) */
|
||||
eventsJson: string = '[]';
|
||||
/** `FbCardDay[]` 的 JSON */
|
||||
daysJson: string = '[]';
|
||||
/** 一行小字提示(未登录 / 离线 / 出错时给用户一个说法) */
|
||||
hint: string = '';
|
||||
/** `15:40 更新` */
|
||||
updatedAt: string = '';
|
||||
/** `10月20日` */
|
||||
dateMd: string = '';
|
||||
/** `今天 10月20日 周二` */
|
||||
dateText: string = '';
|
||||
/** `星期二` */
|
||||
weekday: string = '';
|
||||
/** `九月初一`(空 = 算不出 / 那天没有农历行,卡片不画) */
|
||||
lunarText: string = '';
|
||||
/** 相对今天的天数偏移(0 = 今天) */
|
||||
dayOffset: number = 0;
|
||||
/** 当前显示的是不是今天(只有今天画"当前时间"红线) */
|
||||
isToday: boolean = true;
|
||||
/** 当前显示那天 0 点的毫秒(点卡片进应用时定位到这一天) */
|
||||
selectedDate: number = 0;
|
||||
/** 当天条目数(含全天) */
|
||||
dayCount: number = 0;
|
||||
/** 当天日程(行式,全天在前、其余按时间升序) */
|
||||
listJson: string = '[]';
|
||||
/** 全天/跨天条目(单独一组,画在时间轴上方) */
|
||||
allDayJson: string = '[]';
|
||||
/** 时间轴色块(`FbCardBlock[]`) */
|
||||
timelineJson: string = '[]';
|
||||
/** 「接下来两条」(2×4 右半用) */
|
||||
upcomingJson: string = '[]';
|
||||
/** 日程画法:`timeline` / `list`(与应用内「日程设置」联动) */
|
||||
displayStyle: string = 'timeline';
|
||||
/** 时间轴视窗(小时,含起点不含终点) */
|
||||
viewStartHour: number = 8;
|
||||
viewEndHour: number = 18;
|
||||
/** 当前时刻在当天时间轴上的比例;**-1 = 不画红线**(非今天) */
|
||||
nowRatio: number = -1;
|
||||
/** `15:40` */
|
||||
nowLabel: string = '';
|
||||
}
|
||||
|
||||
/** 两位补零 */
|
||||
@@ -98,74 +148,31 @@ function pad2(v: number): string {
|
||||
return v < 10 ? '0' + v.toString() : v.toString();
|
||||
}
|
||||
|
||||
/** 当前时刻 `HH:mm`(卡片右上角"更新时间") */
|
||||
/** 当前时刻 `HH:mm` */
|
||||
function clockText(): string {
|
||||
const d: Date = new Date();
|
||||
return pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
||||
}
|
||||
|
||||
/** 把接口载荷铺进卡片数据 */
|
||||
function applyHome(home: FbHomeData, out: FbCardData): void {
|
||||
out.todayLabel = home.todayLabel;
|
||||
out.weekday = home.weekday;
|
||||
out.lunarShort = home.lunarShort;
|
||||
out.eventCount = home.todayEvents.length;
|
||||
out.doing = home.stat.doing;
|
||||
out.todo = home.stat.todo;
|
||||
out.overdue = home.stat.overdue;
|
||||
// 日子标记:最多两个(卡片上那一行放三个就会折行,反而看不清)
|
||||
let marks: string = '';
|
||||
let n: number = 0;
|
||||
for (const mk of home.marks as FbMark[]) {
|
||||
if (n >= 2) {
|
||||
break;
|
||||
}
|
||||
marks += (marks === '' ? '' : ' · ') + mk.word;
|
||||
n++;
|
||||
}
|
||||
out.marks = marks;
|
||||
// 今日日程:最多 6 条(4×4 也只放得下 5~6 行)
|
||||
const events: FbCardEvent[] = [];
|
||||
let idx: number = 0;
|
||||
for (const e of home.todayEvents as FbCalEvent[]) {
|
||||
if (idx >= 6) {
|
||||
break;
|
||||
}
|
||||
const it: FbCardEvent = new FbCardEvent();
|
||||
it.key = 'e' + idx.toString();
|
||||
it.time = e.allDay || e.time === '' ? '全天' : e.time;
|
||||
it.title = e.title === '' ? '(无标题)' : e.title;
|
||||
it.color = e.color !== '' ? e.color : '#A84A28';
|
||||
events.push(it);
|
||||
idx++;
|
||||
}
|
||||
out.eventsJson = JSON.stringify(events);
|
||||
// 最近纪念日:最多 3 条(服务端已按"最近的在前"排好,**不要再排**)
|
||||
const days: FbCardDay[] = [];
|
||||
let di: number = 0;
|
||||
for (const rd of home.recentDays as FbRecentDay[]) {
|
||||
if (di >= 3) {
|
||||
break;
|
||||
}
|
||||
const it: FbCardDay = new FbCardDay();
|
||||
it.key = 'd' + di.toString();
|
||||
it.title = rd.title;
|
||||
it.days = rd.days;
|
||||
it.soon = rd.days <= 7;
|
||||
days.push(it);
|
||||
di++;
|
||||
}
|
||||
out.daysJson = JSON.stringify(days);
|
||||
const FULL_WEEK: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
||||
const SHORT_WEEK: string[] = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
|
||||
/** `Date` → `YYYY-MM-DD`(**本地时区**:服务端也按本地自然日给条目) */
|
||||
function isoOf(d: Date): string {
|
||||
return d.getFullYear().toString() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
|
||||
}
|
||||
|
||||
/** 缓存用的 preferences(卡片断网时显示上次成功的数据) */
|
||||
const CACHE_STORE: string = 'fb_card';
|
||||
const CACHE_KEY: string = 'home_json';
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
缓存(断网时显示上次成功取到的数据)
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
async function saveCache(ctx: common.Context, payload: string): Promise<void> {
|
||||
const CACHE_STORE: string = 'fb_card';
|
||||
const CACHE_PREFIX: string = 'd_';
|
||||
|
||||
async function saveCache(ctx: common.Context, iso: string, payload: string): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE);
|
||||
await store.put(CACHE_KEY, payload);
|
||||
await store.put(CACHE_PREFIX + iso, payload);
|
||||
// ⚠️ 必须 flush:卡片进程随时可能被回收,不 flush 等于没写
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
@@ -174,28 +181,168 @@ async function saveCache(ctx: common.Context, payload: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCache(ctx: common.Context): Promise<string> {
|
||||
async function loadCache(ctx: common.Context, iso: string): Promise<string> {
|
||||
try {
|
||||
const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE);
|
||||
const v: preferences.ValueType = await store.get(CACHE_KEY, '');
|
||||
const v: preferences.ValueType = await store.get(CACHE_PREFIX + iso, '');
|
||||
return typeof v === 'string' ? v : '';
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
组装
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/** 这一天第几分钟(`HH:mm` → 分钟;认不出返回 -1) */
|
||||
function minutesOf(hhmm: string): number {
|
||||
return FbTimelineUtil.minutesOf(hhmm);
|
||||
}
|
||||
|
||||
/** 一条日程 → 卡片行 */
|
||||
function rowOf(e: FbCalEvent, idx: number, nowMin: number, isToday: boolean): FbCardRow {
|
||||
const r: FbCardRow = new FbCardRow();
|
||||
r.key = 'r' + idx.toString() + '_' + e.kind + '_' + e.id.toString() + '_' + e.time;
|
||||
const allDay: boolean = e.allDay || minutesOf(e.time) < 0;
|
||||
r.time = allDay ? '全天' : e.time;
|
||||
r.endTime = allDay ? '' : e.endTime;
|
||||
// 标题为空时用日历本名兜底(农历/节气这类衍生条目),别显示"(无标题)"
|
||||
r.title = e.title !== '' ? e.title : (e.calName !== '' ? e.calName : '未命名日程');
|
||||
r.color = e.color;
|
||||
r.calName = e.calName;
|
||||
if (!allDay && isToday) {
|
||||
const s: number = minutesOf(e.time);
|
||||
let en: number = minutesOf(e.endTime);
|
||||
if (en <= s) {
|
||||
en = s + 60;
|
||||
}
|
||||
r.isNow = nowMin >= s && nowMin < en;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 全天在前、其余按开始时间升序(时间相同按标题,保证顺序稳定) */
|
||||
function byDayOrder(a: FbCalEvent, b: FbCalEvent): number {
|
||||
const sa: number = a.allDay || minutesOf(a.time) < 0 ? -1 : minutesOf(a.time);
|
||||
const sb: number = b.allDay || minutesOf(b.time) < 0 ? -1 : minutesOf(b.time);
|
||||
if (sa !== sb) {
|
||||
return sa - sb;
|
||||
}
|
||||
return a.title.localeCompare(b.title);
|
||||
}
|
||||
|
||||
/** 把接口行铺进卡片数据 */
|
||||
function applyRows(rows: FbCalEvent[], out: FbCardData, now: Date): void {
|
||||
const nowMin: number = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
// 1) 农历行 → 抬头那行小字(它的信息量就在"九月初一"这几个字,不必再占一行列表)
|
||||
const events: FbCalEvent[] = [];
|
||||
for (const e of rows) {
|
||||
if (e.kind === 'lunar') {
|
||||
if (out.lunarText === '') {
|
||||
out.lunarText = e.title;
|
||||
}
|
||||
} else {
|
||||
events.push(e);
|
||||
}
|
||||
}
|
||||
events.sort(byDayOrder);
|
||||
|
||||
// 2) 行式列表(4×4 列表模式 / 6×4)
|
||||
const list: FbCardRow[] = [];
|
||||
const allDay: FbCardRow[] = [];
|
||||
let idx: number = 0;
|
||||
for (const e of events) {
|
||||
const r: FbCardRow = rowOf(e, idx, nowMin, out.isToday);
|
||||
list.push(r);
|
||||
if (r.time === '全天') {
|
||||
allDay.push(r);
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
out.listJson = JSON.stringify(list);
|
||||
out.allDayJson = JSON.stringify(allDay);
|
||||
out.dayCount = list.length;
|
||||
|
||||
// 3) 时间轴(只放"带时间"的条目;全天另画在顶部 —— 与 `FbTimelineUtil` 的约定一致)
|
||||
const tl: FbTimelineData = FbTimelineUtil.build(events, out.isToday, nowMin);
|
||||
const blocks: FbCardBlock[] = [];
|
||||
for (const b of tl.blocks as FbTimeBlock[]) {
|
||||
const cb: FbCardBlock = new FbCardBlock();
|
||||
cb.key = b.key;
|
||||
cb.title = b.title;
|
||||
cb.timeText = b.timeText;
|
||||
cb.color = b.color;
|
||||
cb.topRatio = b.topRatio;
|
||||
cb.heightRatio = b.heightRatio;
|
||||
cb.groupIndex = b.groupIndex;
|
||||
cb.laneIndex = b.laneIndex;
|
||||
cb.isNow = b.isNow;
|
||||
blocks.push(cb);
|
||||
}
|
||||
out.timelineJson = JSON.stringify(blocks);
|
||||
const range: FbViewRange = FbTimelineUtil.viewRange(tl.blocks);
|
||||
out.viewStartHour = range.startHour;
|
||||
out.viewEndHour = range.endHour;
|
||||
|
||||
// 4) 「接下来两条」(2×4 右半):今天取"还没开始的",别的日期取当天头两条
|
||||
const upcoming: FbCardRow[] = [];
|
||||
for (const r of list) {
|
||||
if (upcoming.length >= 2) {
|
||||
break;
|
||||
}
|
||||
if (r.time === '全天') {
|
||||
continue;
|
||||
}
|
||||
if (out.isToday && minutesOf(r.time) < nowMin) {
|
||||
continue;
|
||||
}
|
||||
upcoming.push(r);
|
||||
}
|
||||
// 今天已经没有"还没开始的"了 ⇒ 退回头两条有时间的(别让右半空着说"今日日程已结束")
|
||||
if (upcoming.length === 0) {
|
||||
for (const r of list) {
|
||||
if (upcoming.length >= 2) {
|
||||
break;
|
||||
}
|
||||
if (r.time !== '全天') {
|
||||
upcoming.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.upcomingJson = JSON.stringify(upcoming);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装一份卡片数据(异步)。
|
||||
*
|
||||
* @param dayOffset 相对今天的偏移(卡片翻页:负=过去,0=今天,正=未来)
|
||||
*
|
||||
* ⚠️ **永远不抛错**:卡片进程抛出去只会让桌面显示一张空白卡。
|
||||
* 所有失败都翻译成 `state` + `hint`,由卡片自己画一句人话。
|
||||
*/
|
||||
export async function fbCardBuild(ctx: common.Context): Promise<FbCardData> {
|
||||
export async function fbCardBuild(ctx: common.Context, dayOffset: number): Promise<FbCardData> {
|
||||
const out: FbCardData = new FbCardData();
|
||||
const now: Date = new Date();
|
||||
out.updatedAt = clockText();
|
||||
out.nowLabel = out.updatedAt;
|
||||
out.dayOffset = dayOffset;
|
||||
out.isToday = dayOffset === 0;
|
||||
out.nowRatio = out.isToday ? (now.getHours() * 60 + now.getMinutes()) / 1440 : -1;
|
||||
|
||||
const base: Date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset);
|
||||
out.selectedDate = base.getTime();
|
||||
out.dateMd = (base.getMonth() + 1).toString() + '月' + base.getDate().toString() + '日';
|
||||
out.weekday = FULL_WEEK[base.getDay()];
|
||||
const prefix: string = dayOffset === 0 ? '今天 ' : (dayOffset === 1 ? '明天 ' : (dayOffset === -1 ? '昨天 ' : ''));
|
||||
out.dateText = prefix + out.dateMd + ' ' + SHORT_WEEK[base.getDay()];
|
||||
|
||||
try {
|
||||
await FB_SERVER_STORE.init(ctx);
|
||||
await FB_TOKEN_STORE.init(ctx);
|
||||
// 显示方式(时间轴 / 列表)与应用内设置联动 —— 与 SyncCalendar 的 displayStyle 同一个思路
|
||||
await FB_CAL_SETTINGS.init(ctx);
|
||||
out.displayStyle = FB_CAL_SETTINGS.getStyle();
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '卡片环境预加载失败:%{public}s', JSON.stringify(err));
|
||||
}
|
||||
@@ -210,21 +357,31 @@ export async function fbCardBuild(ctx: common.Context): Promise<FbCardData> {
|
||||
out.hint = '先在「家书」里登录';
|
||||
return out;
|
||||
}
|
||||
/* 🔴 `authProbe: true`:卡片是**后台**刷新,401 时**绝不能**触发全局"踢回登录页"
|
||||
(那个回调会把令牌清掉 —— 用户在应用里用得好好的,桌面卡片一刷新把他踢下线,
|
||||
这是最说不清的 bug)。这里自己处理"登录失效"的提示即可。
|
||||
⚠️ 代价:`authProbe` 会跳过 `fbApi` 的"自动补令牌",所以**必须显式传 token**。 */
|
||||
const r: FbApiResult = await fbApi('/api/v1/home', { token: token, authProbe: true });
|
||||
|
||||
const iso: string = isoOf(base);
|
||||
/* 只查**这一天**(服务端的 `from`/`to` 都是闭区间)。
|
||||
⚠️ 查询参数要显式声明成 `Record<string, string>` 再赋值:
|
||||
ArkTS 不允许"字面量直接塞进 `Record`",会报 arkts-no-untyped-obj-literals。 */
|
||||
const query: Record<string, string> = {};
|
||||
query['from'] = iso;
|
||||
query['to'] = iso;
|
||||
/* `authProbe: true` ⇒ 401 时**不触发全局"踢回登录页"**(见文件头约束 ③)。
|
||||
⚠️ 代价:它会跳过 `fbApi` 的"自动补令牌",所以**必须显式传 token**。 */
|
||||
const r: FbApiResult = await fbApi('/api/v1/calendar', {
|
||||
token: token,
|
||||
authProbe: true,
|
||||
query: query
|
||||
});
|
||||
if (r.ok) {
|
||||
applyHome(parseHome(r.data), out);
|
||||
applyRows(parseCalendar(r.data), out, now);
|
||||
out.state = 'ok';
|
||||
out.hint = '';
|
||||
await saveCache(ctx, r.data);
|
||||
await saveCache(ctx, iso, r.data);
|
||||
return out;
|
||||
}
|
||||
const cached: string = await loadCache(ctx);
|
||||
const cached: string = await loadCache(ctx, iso);
|
||||
if (cached !== '') {
|
||||
applyHome(parseHome(cached), out);
|
||||
applyRows(parseCalendar(cached), out, now);
|
||||
out.state = 'offline';
|
||||
out.hint = r.status === 401 ? '登录已失效,请重新登录' : '离线 · 显示上次的数据';
|
||||
return out;
|
||||
@@ -236,10 +393,96 @@ export async function fbCardBuild(ctx: common.Context): Promise<FbCardData> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 供卡片页判断"这条日程是不是第一行"之类的**纯函数**已经不需要了 ——
|
||||
* 卡片直接按数组顺序画。这里只留一个给测试/守卫用的口径函数:
|
||||
* 卡片上**最多**显示几条日程(大小卡共用同一个上限,避免"两卡数字不一致")。
|
||||
* 时间轴「行」列表:把比例形式的色块换算成 vp 高度,供视图直接 `ForEach`。
|
||||
*
|
||||
* 为什么不在卡片里算:卡片页的 ArkTS 是受限子集、也不好写测试;
|
||||
* 这里纯函数、只吃标量,守卫与反向验证都能直接盯。
|
||||
*
|
||||
* @param blocks `FbCardBlock[]`(已按 topRatio 升序)
|
||||
* @param startHour 视窗起点小时
|
||||
* @param hourVp 1 小时 = 多少 vp
|
||||
*/
|
||||
export function fbCardMaxEvents(): number {
|
||||
return 6;
|
||||
export function fbCardTlLines(blocks: FbCardBlock[], startHour: number, hourVp: number): FbCardTlLine[] {
|
||||
const lines: FbCardTlLine[] = [];
|
||||
// 1) 按冲突组归并(组号在 `FbTimelineUtil.build` 里已算好)
|
||||
const order: number[] = [];
|
||||
const groups: Map<number, FbCardBlock[]> = new Map<number, FbCardBlock[]>();
|
||||
for (const b of blocks) {
|
||||
const arr: FbCardBlock[] | undefined = groups.get(b.groupIndex);
|
||||
if (arr === undefined) {
|
||||
groups.set(b.groupIndex, [b]);
|
||||
order.push(b.groupIndex);
|
||||
} else {
|
||||
arr.push(b);
|
||||
}
|
||||
}
|
||||
order.sort((a: number, b: number): number => a - b);
|
||||
|
||||
// 2) 逐组产出:组前补空白 + 组内按列并排
|
||||
// ⚠️ 游标从**视窗起点**开始(不是 0 点):8 点视窗下第一组前面的 0~8 点空白
|
||||
// 不该画进卡片,否则时间轴一开始就有一大段空格子,看着像"数据算错了"。
|
||||
let cursor: number = startHour; // 已画到"当天第几小时"(浮点)
|
||||
for (const gi of order) {
|
||||
const bs: FbCardBlock[] = groups.get(gi) ?? [];
|
||||
if (bs.length === 0) {
|
||||
continue;
|
||||
}
|
||||
let top: number = 1;
|
||||
let bot: number = 0;
|
||||
for (const b of bs) {
|
||||
const t: number = b.topRatio * 24;
|
||||
const e: number = (b.topRatio + b.heightRatio) * 24;
|
||||
if (t < top) {
|
||||
top = t;
|
||||
}
|
||||
if (e > bot) {
|
||||
bot = e;
|
||||
}
|
||||
}
|
||||
if (top < cursor) {
|
||||
top = cursor;
|
||||
}
|
||||
const gap: number = (top - cursor) * hourVp;
|
||||
if (gap > 0.5) {
|
||||
const blank: FbCardTlLine = new FbCardTlLine();
|
||||
blank.key = 'gap' + gi.toString();
|
||||
blank.blankVp = gap;
|
||||
lines.push(blank);
|
||||
}
|
||||
const g: FbCardTlGroup = new FbCardTlGroup();
|
||||
g.key = 'g' + gi.toString();
|
||||
g.heightVp = Math.max((bot - top) * hourVp, 14);
|
||||
// 列号可能不连续 ⇒ 用"出现的列号升序"重排,保证 Row 内 layoutWeight 均匀
|
||||
const laneIdx: number[] = [];
|
||||
for (const b of bs) {
|
||||
if (laneIdx.indexOf(b.laneIndex) < 0) {
|
||||
laneIdx.push(b.laneIndex);
|
||||
}
|
||||
}
|
||||
laneIdx.sort((a: number, b: number): number => a - b);
|
||||
for (const li of laneIdx) {
|
||||
const lane: FbCardTlBlock[] = [];
|
||||
for (const b of bs) {
|
||||
if (b.laneIndex !== li) {
|
||||
continue;
|
||||
}
|
||||
const cb: FbCardTlBlock = new FbCardTlBlock();
|
||||
cb.key = b.key;
|
||||
cb.title = b.title;
|
||||
cb.timeText = b.timeText;
|
||||
cb.color = b.color;
|
||||
cb.isNow = b.isNow;
|
||||
cb.padTopVp = Math.max((b.topRatio * 24 - top) * hourVp, 0);
|
||||
cb.heightVp = Math.max(b.heightRatio * 24 * hourVp, 14);
|
||||
lane.push(cb);
|
||||
}
|
||||
g.lanes.push(lane);
|
||||
}
|
||||
const line: FbCardTlLine = new FbCardTlLine();
|
||||
line.key = g.key;
|
||||
line.group = g;
|
||||
lines.push(line);
|
||||
cursor = bot;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 桌面卡片的「每张卡」状态(鸿蒙端)
|
||||
*
|
||||
* 照 `SyncCalendar/entry/src/main/ets/common/CardDataService.ets` 的那一套:
|
||||
* · `registerForm / unregisterForm` —— 记住桌面上有哪些卡(推送时要知道推给谁);
|
||||
* · `getDayOffset / setDayOffset` —— **每张卡各自翻到哪一天**(卡片不能滚到别的日子,
|
||||
* 只能靠翻页;两张卡翻到不同日期是正常的);
|
||||
* · `pushAll` —— 应用内改完日程后,让桌面上的卡立刻跟上。
|
||||
*
|
||||
* 🔴 与 SyncCalendar 的**一处有意加强**:
|
||||
* 它的翻页偏移只放在**内存 Map** 里(进程一被回收就丢),于是系统每 30 分钟的
|
||||
* 定时刷新会把用户刚翻到的"下周三"悄悄拽回"今天"。
|
||||
* 这里落进 `preferences`:FormExtensionAbility 的进程本来就可能随时被回收,
|
||||
* "翻页"这种用户意图必须能跨进程活下来,否则表现就是"翻着翻着自己跳回去了"。
|
||||
*
|
||||
* ⚠️ 偏移量做**夹取**(±366 天):`onFormEvent` 的 message 是外部输入,
|
||||
* 连续点 ‹ 几百次也不该把它算成一个荒唐的年份(`new Date(y, m, d + 99999)`
|
||||
* 在部分设备上会算出 Invalid Date,卡片直接白掉)。
|
||||
*/
|
||||
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { formBindingData, formProvider } from '@kit.FormKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { FbCardData, fbCardBuild } from './FbCardData';
|
||||
|
||||
const DOMAIN: number = 0x0000;
|
||||
const TAG: string = 'FbCardForms';
|
||||
|
||||
const STORE_NAME: string = 'fb_card_forms';
|
||||
const K_IDS: string = 'form_ids';
|
||||
const K_OFF: string = 'day_offsets';
|
||||
|
||||
/** 翻页最多到前后一年(防外部输入把日期算成 Invalid Date) */
|
||||
const MAX_OFFSET: number = 366;
|
||||
|
||||
/** 条目分隔符用 `;`、键值用 `=`:formId 是数字串,不会撞这两个字符 */
|
||||
const SEP_ITEM: string = ';';
|
||||
const SEP_KV: string = '=';
|
||||
|
||||
class FbCardFormsImpl {
|
||||
private async store(ctx: common.Context): Promise<preferences.Preferences> {
|
||||
return preferences.getPreferences(ctx, STORE_NAME);
|
||||
}
|
||||
|
||||
private async readStr(ctx: common.Context, key: string): Promise<string> {
|
||||
try {
|
||||
const p: preferences.Preferences = await this.store(ctx);
|
||||
const v: preferences.ValueType = await p.get(key, '');
|
||||
return typeof v === 'string' ? v : '';
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private async writeStr(ctx: common.Context, key: string, val: string): Promise<void> {
|
||||
try {
|
||||
const p: preferences.Preferences = await this.store(ctx);
|
||||
await p.put(key, val);
|
||||
// 卡片进程随时可能被回收:不 flush 等于没写
|
||||
await p.flush();
|
||||
} catch (err) {
|
||||
const e: BusinessError = err as BusinessError;
|
||||
hilog.warn(DOMAIN, TAG, '写卡片状态失败(%{public}s):%{public}s', key, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 偏移量(formId → 相对今天的天数)---- */
|
||||
|
||||
private parseOffsets(raw: string): Map<string, number> {
|
||||
const map: Map<string, number> = new Map<string, number>();
|
||||
for (const item of raw.split(SEP_ITEM)) {
|
||||
if (item === '') {
|
||||
continue;
|
||||
}
|
||||
const at: number = item.indexOf(SEP_KV);
|
||||
if (at <= 0) {
|
||||
continue;
|
||||
}
|
||||
const id: string = item.substring(0, at);
|
||||
const off: number = parseInt(item.substring(at + 1), 10);
|
||||
if (!isNaN(off)) {
|
||||
map.set(id, off);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private joinOffsets(map: Map<string, number>): string {
|
||||
const parts: string[] = [];
|
||||
map.forEach((off: number, id: string): void => {
|
||||
parts.push(id + SEP_KV + off.toString());
|
||||
});
|
||||
return parts.join(SEP_ITEM);
|
||||
}
|
||||
|
||||
/** 读某张卡翻到第几天(读不到按今天 = 0) */
|
||||
async getOffset(ctx: common.Context, formId: string): Promise<number> {
|
||||
const map: Map<string, number> = this.parseOffsets(await this.readStr(ctx, K_OFF));
|
||||
const v: number | undefined = map.get(formId);
|
||||
return v === undefined ? 0 : v;
|
||||
}
|
||||
|
||||
/** 写某张卡的偏移(已夹取到 ±366) */
|
||||
async setOffset(ctx: common.Context, formId: string, off: number): Promise<number> {
|
||||
let v: number = Math.round(off);
|
||||
if (v > MAX_OFFSET) {
|
||||
v = MAX_OFFSET;
|
||||
}
|
||||
if (v < -MAX_OFFSET) {
|
||||
v = -MAX_OFFSET;
|
||||
}
|
||||
const map: Map<string, number> = this.parseOffsets(await this.readStr(ctx, K_OFF));
|
||||
map.set(formId, v);
|
||||
await this.writeStr(ctx, K_OFF, this.joinOffsets(map));
|
||||
return v;
|
||||
}
|
||||
|
||||
async clearOffset(ctx: common.Context, formId: string): Promise<void> {
|
||||
const map: Map<string, number> = this.parseOffsets(await this.readStr(ctx, K_OFF));
|
||||
if (map.delete(formId)) {
|
||||
await this.writeStr(ctx, K_OFF, this.joinOffsets(map));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 卡片注册表 ---- */
|
||||
|
||||
private async ids(ctx: common.Context): Promise<string[]> {
|
||||
const raw: string = await this.readStr(ctx, K_IDS);
|
||||
const out: string[] = [];
|
||||
for (const id of raw.split(SEP_ITEM)) {
|
||||
if (id !== '' && out.indexOf(id) < 0) {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private async saveIds(ctx: common.Context, list: string[]): Promise<void> {
|
||||
await this.writeStr(ctx, K_IDS, list.join(SEP_ITEM));
|
||||
}
|
||||
|
||||
async register(ctx: common.Context, formId: string): Promise<void> {
|
||||
if (formId === '') {
|
||||
return;
|
||||
}
|
||||
const list: string[] = await this.ids(ctx);
|
||||
if (list.indexOf(formId) < 0) {
|
||||
list.push(formId);
|
||||
await this.saveIds(ctx, list);
|
||||
}
|
||||
}
|
||||
|
||||
async unregister(ctx: common.Context, formId: string): Promise<void> {
|
||||
const list: string[] = (await this.ids(ctx)).filter((id: string): boolean => id !== formId);
|
||||
await this.saveIds(ctx, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用内日程变化后刷新**所有**已添加的卡。
|
||||
*
|
||||
* ⚠️ 按偏移量分组构建:两张卡可能翻在不同日期,逐张构建会重复打同一个接口。
|
||||
* ⚠️ 推失败的 formId 直接**注销**(卡片多半已被用户删掉),否则每刷一次都白推一遍。
|
||||
*/
|
||||
async pushAll(ctx: common.Context): Promise<void> {
|
||||
const list: string[] = await this.ids(ctx);
|
||||
if (list.length === 0) {
|
||||
return;
|
||||
}
|
||||
const cache: Map<number, formBindingData.FormBindingData> =
|
||||
new Map<number, formBindingData.FormBindingData>();
|
||||
const stale: string[] = [];
|
||||
for (const formId of list) {
|
||||
const off: number = await this.getOffset(ctx, formId);
|
||||
let binding: formBindingData.FormBindingData | undefined = cache.get(off);
|
||||
if (binding === undefined) {
|
||||
const data: FbCardData = await fbCardBuild(ctx, off);
|
||||
binding = formBindingData.createFormBindingData(data);
|
||||
cache.set(off, binding);
|
||||
}
|
||||
try {
|
||||
await formProvider.updateForm(formId, binding);
|
||||
} catch (err) {
|
||||
const e: BusinessError = err as BusinessError;
|
||||
hilog.warn(DOMAIN, TAG, '卡片 %{public}s 推送失败(已注销):%{public}s', formId, e.message);
|
||||
stale.push(formId);
|
||||
}
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
const keep: string[] = list.filter((id: string): boolean => stale.indexOf(id) < 0);
|
||||
await this.saveIds(ctx, keep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const FB_CARD_FORMS: FbCardFormsImpl = new FbCardFormsImpl();
|
||||
Reference in New Issue
Block a user