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

489 lines
18 KiB
Plaintext
Raw Normal View History

/**
* 家书 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 = '';
}
/** 两位补零 */
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;
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;
}