v65/v66:六个空壳模块接线 + 桌面服务卡片

用户 2026-10-20:
「做几个和 syncCalendar 差不多的卡片。另外其他的功能也逐步完成……旅程的
新建旅程啊,车辆管理、出行方式、节点设置……资源也点不进去……任务也点不
进去,也没法完成……纪念日、新增纪念日……财务的显示也有问题……笔记现在
也点不进去,记笔记也记不了。」

v65 —— 六个模块(任务 / 资源 / 纪念日 / 笔记 / 账本 / 旅程)原本是**同一套
空壳模板**复制出来的:`FbPrimaryButton` 没有 `onTap`、`ListItem` 没有
`onClick`、设置菜单的 `action` 全是空串。这一版把它们全部接上真实接口:

· 任务:新建 / 编辑(`FbTaskEditSheet` + `FbTaskRepo`)、列表可点、勾完成
  (走 `/tasks/<id>/status` —— 服务端 `PATCH` **显式忽略** status,走 Patch
  会得到"看起来成功的假成功")、按看板筛选
· 资源:新增 / 查看 / 改 / 删(`FbResourceSheet` + `FbResourceRepo`);
  过滤维度从**并不存在的**「证件 / 资料」改成按权限与归属
  (`resources` 表没有 category 列,那个筛选一直是筛空的)
· 纪念日:新增 / 编辑(`FbDayEditSheet` + `FbDayRepo`);派生生日
  (id=0、来自名册)只给提示不给编辑 —— 点开 `/days/0` 必然 404
· 笔记:记一条 / 打开 / 改(`FbNoteSheet` + `FbNoteRepo`),正文按 blocks 存
· 账本:记一笔(`FbLedgerEntrySheet` + `FbLedgerRepo`);修掉财务显示自相
  矛盾 —— 资产/负债改回**服务端 summary**(原来端上按金额正负号重算,与
  服务端"负债取绝对值"的口径打架,会算出 净资产 ≠ 资产 − 负债)
· 旅程:题图卡片(封面 + 标题 + 城市)+ 行程 + 新建旅程 + 新建节点

服务端同时修掉两处**静默数据丢失**(网页端因提交格式凑巧没暴露,只有走
API 的客户端会中招):
· 新建笔记传的是 `body`,而 `Note::create()` 只读 `blocks` ⇒ 新笔记只有标题
· 改笔记时 `blocks` 恒为空,而 `Note::update()` 是整组替换 ⇒ 只改标题就把
  正文全删光;另修 `annivPeopleOf()`(`find()` 的 SQL 不含 people,原来那个
  兜底恒为空 ⇒ 只改纪念日标题会清空关联人)

v66 —— 桌面服务卡片(用户选了「桌面服务卡片」这一项):
· `FbFormAbility`(FormExtensionAbility)+ `form_config.json` 三块尺寸:
  2×2 今日概览 / 2×4 左右分栏 / 4×4 今日全景
· `FbCardData` 只打 `/home` 一个接口(与网页首页同源),数据**扁平化**后
  由 `formBindingData` 推给卡片;断网时用上次成功的缓存并注明"离线"
· 卡片是后台进程 ⇒ 请求带 `authProbe: true`:401 **不能**触发全局"踢回
  登录页"(那会顺手把正在用应用的人踢下线,v62 那次事故同一个坑)
· 点卡片 → `postCardAction(router, fbModule)` → `EntryAbility` 读 Want 参数
  直接落到对应模块;补了 `onNewWant`(没有它,第二次点卡片"什么也没发生")
· 4×4 右上角 ↻ 走 `postAction(message)` → `onFormEvent` 重取数再推回

验证:`hvigorw assembleHap` BUILD SUCCESSFUL;打包后的 hap 里确认
`FbFormAbility`(type=form)与 `$profile:form_config` 都在,卡片代码进了
`ets/widgets.abc`。静态守卫 730 条全绿,反向验证 110 条注入全部能让守卫变红。
⚠️ 设备仍断连(`hdc list targets` 为空),真机尚未验收。
This commit is contained in:
2026-10-20 15:27:32 +08:00
parent 3b7238a418
commit b3e1330244
32 changed files with 5572 additions and 227 deletions
+245
View File
@@ -0,0 +1,245 @@
/**
* 家书 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;
}