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
+71 -7
View File
@@ -123,6 +123,10 @@ interface RawDay {
month?: number; day?: number; is_leap?: number;
person_id?: number | null; person_name?: string | null;
source?: string; born?: string | null;
/** v65:关联人 id 数组(服务端列表/单条都会带) */
person_ids?: number[] | null;
/** v65:备注 */
note?: string | null;
}
interface RawTask {
@@ -201,6 +205,10 @@ interface RawNote {
id?: number; title?: string; is_shared?: number; updated_at?: string;
author_name?: string | null; avatar?: string | null; avatar_color?: string | null;
is_mine?: number; blocks?: RawNoteBlock[];
/** v65:列表里服务端已经算好的摘要 */
excerpt?: string | null;
/** v65:标签数组(`Note::attachChildren` 挂上的) */
tags?: string[] | null;
}
interface RawNoteBlock {
@@ -262,6 +270,12 @@ export function parseTrips(json: string): FbTripItem[] {
dateRange: dateRangeText(s(r.starts_at), s(r.ends_at), dayCount),
nodeCount: n(r.node_count),
doneCount: 0,
/* 题图 / 城市 / 简介 / 同行人数 —— 对齐网页版列表卡(`templates/trips/_list_body.php`)。
⚠️ `cover_key` 存**原始键**,拼地址交给 `fbMediaUrl()`(见 FbTripItem 的说明)。 */
coverKey: s(r.cover_key),
city: s(r.city) !== '' ? s(r.city) : cities.join(' · '),
summary: s(r.summary),
attendeeCount: n(r.attendee_count),
};
out.push(it);
}
@@ -280,6 +294,7 @@ export function parseDays(json: string): FbAnniversaryItem[] {
if (n(r.person_id) > 0) {
people.push(chip(s(r.person_name), '#A84A28', n(r.person_id)));
}
const src: string = s(r.source) !== '' ? s(r.source) : 'custom';
const it: FbAnniversaryItem = {
id: n(r.id),
title: s(r.title),
@@ -288,6 +303,15 @@ export function parseDays(json: string): FbAnniversaryItem[] {
kind: kindCn(s(r.kind)),
people: people,
yearly: s(r.repeat_type) !== 'once',
source: src,
month: m,
day: d,
repeatType: s(r.repeat_type) !== '' ? s(r.repeat_type) : 'solar',
/* ⚠️ 服务端 `kind` 是**自由文本**(用户在设置里能自己加分类名)
⇒ 这个原始值要留着回填,别只留映射后的类别码。 */
rawKind: s(r.kind),
note: s(r.note),
personIds: idList(r.person_ids),
};
out.push(it);
}
@@ -339,6 +363,10 @@ function taskRow(t: RawTask, boardName: string, boardColor: string): FbTaskItem
assignee: s(t.assignee_name),
childDone: n(t.child_done),
childTotal: n(t.child_total),
/* v65:新建/编辑/搬看板要用到的三个**原始**字段(原来只派生了显示文案) */
boardId: n(t.board_id),
note: s(t.note),
dueAt: due,
};
return it;
}
@@ -455,15 +483,18 @@ export function parseResources(json: string): FbResourceItem[] {
const rows: RawResource[] = asArray<RawResource>(json);
const out: FbResourceItem[] = [];
for (const r of rows) {
/* ⚠️ 服务端 `resources` 表**没有 category 列**(见 `app/Schema.php`)——
只有 title / content / owner_id。所以端上按「归属人」分组/筛选,
不按"证件/资料"那种并不存在的类别(v65 之前那两个胶囊永远筛出空列表)。 */
const perm: string = s(r.perm);
const it: FbResourceItem = {
id: n(r.id),
name: s(r.title),
// 资源没有"类别"字段(服务端的 content 是自由文本),
// ⇒ 用「谁的」来分类更贴近实际用法(Web 端列表也是按归属分组)
kind: 'other',
ownerName: s(r.owner_nick) !== '' ? s(r.owner_nick) : s(r.owner_name),
fileCount: 0,
shared: s(r.perm) !== 'owner',
ownerId: n(r.owner_id),
perm: perm !== '' ? perm : 'read',
content: s(r.content),
shared: perm !== 'owner',
updatedAt: datePart(s(r.updated_at)),
};
out.push(it);
@@ -502,19 +533,52 @@ export function parseNotes(json: string): FbNoteItem[] {
const rows: RawNote[] = arr<RawNote>(root['items'] as RawNote[]);
const out: FbNoteItem[] = [];
for (const r of rows) {
/* ⚠️ 服务端列表已带 `blocks` / `tags` / `excerpt`(`Note::attachChildren`),
这里把它们都取出来 —— 原来只拿 blocks 算了个摘要,正文和标签全丢了。 */
const body: string = noteBodyOf(r.blocks);
const tags: string[] = [];
for (const t of arr<string>(r.tags)) {
tags.push(s(t));
}
const exc: string = s(r.excerpt);
out.push({
id: n(r.id),
title: s(r.title),
excerpt: firstTextOf(r.blocks),
tags: [],
excerpt: exc !== '' ? exc : body,
tags: tags,
updatedAt: datePart(s(r.updated_at)),
shared: n(r.is_shared) !== 0,
attachCount: 0,
body: body,
mine: n(r.is_mine) !== 0,
});
}
return out;
}
/**
* `note_blocks` → 一段纯文本。
*
* ⚠️ 图片块的内容是**附件 key**(不是正文),拼进来会变成一串乱码 key
* ⇒ 记成「[图片]」,用户一眼知道那里有张图。
*/
function noteBodyOf(blocks: RawNoteBlock[] | undefined): string {
const rows: RawNoteBlock[] = arr<RawNoteBlock>(blocks);
const parts: string[] = [];
for (const b of rows) {
const kind: string = s(b.kind);
if (kind === 'image') {
parts.push('[图片]');
continue;
}
const txt: string = s(b.content).trim();
if (txt !== '') {
parts.push(txt);
}
}
return parts.join('\n\n');
}
/** 通知 → FbNotificationItem[] */
export function parseNotifications(json: string): FbNotificationItem[] {
const root: Record<string, Object> | null = asObject(json);