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
+372 -19
View File
@@ -9,9 +9,9 @@
* 看板名仍保留(卡片上那个小色块标签),点它可以切到别的看板。
*/
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK } from '../../common/Theme';
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK, fbAlpha } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbTaskItem } from '../../model/Types';
import { FbBoardOpt, FbGroup, FbTaskItem } from '../../model/Types';
import { fbGroupTask } from '../../model/DemoData';
import { parseTasks } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
@@ -20,8 +20,14 @@ import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FbSheetRing } from '../../components/FbSheetRing';
import { FbSheetClose } from '../../components/FbSheetClose';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_TASKS } from '../../model/FbSettingsMenus';
import { FbTaskEditSheet } from '../../components/FbTaskEditSheet';
import { setTaskStatus } from '../../data/FbTaskRepo';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { FbApiResult } from '../../data/FbHttp';
@ComponentV2
export struct FbTasksPage {
@@ -33,7 +39,6 @@ export struct FbTasksPage {
@BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState();
private boards: string[] = [];
/** 模块主题色(任务 = 赭红) */
private accent: string = '#A8574F';
/**
@@ -48,6 +53,36 @@ export struct FbTasksPage {
@Local tasks: FbTaskItem[] = [];
/** 当前筛选的看板('' = 全部) */
@Local boardFilter: string = '';
/**
* 状态筛选('' = 全部;'done' = 只看已完成)。
*
* 🔴 v65:设置菜单里的「已完成」原来 `action === ''` ⇒ 点了不响应。
* 网页版是 `?view=done`(`Task::doneList`),端上不换接口,
* 直接在已拉到的整份列表上筛(数据本来就是全量,没必要多打一次网络)。
*/
@Local statusFilter: string = '';
/**
* 新建 / 编辑任务浮层是否打开。
*
* 🔴 v65(用户 2026-10-20):「任务也是一样啊,任务也点不进去,也没法那个完成,
* 新建任务、设置这些东西都这都没有。」
* ⇒ 原来「新建任务」按钮**没有 `onTap`**、列表项**没有 `onClick`**,
* 勾完成的那个圈也**没有热区** —— 三件事一件都没接。现在都接上了。
*/
@Local editOpen: boolean = false;
/** 正在编辑的任务(0 = 新建) */
@Local editingId: number = 0;
@Local editingTitle: string = '';
@Local editingNote: string = '';
/** `2026-10-25 18:00`;空 = 没有期限 */
@Local editingDueAt: string = '';
@Local editingBoardId: number = 0;
/** 正在改状态的那条(防连点打重复请求) */
@Local busyId: number = 0;
/** 「分类与看板」浮层是否打开 */
@Local boardSheetOpen: boolean = false;
/** 操作失败时的一行红字(如勾完成失败) */
@Local opNote: string = '';
/**
* 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
@@ -65,6 +100,16 @@ export struct FbTasksPage {
}
private backOnce(): boolean {
/* v65:返回手势要**先关新的浮层**,再关设置菜单 ——
顺序按"后打开的在前",否则按一次返回会跳关两层。 */
if (this.editOpen) {
this.editOpen = false;
return true;
}
if (this.boardSheetOpen) {
this.boardSheetOpen = false;
return true;
}
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
@@ -102,6 +147,103 @@ export struct FbTasksPage {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
/** 新建/编辑浮层开着 ⇒ 底部悬浮快捷栏也要让位(与 `settingsOpen` 不会同时开) */
@Monitor('editOpen')
onEditChanged(): void {
fbAppState().sheetCount = this.editOpen ? 1 : 0;
}
/** 「分类与看板」浮层同理 */
@Monitor('boardSheetOpen')
onBoardSheetChanged(): void {
fbAppState().sheetCount = this.boardSheetOpen ? 1 : 0;
}
/** 打开「新建」浮层(清空回填字段) */
private openNew(): void {
this.editingId = 0;
this.editingTitle = '';
this.editingNote = '';
this.editingDueAt = '';
this.editingBoardId = 0;
this.opNote = '';
this.editOpen = true;
}
/** 打开「编辑」浮层(把这条任务的字段回填进去) */
private openEdit(t: FbTaskItem): void {
this.editingId = t.id;
this.editingTitle = t.title;
this.editingNote = t.note;
this.editingDueAt = t.dueAt;
this.editingBoardId = t.boardId;
this.opNote = '';
this.editOpen = true;
}
/**
* 勾选 / 取消完成。
*
* 🔴 **必须走 `/tasks/<id>/status`**:服务端 `PATCH /tasks/<id>` 会把
* 请求里的 status **原样忽略**(见 `FbTaskRepo` 文件头的说明)
* ⇒ 走 PATCH 会"看起来保存成功,但状态没变"。
*
* ⚠️ 已完成 → 取消完成统一回 `todo`(不回 `doing`:用户只想撤销一次误勾,
* 把它标成"进行中"是替用户做了他没表达的决定)。
*/
private async toggleDone(t: FbTaskItem): Promise<void> {
if (this.busyId !== 0) {
return;
}
this.busyId = t.id;
this.opNote = '';
const next: string = t.status === 'done' ? 'todo' : 'done';
const res: FbApiResult = await setTaskStatus(FB_TOKEN_STORE.get(), t.id, next);
this.busyId = 0;
if (!res.ok) {
this.opNote = res.error !== '' ? res.error : '改状态失败,请稍后再试。';
return;
}
// 本地先把这条改掉(列表立刻有反馈),再重拉一遍对齐服务端派生字段(逾期等)
this.tasks = this.tasks.map((x: FbTaskItem): FbTaskItem => {
if (x.id !== t.id) {
return x;
}
const y: FbTaskItem = {
id: x.id, title: x.title, status: next, board: x.board, boardColor: x.boardColor,
dueText: x.dueText, overdue: next === 'done' ? false : x.overdue,
assignee: x.assignee, childDone: x.childDone, childTotal: x.childTotal,
boardId: x.boardId, note: x.note, dueAt: x.dueAt
};
return y;
});
this.load();
}
/** 看板列表(从已加载的任务里反推:`/tasks` 的 boards 里没任务的那些画出来也没用) */
private boardOpts(): FbBoardOpt[] {
const out: FbBoardOpt[] = [];
const seen: number[] = [];
for (const t of this.tasks) {
if (seen.indexOf(t.boardId) >= 0) {
continue;
}
seen.push(t.boardId);
out.push({ id: t.boardId, name: t.board, color: t.boardColor });
}
// 没归看板的「其他」放最后(它不是一个真的看板)
return out.sort((a: FbBoardOpt, b: FbBoardOpt): number => {
const ka: number = a.id === 0 ? 999999 : a.id;
const kb: number = b.id === 0 ? 999999 : b.id;
return ka - kb;
});
}
/** 某个看板下的任务数(「分类与看板」浮层里显示) */
private countOf(boardName: string): number {
return this.tasks.filter((t: FbTaskItem): boolean => t.board === boardName).length;
}
build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
@@ -129,7 +271,11 @@ export struct FbTasksPage {
FbPrimaryButton({
label: '新建任务',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
icon: $r('app.media.fb_ic_plus'),
/* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** —— 纯摆设。 */
onTap: (): void => {
this.openNew();
}
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
@@ -145,13 +291,43 @@ export struct FbTasksPage {
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// 操作失败的一行红字(如勾完成失败)—— 不弹 toast,就地说明
if (this.opNote !== '') {
Text(this.opNote)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 6 })
}
// ---- 看板筛选条(横向滚动)----
Scroll() {
Row({ space: 8 }) {
this.boardChip('全部', '')
ForEach(this.boards, (b: string) => {
this.boardChip(b, b)
}, (b: string): string => 'bd_' + b)
/* v65:看板从已加载的任务里反推(原来这个数组**从来没被赋值** ⇒
这条筛选条永远是空的,只剩一个「全部」) */
ForEach(this.boardOpts(), (b: FbBoardOpt) => {
this.boardChip(b.name, b.name)
}, (b: FbBoardOpt): string => 'bd_' + b.id.toString())
// 「只看已完成」是设置菜单打开的筛选 ⇒ 这里给个能取消的胶囊
if (this.statusFilter === 'done') {
Row({ space: 4 }) {
Text('只看已完成')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
Image($r('app.media.fb_ic_close'))
.width(10)
.height(10)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(this.accent))
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(fbAlpha(this.accent, 0.1))
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.statusFilter = '';
})
}
}
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
@@ -222,17 +398,155 @@ export struct FbTasksPage {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
/* v65:这两条原来 `action === ''`(画灰、点了不响应)。
现在都接上了:
· taskdone → 只看已完成(对应网页版 `?view=done`)
· taskboards → 看板列表(挑一个看板只看它;增删改看板仍在网页端) */
if (action === 'taskdone') {
this.statusFilter = 'done';
this.settingsOpen = false;
return;
}
if (action === 'taskboards') {
this.boardSheetOpen = true;
this.settingsOpen = false;
return;
}
}
})
.transition(fbSheetTransition())
}
// ---- 新建 / 编辑任务浮层(v65)----
if (this.editOpen) {
FbTaskEditSheet({
accent: this.accent,
boards: this.boardOpts(),
editingId: this.editingId,
editingTitle: this.editingTitle,
editingNote: this.editingNote,
editingDueAt: this.editingDueAt,
editingBoardId: this.editingBoardId,
onClose: (): void => {
this.editOpen = false;
},
onSaved: (): void => {
this.editOpen = false;
// 服务端会派生逾期/看板色,保存后重拉一遍
this.load();
}
})
.transition(fbSheetTransition())
}
// ---- 「分类与看板」浮层(v65)----
if (this.boardSheetOpen) {
/* 🔴 遮罩与卡片必须**叠**(`Stack`),不能当 `Column` 的子项"排" ——
排会把卡片挤出屏幕(本项目 2026-10-20 真机事故,见 FbTaskEditSheet 同一说明)。 */
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.boardSheetOpen = false;
})
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) {
Column({ space: 12 }) {
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('切换看板')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('挑一个看板,只看它的任务')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
FbSheetClose({
onClose: (): void => {
this.boardSheetOpen = false;
},
color: this.accent
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 2 }) {
this.boardRow('全部', '', '', this.tasks.length)
ForEach(this.boardOpts(), (b: FbBoardOpt) => {
this.boardRow(b.name, b.name, b.color, this.countOf(b.name))
}, (b: FbBoardOpt): string => 'bs_' + b.id.toString())
}
.width('100%')
}
.constraintSize({ maxHeight: 340 })
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
Text('增删改看板、改分类,仍在网页端「任务 → 分类与看板」里做。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.maxLines(2)
}
.width('100%')
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
.transition(fbSheetTransition())
}
}
.width('100%')
.height('100%')
}
/** 「分类与看板」浮层里的一行:色点 + 名字 + 条数(点它切筛选) */
@Builder
boardRow(label: string, key: string, color: string, count: number) {
Row({ space: 8 }) {
FbDot({ dia: 8, fillC: color !== '' ? color : this.accent })
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(this.boardFilter === key ? this.accent : FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(count.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
if (this.boardFilter === key) {
Image($r('app.media.fb_ic_check'))
.width(12)
.height(12)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(this.accent))
}
}
.width('100%')
.padding({ left: 6, right: 6, top: 11, bottom: 11 })
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.boardFilter = key;
this.boardSheetOpen = false;
})
}
/** 看板筛选胶囊 */
@Builder
boardChip(label: string, key: string) {
@@ -265,17 +579,30 @@ export struct FbTasksPage {
.backgroundColor(FbColors.paper)
}
/** 一张任务卡 */
/** 一张任务卡(整卡可点 → 打开编辑;左边的圈单独点 → 勾完成) */
@Builder
taskCard(t: FbTaskItem) {
Column({ space: 8 }) {
Row({ space: 10 }) {
// 状态圈:完成=实心+勾、进行中=实心+白点、待办=空心
FbCheckCircle({
dia: 16,
done: t.status === 'done',
doing: t.status === 'doing',
color: this.accent
/* 状态圈 + 一块透明热区。
🔴 v65:原来这个圈只有 16vp 且**没有 `onClick`** ⇒
「没法那个完成」就是这条 —— 点它什么都不发生。
⇒ 外面包一层 32×32 的热区(手指点得中),并接上改状态。
⚠️ 内层有点击时父级的整卡点击**不会**再触发(ArkUI 由最内层消费),
所以勾完成不会顺带弹出编辑浮层。 */
Stack({ alignContent: Alignment.Center }) {
FbCheckCircle({
dia: 16,
done: t.status === 'done',
doing: t.status === 'doing',
color: this.accent
})
}
.width(32)
.height(32)
.flexShrink(0)
.onClick((): void => {
this.toggleDone(t);
})
Text(t.title)
@@ -336,16 +663,42 @@ export struct FbTasksPage {
.borderRadius(FbMetric.radius)
.margin({ bottom: 8 })
.alignItems(HorizontalAlign.Start)
/* 🔴 v65:整卡可点 → 打开编辑(原来列表项**没有 `onClick`** ⇒ "点不进去") */
.onClick((): void => {
this.openEdit(t);
})
}
private filtered(): FbTaskItem[] {
if (this.boardFilter.length === 0) {
return this.tasks;
let list: FbTaskItem[] = this.tasks;
if (this.boardFilter.length > 0) {
list = list.filter((t: FbTaskItem): boolean => t.board === this.boardFilter);
}
return this.tasks.filter((t: FbTaskItem): boolean => t.board === this.boardFilter);
/* v65:设置里的「已完成」(对应网页版 `?view=done`)。
⚠️ 只看已完成时**不再按状态分组**(一份列表里只有一个组,分组头就没意义了),
见 `groups()`。 */
if (this.statusFilter === 'done') {
list = list.filter((t: FbTaskItem): boolean => t.status === 'done');
}
return list;
}
private groups(): FbGroup<FbTaskItem>[] {
if (this.statusFilter === 'done') {
const items: FbTaskItem[] = this.filtered();
if (items.length === 0) {
return [];
}
const one: FbGroup<FbTaskItem> = {
key: 'done',
title: '已完成',
items: items,
count: items.length.toString() + ' 项'
};
const only: FbGroup<FbTaskItem>[] = [];
only.push(one);
return only;
}
return fbGroupTask(this.filtered());
}