2026-10-20 15:27:32 +08:00
|
|
|
|
/**
|
2026-10-20 21:59:23 +08:00
|
|
|
|
* 家书 FamilyBook · **日程**桌面卡片数据(鸿蒙端)
|
2026-10-20 15:27:32 +08:00
|
|
|
|
*
|
2026-10-20 21:59:23 +08:00
|
|
|
|
* 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」
|
|
|
|
|
|
* ⇒ 结构照 `D:\Yongquan\code\SyncCalendar` 的 `CardDataService` + 四块卡:
|
|
|
|
|
|
* · 2×2 日期 / 星期农历 / 当天条数;
|
|
|
|
|
|
* · 2×4 左边同 2×2,右边「今天接下来两条」;
|
|
|
|
|
|
* · 4×4 日期翻页条 + 当天日程(时间轴 / 列表,跟应用内设置联动);
|
|
|
|
|
|
* · 6×4 同 4×4,横向更宽。
|
2026-10-20 15:27:32 +08:00
|
|
|
|
*
|
2026-10-20 21:59:23 +08:00
|
|
|
|
* 与 SyncCalendar 的**两处有意差异**(都是家书的数据形状决定的,不照抄):
|
|
|
|
|
|
* ① 它读端上本地库,家书读服务端 `GET /api/v1/calendar?from=&to=`;
|
|
|
|
|
|
* ② 它的数据里没有"衍生条目",家书的 `/calendar` 会给农历 / 节气 / 节假日
|
|
|
|
|
|
* ⇒ 农历行抽成 `lunarText`(抬头那一行),其余衍生条目照常进列表。
|
2026-10-20 15:27:32 +08:00
|
|
|
|
*
|
2026-10-20 21:59:23 +08:00
|
|
|
|
* 🔴 三条硬约束(沿用上一版卡片,别丢):
|
|
|
|
|
|
* ① 卡片进程生命周期回调**只活约 10 秒** ⇒ 回调里不同步做长任务;
|
|
|
|
|
|
* ② `FormBindingData` **只认标量** ⇒ 数组一律 `JSON.stringify` 成字符串;
|
|
|
|
|
|
* ③ 卡片是**后台**刷新,请求必须 `authProbe: true` 且显式带 token ——
|
|
|
|
|
|
* 否则一次 401 会触发全局"踢回登录页",用户在应用里用得好好的却被卡片踢下线。
|
2026-10-20 15:27:32 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
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';
|
2026-10-20 21:59:23 +08:00
|
|
|
|
import { FB_CAL_SETTINGS } from './FbCalSettings';
|
2026-10-20 15:27:32 +08:00
|
|
|
|
import { fbApi, FbApiResult } from './FbHttp';
|
2026-10-20 21:59:23 +08:00
|
|
|
|
import { parseCalendar } from './FbParse';
|
|
|
|
|
|
import { FbCalEvent } from '../model/Types';
|
|
|
|
|
|
import { FbTimelineUtil, FbTimelineData, FbTimeBlock, FbViewRange } from '../common/FbTimeline';
|
2026-10-20 15:27:32 +08:00
|
|
|
|
|
|
|
|
|
|
const DOMAIN: number = 0x0000;
|
|
|
|
|
|
const TAG: string = 'FbCard';
|
|
|
|
|
|
|
2026-10-20 21:59:23 +08:00
|
|
|
|
/** 卡片上的一行日程(扁平标量,卡片侧 `JSON.parse` 后 ForEach) */
|
|
|
|
|
|
export class FbCardRow {
|
|
|
|
|
|
/** ForEach 的 key(**必须唯一**:同名两条只渲染一条,踩过) */
|
2026-10-20 15:27:32 +08:00
|
|
|
|
key: string = '';
|
|
|
|
|
|
/** `09:00` / `全天` */
|
|
|
|
|
|
time: string = '';
|
2026-10-20 21:59:23 +08:00
|
|
|
|
/** `10:30`(全天为空) */
|
|
|
|
|
|
endTime: string = '';
|
2026-10-20 15:27:32 +08:00
|
|
|
|
title: string = '';
|
2026-10-20 21:59:23 +08:00
|
|
|
|
/** 所属日历本色标(左侧色条) */
|
2026-10-20 15:27:32 +08:00
|
|
|
|
color: string = '#A84A28';
|
2026-10-20 21:59:23 +08:00
|
|
|
|
/** 所属日历本名(4×4 列表右侧小字) */
|
|
|
|
|
|
calName: string = '';
|
|
|
|
|
|
/** 正在进行中(仅"今天"的有时间日程) */
|
|
|
|
|
|
isNow: boolean = false;
|
2026-10-20 15:27:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 21:59:23 +08:00
|
|
|
|
/** 时间轴上的一块(比例形式;视图侧按小时高度换算成 vp) */
|
|
|
|
|
|
export class FbCardBlock {
|
2026-10-20 15:27:32 +08:00
|
|
|
|
key: string = '';
|
|
|
|
|
|
title: string = '';
|
2026-10-20 21:59:23 +08:00
|
|
|
|
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;
|
2026-10-20 15:27:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 卡片整体数据。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ **字段名就是 `LocalStorage` 的键名**(卡片页 `@LocalStorageProp('xxx')`),
|
2026-10-20 21:59:23 +08:00
|
|
|
|
* 两侧改名必须同步 —— 改一边的表现是"卡片永远显示默认值",编译不报错。
|
2026-10-20 15:27:32 +08:00
|
|
|
|
*/
|
|
|
|
|
|
export class FbCardData {
|
2026-10-20 21:59:23 +08:00
|
|
|
|
/** `ok` / `login` / `noserver` / `offline` / `loading` */
|
2026-10-20 15:27:32 +08:00
|
|
|
|
state: string = 'loading';
|
|
|
|
|
|
/** 一行小字提示(未登录 / 离线 / 出错时给用户一个说法) */
|
|
|
|
|
|
hint: string = '';
|
|
|
|
|
|
/** `15:40 更新` */
|
|
|
|
|
|
updatedAt: string = '';
|
2026-10-20 21:59:23 +08:00
|
|
|
|
/** `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 = '';
|
2026-10-20 15:27:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 两位补零 */
|
|
|
|
|
|
function pad2(v: number): string {
|
|
|
|
|
|
return v < 10 ? '0' + v.toString() : v.toString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 21:59:23 +08:00
|
|
|
|
/** 当前时刻 `HH:mm` */
|
2026-10-20 15:27:32 +08:00
|
|
|
|
function clockText(): string {
|
|
|
|
|
|
const d: Date = new Date();
|
|
|
|
|
|
return pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 21:59:23 +08:00
|
|
|
|
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());
|
2026-10-20 15:27:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 21:59:23 +08:00
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
|
|
|
|
缓存(断网时显示上次成功取到的数据)
|
|
|
|
|
|
══════════════════════════════════════════════════════════════ */
|
2026-10-20 15:27:32 +08:00
|
|
|
|
|
2026-10-20 21:59:23 +08:00
|
|
|
|
const CACHE_STORE: string = 'fb_card';
|
|
|
|
|
|
const CACHE_PREFIX: string = 'd_';
|
|
|
|
|
|
|
|
|
|
|
|
async function saveCache(ctx: common.Context, iso: string, payload: string): Promise<void> {
|
2026-10-20 15:27:32 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE);
|
2026-10-20 21:59:23 +08:00
|
|
|
|
await store.put(CACHE_PREFIX + iso, payload);
|
2026-10-20 15:27:32 +08:00
|
|
|
|
// ⚠️ 必须 flush:卡片进程随时可能被回收,不 flush 等于没写
|
|
|
|
|
|
await store.flush();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const e: BusinessError = err as BusinessError;
|
|
|
|
|
|
hilog.warn(DOMAIN, TAG, '卡片缓存写入失败:%{public}s', e.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 21:59:23 +08:00
|
|
|
|
async function loadCache(ctx: common.Context, iso: string): Promise<string> {
|
2026-10-20 15:27:32 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE);
|
2026-10-20 21:59:23 +08:00
|
|
|
|
const v: preferences.ValueType = await store.get(CACHE_PREFIX + iso, '');
|
2026-10-20 15:27:32 +08:00
|
|
|
|
return typeof v === 'string' ? v : '';
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 21:59:23 +08:00
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
|
|
|
|
组装
|
|
|
|
|
|
══════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
|
|
|
|
|
|
/** 这一天第几分钟(`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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 15:27:32 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 组装一份卡片数据(异步)。
|
|
|
|
|
|
*
|
2026-10-20 21:59:23 +08:00
|
|
|
|
* @param dayOffset 相对今天的偏移(卡片翻页:负=过去,0=今天,正=未来)
|
|
|
|
|
|
*
|
2026-10-20 15:27:32 +08:00
|
|
|
|
* ⚠️ **永远不抛错**:卡片进程抛出去只会让桌面显示一张空白卡。
|
|
|
|
|
|
*/
|
2026-10-20 21:59:23 +08:00
|
|
|
|
export async function fbCardBuild(ctx: common.Context, dayOffset: number): Promise<FbCardData> {
|
2026-10-20 15:27:32 +08:00
|
|
|
|
const out: FbCardData = new FbCardData();
|
2026-10-20 21:59:23 +08:00
|
|
|
|
const now: Date = new Date();
|
2026-10-20 15:27:32 +08:00
|
|
|
|
out.updatedAt = clockText();
|
2026-10-20 21:59:23 +08:00
|
|
|
|
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()];
|
|
|
|
|
|
|
2026-10-20 15:27:32 +08:00
|
|
|
|
try {
|
|
|
|
|
|
await FB_SERVER_STORE.init(ctx);
|
|
|
|
|
|
await FB_TOKEN_STORE.init(ctx);
|
2026-10-20 21:59:23 +08:00
|
|
|
|
// 显示方式(时间轴 / 列表)与应用内设置联动 —— 与 SyncCalendar 的 displayStyle 同一个思路
|
|
|
|
|
|
await FB_CAL_SETTINGS.init(ctx);
|
|
|
|
|
|
out.displayStyle = FB_CAL_SETTINGS.getStyle();
|
2026-10-20 15:27:32 +08:00
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
2026-10-20 21:59:23 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
});
|
2026-10-20 15:27:32 +08:00
|
|
|
|
if (r.ok) {
|
2026-10-20 21:59:23 +08:00
|
|
|
|
applyRows(parseCalendar(r.data), out, now);
|
2026-10-20 15:27:32 +08:00
|
|
|
|
out.state = 'ok';
|
|
|
|
|
|
out.hint = '';
|
2026-10-20 21:59:23 +08:00
|
|
|
|
await saveCache(ctx, iso, r.data);
|
2026-10-20 15:27:32 +08:00
|
|
|
|
return out;
|
|
|
|
|
|
}
|
2026-10-20 21:59:23 +08:00
|
|
|
|
const cached: string = await loadCache(ctx, iso);
|
2026-10-20 15:27:32 +08:00
|
|
|
|
if (cached !== '') {
|
2026-10-20 21:59:23 +08:00
|
|
|
|
applyRows(parseCalendar(cached), out, now);
|
2026-10-20 15:27:32 +08:00
|
|
|
|
out.state = 'offline';
|
|
|
|
|
|
out.hint = r.status === 401 ? '登录已失效,请重新登录' : '离线 · 显示上次的数据';
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
out.state = 'offline';
|
|
|
|
|
|
out.hint = r.status === 401 ? '登录已失效,请重新登录'
|
|
|
|
|
|
: (r.error !== '' ? r.error : '连不上服务器');
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-20 21:59:23 +08:00
|
|
|
|
* 时间轴「行」列表:把比例形式的色块换算成 vp 高度,供视图直接 `ForEach`。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么不在卡片里算:卡片页的 ArkTS 是受限子集、也不好写测试;
|
|
|
|
|
|
* 这里纯函数、只吃标量,守卫与反向验证都能直接盯。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param blocks `FbCardBlock[]`(已按 topRatio 升序)
|
|
|
|
|
|
* @param startHour 视窗起点小时
|
|
|
|
|
|
* @param hourVp 1 小时 = 多少 vp
|
2026-10-20 15:27:32 +08:00
|
|
|
|
*/
|
2026-10-20 21:59:23 +08:00
|
|
|
|
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;
|
2026-10-20 15:27:32 +08:00
|
|
|
|
}
|