246 lines
9.0 KiB
Plaintext
246 lines
9.0 KiB
Plaintext
/**
|
||||
|
|
* 家书 FamilyBook · 桌面服务卡片数据(鸿蒙端)
|
|||
|
|
*
|
|||
|
|
* 用户 2026-10-20:「做几个和 syncCalendar 差不多的卡片。」
|
|||
|
|
* ⇒ 参照 `SyncCalendar` 的 `CardDataService`:卡片**不直连数据库**,
|
|||
|
|
* 而是把"卡片要显示的那几十个字段"先算成一份**扁平的**数据对象
|
|||
|
|
* (每个字段都是 string / number),再由 `formBindingData` 推给卡片渲染。
|
|||
|
|
*
|
|||
|
|
* 为什么不让卡片自己去请求接口:
|
|||
|
|
* ① 卡片的 ArkTS 是**受限子集**(不能随便 await、不能拿 Context 去读 preferences),
|
|||
|
|
* 它只认 `LocalStorage` 里那几个标量;
|
|||
|
|
* ② 逻辑放在这里 ⇒ 2×2 / 2×4 / 4×4 三种尺寸共用**同一份计算**,
|
|||
|
|
* 不会出现"大卡说 5 条日程、小卡说 3 条"这种自相矛盾。
|
|||
|
|
*
|
|||
|
|
* 🔴 数据来源只用 **`GET /api/v1/home` 一个接口**:
|
|||
|
|
* 它一次给齐 日期/星期/农历/日子标记/今日日程/统计/最近纪念日,
|
|||
|
|
* 与网页版首页**同源**(服务端 `home()` 的注释写明了这一点)。
|
|||
|
|
* 多打两个接口(calendar + days)只会让卡片刷新变慢、且口径可能与首页不一致。
|
|||
|
|
*
|
|||
|
|
* ⚠️ 令牌与服务器地址:
|
|||
|
|
* 卡片进程(FormExtensionAbility)与主应用**共享同一个沙箱**,
|
|||
|
|
* 所以直接复用 `FbTokenStore` / `FbServerStore` 的 preferences 即可,
|
|||
|
|
* **不要在卡片里另存一份**(那样用户在应用里"退出登录",卡片还带着老令牌)。
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
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 { fbApi, FbApiResult } from './FbHttp';
|
|||
|
|
import { parseHome } from './FbParse';
|
|||
|
|
import { FbCalEvent, FbHomeData, FbMark, FbRecentDay } from '../model/Types';
|
|||
|
|
|
|||
|
|
const DOMAIN: number = 0x0000;
|
|||
|
|
const TAG: string = 'FbCard';
|
|||
|
|
|
|||
|
|
/** 卡片上的一条日程(扁平化:卡片只认标量) */
|
|||
|
|
export class FbCardEvent {
|
|||
|
|
/** ForEach 的 key(**必须唯一**,否则同名的两条只渲染第一条) */
|
|||
|
|
key: string = '';
|
|||
|
|
/** `09:00` / `全天` */
|
|||
|
|
time: string = '';
|
|||
|
|
title: string = '';
|
|||
|
|
/** 日历本色标(直接当左侧色条用) */
|
|||
|
|
color: string = '#A84A28';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 卡片上的一条纪念日倒计时 */
|
|||
|
|
export class FbCardDay {
|
|||
|
|
key: string = '';
|
|||
|
|
title: string = '';
|
|||
|
|
/** 还有几天(0 = 就是今天) */
|
|||
|
|
days: number = 0;
|
|||
|
|
/** 7 天内 ⇒ 卡片上用品牌色强调 */
|
|||
|
|
soon: boolean = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 卡片整体数据。
|
|||
|
|
*
|
|||
|
|
* ⚠️ **字段名就是 `LocalStorage` 的键名**(卡片页 `@LocalStorageProp('xxx')`),
|
|||
|
|
* 两侧改名必须同步 —— 改一边的表现是"卡片永远显示默认值",不会报错。
|
|||
|
|
*/
|
|||
|
|
export class FbCardData {
|
|||
|
|
/**
|
|||
|
|
* 状态:`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 = '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 两位补零 */
|
|||
|
|
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());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 把接口载荷铺进卡片数据 */
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 缓存用的 preferences(卡片断网时显示上次成功的数据) */
|
|||
|
|
const CACHE_STORE: string = 'fb_card';
|
|||
|
|
const CACHE_KEY: string = 'home_json';
|
|||
|
|
|
|||
|
|
async function saveCache(ctx: common.Context, payload: string): Promise<void> {
|
|||
|
|
try {
|
|||
|
|
const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE);
|
|||
|
|
await store.put(CACHE_KEY, 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): Promise<string> {
|
|||
|
|
try {
|
|||
|
|
const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE);
|
|||
|
|
const v: preferences.ValueType = await store.get(CACHE_KEY, '');
|
|||
|
|
return typeof v === 'string' ? v : '';
|
|||
|
|
} catch (err) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 组装一份卡片数据(异步)。
|
|||
|
|
*
|
|||
|
|
* ⚠️ **永远不抛错**:卡片进程抛出去只会让桌面显示一张空白卡。
|
|||
|
|
* 所有失败都翻译成 `state` + `hint`,由卡片自己画一句人话。
|
|||
|
|
*/
|
|||
|
|
export async function fbCardBuild(ctx: common.Context): Promise<FbCardData> {
|
|||
|
|
const out: FbCardData = new FbCardData();
|
|||
|
|
out.updatedAt = clockText();
|
|||
|
|
try {
|
|||
|
|
await FB_SERVER_STORE.init(ctx);
|
|||
|
|
await FB_TOKEN_STORE.init(ctx);
|
|||
|
|
} 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;
|
|||
|
|
}
|
|||
|
|
/* 🔴 `authProbe: true`:卡片是**后台**刷新,401 时**绝不能**触发全局"踢回登录页"
|
|||
|
|
(那个回调会把令牌清掉 —— 用户在应用里用得好好的,桌面卡片一刷新把他踢下线,
|
|||
|
|
这是最说不清的 bug)。这里自己处理"登录失效"的提示即可。
|
|||
|
|
⚠️ 代价:`authProbe` 会跳过 `fbApi` 的"自动补令牌",所以**必须显式传 token**。 */
|
|||
|
|
const r: FbApiResult = await fbApi('/api/v1/home', { token: token, authProbe: true });
|
|||
|
|
if (r.ok) {
|
|||
|
|
applyHome(parseHome(r.data), out);
|
|||
|
|
out.state = 'ok';
|
|||
|
|
out.hint = '';
|
|||
|
|
await saveCache(ctx, r.data);
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
const cached: string = await loadCache(ctx);
|
|||
|
|
if (cached !== '') {
|
|||
|
|
applyHome(parseHome(cached), out);
|
|||
|
|
out.state = 'offline';
|
|||
|
|
out.hint = r.status === 401 ? '登录已失效,请重新登录' : '离线 · 显示上次的数据';
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
out.state = 'offline';
|
|||
|
|
out.hint = r.status === 401 ? '登录已失效,请重新登录'
|
|||
|
|
: (r.error !== '' ? r.error : '连不上服务器');
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 供卡片页判断"这条日程是不是第一行"之类的**纯函数**已经不需要了 ——
|
|||
|
|
* 卡片直接按数组顺序画。这里只留一个给测试/守卫用的口径函数:
|
|||
|
|
* 卡片上**最多**显示几条日程(大小卡共用同一个上限,避免"两卡数字不一致")。
|
|||
|
|
*/
|
|||
|
|
export function fbCardMaxEvents(): number {
|
|||
|
|
return 6;
|
|||
|
|
}
|