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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user