/** * 家书 FamilyBook · **日程**桌面卡片数据(鸿蒙端) * * 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」 * ⇒ 结构照 `D:\Yongquan\code\SyncCalendar` 的 `CardDataService` + 四块卡: * · 2×2 日期 / 星期农历 / 当天条数; * · 2×4 左边同 2×2,右边「今天接下来两条」; * · 4×4 日期翻页条 + 当天日程(时间轴 / 列表,跟应用内设置联动); * · 6×4 同 4×4,横向更宽。 * * 与 SyncCalendar 的**两处有意差异**(都是家书的数据形状决定的,不照抄): * ① 它读端上本地库,家书读服务端 `GET /api/v1/calendar?from=&to=`; * ② 它的数据里没有"衍生条目",家书的 `/calendar` 会给农历 / 节气 / 节假日 * ⇒ 农历行抽成 `lunarText`(抬头那一行),其余衍生条目照常进列表。 * * 🔴 三条硬约束(沿用上一版卡片,别丢): * ① 卡片进程生命周期回调**只活约 10 秒** ⇒ 回调里不同步做长任务; * ② `FormBindingData` **只认标量** ⇒ 数组一律 `JSON.stringify` 成字符串; * ③ 卡片是**后台**刷新,请求必须 `authProbe: true` 且显式带 token —— * 否则一次 401 会触发全局"踢回登录页",用户在应用里用得好好的却被卡片踢下线。 */ import { common } from '@kit.AbilityKit'; import { preferences } from '@kit.ArkData'; 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 { parseCalendar } from './FbParse'; import { FbCalEvent } from '../model/Types'; import { FbTimelineUtil, FbTimelineData, FbTimeBlock, FbViewRange } from '../common/FbTimeline'; const DOMAIN: number = 0x0000; const TAG: string = 'FbCard'; /** 卡片上的一行日程(扁平标量,卡片侧 `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; } /** 时间轴上的一块(比例形式;视图侧按小时高度换算成 vp) */ export class FbCardBlock { key: string = ''; title: string = ''; 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` */ state: string = 'loading'; /** 一行小字提示(未登录 / 离线 / 出错时给用户一个说法) */ 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 = ''; /** 🔴 v89:本次推送的唯一版本号(`Date.now()`)。 * ⚠️ 卡片 ForEach 的 key **必须**带上前缀:时间轴的冲突组号/空白 key * (`g0`、`gap0`)每份数据都从 0 重新编号 —— 翻到下一天时 key 一模一样, * ArkUI ForEach 认为"没变"直接复用旧组件 ⇒ 色块/空白高度全是旧一天的 * (真机实测:10/25、10/26 一直显示 10/24 的「工程管理硕士开题」)。 * 日期头、全天区能更新是因为它们的 key(事件 id)确实变了。 * ⚠️ 用 `Date.now()` 而不是 dayOffset:同一天也可能被定时刷新推新数据 * (日程改了),version 必须每次推送都不同才能逼 ForEach 全量重建。 */ version: string = ''; } /** 两位补零 */ function pad2(v: number): string { return v < 10 ? '0' + v.toString() : v.toString(); } /** 当前时刻 `HH:mm` */ function clockText(): string { const d: Date = new Date(); return pad2(d.getHours()) + ':' + pad2(d.getMinutes()); } 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()); } /* ══════════════════════════════════════════════════════════════ 缓存(断网时显示上次成功取到的数据) ══════════════════════════════════════════════════════════════ */ const CACHE_STORE: string = 'fb_card'; const CACHE_PREFIX: string = 'd_'; async function saveCache(ctx: common.Context, iso: string, payload: string): Promise { try { const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE); await store.put(CACHE_PREFIX + iso, payload); // ⚠️ 必须 flush:卡片进程随时可能被回收,不 flush 等于没写 await store.flush(); } catch (err) { const e: BusinessError = err as BusinessError; hilog.warn(DOMAIN, TAG, '卡片缓存写入失败:%{public}s', e.message); } } async function loadCache(ctx: common.Context, iso: string): Promise { try { const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE); 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=今天,正=未来) * * ⚠️ **永远不抛错**:卡片进程抛出去只会让桌面显示一张空白卡。 */ export async function fbCardBuild(ctx: common.Context, dayOffset: number): Promise { const out: FbCardData = new FbCardData(); const now: Date = new Date(); out.updatedAt = clockText(); out.nowLabel = out.updatedAt; out.dayOffset = dayOffset; /* 🔴 v89:每次推送一个新版本号,卡片侧 ForEach key 全部带上前缀 —— 不带的话翻页/刷新时 key 撞车,时间轴整层不重绘(文件头 version 注释)。 */ out.version = Date.now().toString(); 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)); } if (FB_SERVER_STORE.get() === '') { out.state = 'noserver'; out.hint = '先在「家书」里填写服务器地址'; return out; } const token: string = FB_TOKEN_STORE.get(); if (token === '') { out.state = 'login'; out.hint = '先在「家书」里登录'; return out; } const iso: string = isoOf(base); /* 只查**这一天**(服务端的 `from`/`to` 都是闭区间)。 ⚠️ 查询参数要显式声明成 `Record` 再赋值: ArkTS 不允许"字面量直接塞进 `Record`",会报 arkts-no-untyped-obj-literals。 */ const query: Record = {}; 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) { applyRows(parseCalendar(r.data), out, now); out.state = 'ok'; out.hint = ''; await saveCache(ctx, iso, r.data); return out; } const cached: string = await loadCache(ctx, iso); if (cached !== '') { applyRows(parseCalendar(cached), out, now); out.state = 'offline'; out.hint = r.status === 401 ? '登录已失效,请重新登录' : '离线 · 显示上次的数据'; return out; } out.state = 'offline'; out.hint = r.status === 401 ? '登录已失效,请重新登录' : (r.error !== '' ? r.error : '连不上服务器'); return out; } /** * 时间轴「行」列表:把比例形式的色块换算成 vp 高度,供视图直接 `ForEach`。 * * 为什么不在卡片里算:卡片页的 ArkTS 是受限子集、也不好写测试; * 这里纯函数、只吃标量,守卫与反向验证都能直接盯。 * * @param blocks `FbCardBlock[]`(已按 topRatio 升序) * @param startHour 视窗起点小时 * @param hourVp 1 小时 = 多少 vp */ export function fbCardTlLines(blocks: FbCardBlock[], startHour: number, hourVp: number): FbCardTlLine[] { const lines: FbCardTlLine[] = []; // 1) 按冲突组归并(组号在 `FbTimelineUtil.build` 里已算好) const order: number[] = []; const groups: Map = new Map(); 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; }