Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbCardData.ets
T

501 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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<void> {
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<string> {
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<FbCardData> {
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<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) {
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<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;
}