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
+363 -42
View File
@@ -12,7 +12,7 @@
* 时间轴节点分「计划 / 实际」两阶段 —— 与 Web 端一致(v41 加的四种动作按钮)。
*/
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 { FbTripItem, FbTripNode } from '../../model/Types';
import { fbMoney } from '../../model/DemoData';
@@ -25,6 +25,12 @@ import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_TRIPS } from '../../model/FbSettingsMenus';
/* v65:题图要走媒体代理(`Image()` 加不了自定义请求头,令牌得用 URL query 带过去) */
import { fbImageSrc } from '../../data/FbAttachQueue';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { fetchTripNodes } from '../../data/FbTripRepo';
import { FbTripNewSheet } from '../../components/FbTripNewSheet';
import { FbTripNodeSheet } from '../../components/FbTripNodeSheet';
@ComponentV2
export struct FbTripsPage {
@@ -66,6 +72,27 @@ export struct FbTripsPage {
private accent: string = '#3F7D5A';
/** 当前展开的旅程 id(0 = 都没展开) */
@Local expandedId: number = 0;
/**
* 「新建旅程」浮层是否打开(v65)。
*
* 🔴 原先这颗按钮**没有 `onTap`** ⇒ 点了什么都不发生(用户:
* 「新建旅程啊…这些东西都还没完成」)。现在接上了。
*/
@Local newOpen: boolean = false;
/** 正在编辑的旅程(0 = 新建)。点卡片的「编辑」用 */
@Local editingId: number = 0;
@Local editingTitle: string = '';
@Local editingSummary: string = '';
/** 行程节点正在加载(展开时拉 `/trips/<id>/nodes`) */
@Local nodesLoading: boolean = false;
/** 节点加载失败的说明(空 = 没失败) */
@Local nodesNote: string = '';
/** 哪一趟旅程的节点是「当前这份 `nodes`」(避免 A 的节点画到 B 上) */
@Local nodesTripId: number = 0;
/** 题图加载失败集合(一张图坏了不该反复重试,会让列表一直闪) */
@Local coverFailedIds: number[] = [];
/** 「加一段行程」浮层是否打开,以及它属于哪一趟旅程(0 = 关着) */
@Local nodeSheetTripId: number = 0;
/**
* 页面出现:先缓存、再网络(与观影/名册同一套"本地优先"流程)。
@@ -84,10 +111,25 @@ export struct FbTripsPage {
}
private backOnce(): boolean {
/* v65:返回手势要**先关新的浮层**,再关设置菜单 ——
顺序按"层级由浅到深"(后打开的在前),否则按一次返回会跳关两层。 */
if (this.nodeSheetTripId > 0) {
this.nodeSheetTripId = 0;
return true;
}
if (this.newOpen) {
this.newOpen = false;
return true;
}
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
// 行程展开着时,返回先收起它(否则用户只能靠再点一次卡片)
if (this.expandedId !== 0) {
this.expandedId = 0;
return true;
}
return false;
}
@@ -119,6 +161,23 @@ export struct FbTripsPage {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
/**
* 新建/编辑浮层开着 ⇒ 底部悬浮快捷栏也要让位。
*
* ⚠️ 与 `settingsOpen` 各管一个 @Monitor:两个浮层**不会同时开**
* (新建时设置菜单已关),所以这里直接按 `newOpen` 赋值即可。
*/
@Monitor('newOpen')
onNewChanged(): void {
fbAppState().sheetCount = this.newOpen ? 1 : 0;
}
/** 「加一段行程」浮层同样要让悬浮栏让位 */
@Monitor('nodeSheetTripId')
onNodeSheetChanged(): void {
fbAppState().sheetCount = this.nodeSheetTripId > 0 ? 1 : 0;
}
build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
@@ -144,7 +203,15 @@ export struct FbTripsPage {
FbPrimaryButton({
label: '新建旅程',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
icon: $r('app.media.fb_ic_plus'),
/* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** —— 纯摆设,
点了什么都不发生(用户报的"新建旅程没完成"就是这条)。 */
onTap: (): void => {
this.editingId = 0;
this.editingTitle = '';
this.editingSummary = '';
this.newOpen = true;
}
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
@@ -221,92 +288,258 @@ export struct FbTripsPage {
})
.transition(fbSheetTransition())
}
// ---- 新建 / 编辑旅程浮层(v65)----
if (this.newOpen) {
FbTripNewSheet({
accent: this.accent,
editingId: this.editingId,
editingTitle: this.editingTitle,
editingSummary: this.editingSummary,
onClose: (): void => {
this.newOpen = false;
},
onSaved: (): void => {
this.newOpen = false;
// 保存后重新拉一遍列表(服务端的城市/时间是派生的,本地拼不出来)
this.expandedId = 0;
this.load();
}
})
.transition(fbSheetTransition())
}
// ---- 加一段行程浮层(v65)----
if (this.nodeSheetTripId > 0) {
FbTripNodeSheet({
tripId: this.nodeSheetTripId,
accent: this.accent,
onClose: (): void => {
this.nodeSheetTripId = 0;
},
onSaved: (): void => {
const id: number = this.nodeSheetTripId;
this.nodeSheetTripId = 0;
// 加完重拉节点(服务端会重算派生时间,列表也要跟着变)
this.load();
this.loadNodes(id);
}
})
.transition(fbSheetTransition())
}
}
.width('100%')
.height('100%')
}
/** 一张旅程卡:标题 + 时间 + 进度;展开后接行程时间轴 */
/**
* 一张旅程卡:**题图 + 标题 + 时间/城市/段数 + 简介 + 同行人**。
*
* 🔴 v65(用户 2026-10-20):「旅程的列表也不太对呀,应该有,题图啊、标题啊、
* 然后城市啊,应该跟网页版的题图是类似的。」
* ⇒ 照 `templates/trips/_list_body.php` 的 `trip-card` 重做:
* 网页版是**横排**(左题图 132×96 + 右 `trip-main`)。
* 手机上按同样比例缩到 96×72(4:3),右边留足文字宽度。
*
* ⚠️ 题图比例:网页版列表卡是 132×96 ≈ 4:3,而**详情页**的 hero 是 16:9。
* 这里按列表卡的 4:3 走(与用户要的"跟网页版类似"一致)。
*/
@Builder
tripCard(t: FbTripItem) {
Column({ space: 0 }) {
// 卡头
Row({ space: 10 }) {
// 左侧色条(模块色的竖条,一眼区分旅程)
Column()
.width(4)
.height(38)
.borderRadius(2)
.backgroundColor(this.accent)
// ---- 卡头:左题图 + 右内容(横排,同网页版)----
Row({ space: 12 }) {
// 题图:有图放真图,没图放占位(网页版是 🧳)
Stack({ alignContent: Alignment.Center }) {
if (t.coverKey !== '') {
Image(fbImageSrc(t.coverKey, FB_TOKEN_STORE.get()))
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
// ⚠️ 不给 alt 图标:旅程封面是实景照,失败时给纯色底比给个
// "行李箱图标"更不刺眼(网页版失败时也是底色调)
.onError((): void => {
this.markCoverFailed(t.id);
})
}
// 有图但加载失败时,也别留一块透明 —— 统一给底色 + 图标
if (t.coverKey === '' || this.coverFailed(t.id)) {
Column({ space: 4 }) {
Image($r('app.media.fb_ic_trips'))
.width(22)
.height(22)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(FbTint.faint))
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F1E7D6')
}
}
.width(96)
.height(72)
.borderRadius(FbMetric.radiusS)
.clip(true)
.flexShrink(0)
Column({ space: 3 }) {
// 右侧:标题 / meta / 简介
Column({ space: 4 }) {
Text(t.title)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
// meta 一行:时间 · 📍城市 · N 段行程
Row({ space: 10 }) {
if (t.dateRange !== '') {
Text(t.dateRange)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
}
if (t.city !== '') {
Text('📍 ' + t.city)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
.layoutWeight(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
} else {
Column().layoutWeight(1)
}
}
.width('100%')
// 段数 + 同行人数(网页版是一排 meta,这里小屏分两行更清楚)
Row({ space: 8 }) {
Text(t.dateRange)
Text(t.nodeCount.toString() + ' 段行程')
.fontSize(FbMetric.fontTiny)
.fontColor(t.nodeCount > 0 ? this.accent : FbColors.faint)
if (t.doneCount > 0) {
Text('已完成 ' + t.doneCount.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
if (t.attendeeCount > 0) {
Text('· ' + t.attendeeCount.toString() + ' 人同行')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
}
.width('100%')
// 简介:网页版截 80 字,这里两行截断(手机一行更窄)
if (t.summary !== '') {
Text(t.summary)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
Text(t.sub)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.lineHeight(17)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 3 }) {
if (t.nodeCount > 0) {
Text(t.doneCount.toString() + '/' + t.nodeCount.toString())
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(this.accent)
Text('行程')
.fontSize(10)
.fontColor(FbColors.faint)
} else {
FbTag({ text: '还没行程', tone: 'muted' })
}
.flexShrink(1)
}
.width('100%')
.padding(FbMetric.cardPad)
.alignItems(VerticalAlign.Top)
.onClick((): void => {
// 点卡片主体 = 展开/收起行程(网页版是"进详情",手机上一屏放不下,
// 就地展开更顺手;编辑走下面那颗小按钮)
const was: boolean = this.isExpanded(t.id);
this.expandedId = was ? 0 : t.id;
if (!was) {
this.loadNodes(t.id);
}
.alignItems(HorizontalAlign.End)
})
// ---- 卡尾操作条:编辑(网页版进详情页才有编辑,这里给个显式入口)----
Row({ space: 14 }) {
Text(this.isExpanded(t.id) ? '收起行程' : '查看行程')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
Column().layoutWeight(1)
Text('编辑')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.openEdit(t);
})
Image($r('app.media.fb_ic_forward'))
.width(14)
.height(14)
.width(12)
.height(12)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(FbTint.faint))
.rotate({ angle: this.isExpanded(t.id) ? 90 : 0 })
}
.width('100%')
.padding(FbMetric.cardPad)
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 12 })
.alignItems(VerticalAlign.Center)
.onClick(() => {
// 展开/收起:只改本地状态,不发请求
this.expandedId = this.isExpanded(t.id) ? 0 : t.id;
})
// 展开区:行程时间轴
// ---- 展开区:行程时间轴 ----
if (this.isExpanded(t.id)) {
Divider()
.color(FbColors.line2)
.margin({ left: FbMetric.cardPad, right: FbMetric.cardPad })
if (this.nodes.length === 0) {
Column() {
Text('这个旅程还没有排行程')
if (this.nodesLoading) {
Row({ space: 8 }) {
LoadingProgress()
.width(16)
.height(16)
.color(this.accent)
Text('正在取行程…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.padding(FbMetric.cardPad)
.alignItems(VerticalAlign.Center)
} else if (this.nodesNote !== '') {
/* 拉失败时**说实话**,不假装"还没排行程" ——
后者会让用户以为自己的行程丢了。 */
Column({ space: 6 }) {
Text(this.nodesNote)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
Text('点这里重试')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.onClick((): void => {
this.loadNodes(t.id);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(FbMetric.cardPad)
} else if (this.nodes.length === 0) {
Column({ space: 8 }) {
Text('这个旅程还没有排行程')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
this.addNodeBtn(t.id)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(FbMetric.cardPad)
} else {
Column({ space: 0 }) {
// 分两段列:「实际」(已发生的)在前,「计划」在后 —— 与 Web 端顺序一致
this.stageBlock('实际', 'actual')
this.stageBlock('计划', 'plan')
// 已有节点时,加号排在最后(像网页版时间轴的末尾)
this.addNodeBtn(t.id)
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: FbMetric.cardPad })
@@ -320,6 +553,94 @@ export struct FbTripsPage {
.alignItems(HorizontalAlign.Start)
}
/** 打开编辑浮层(预填当前值) */
private openEdit(t: FbTripItem): void {
this.editingId = t.id;
this.editingTitle = t.title;
this.editingSummary = t.summary;
this.newOpen = true;
}
/** 展开区底部那颗「+ 加一段行程」 */
@Builder
addNodeBtn(tripId: number) {
Row({ space: 6 }) {
Image($r('app.media.fb_ic_plus'))
.width(13)
.height(13)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(this.accent))
Text('加一段行程')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
}
.padding({ left: 10, right: 12, top: 7, bottom: 7 })
.backgroundColor(fbAlpha(this.accent, 0.08))
.borderRadius(FbMetric.radiusPill)
.alignItems(VerticalAlign.Center)
.margin({ top: 6 })
.onClick((): void => {
this.nodeSheetTripId = tripId;
})
}
/**
* 拉某趟旅程的行程节点。
*
* 🔴 v65:这是端上原先**最大**的缺口 —— `nodes` 一直是空数组,
* 所以每趟旅程展开后永远显示"还没有排行程"。
* 用户原话:「现在这个旅程下面也没有这个行程啊」。
*
* ⚠️ 三个状态要分清(否则用户会以为数据丢了):
* ① 正在拉 → 转圈;② 拉失败 → 明说"没取到" + 可重试;
* ③ 拉成功但确实没有 → 才说"还没排行程"。
*/
private async loadNodes(tripId: number): Promise<void> {
if (this.nodesLoading) {
return;
}
this.nodesLoading = true;
this.nodesNote = '';
this.nodesTripId = tripId;
const rows: FbTripNode[] = await fetchTripNodes(FB_TOKEN_STORE.get(), tripId);
this.nodesLoading = false;
/* ⚠️ 用户可能在等待期间又点了别的旅程 ⇒ 只认"当前还展开着的那一趟",
否则 A 的节点会画到 B 的展开区里。 */
if (this.expandedId !== tripId) {
return;
}
this.nodes = rows;
/* 拉回来是空数组有两种可能:真的没有 / 请求失败。`fetchTripNodes` 失败也回 [],
这里无法区分 ⇒ 由下面 `node_count` 兜一道:
列表里说有 N 段、这里却是 0 ⇒ 那就是没取到,要明说。 */
if (rows.length === 0) {
const cur: FbTripItem | null = this.tripById(tripId);
if (cur !== null && cur.nodeCount > 0) {
this.nodesNote = '行程没取到(网络或权限问题),列表里显示有 '
+ cur.nodeCount.toString() + ' 段。';
}
}
}
private tripById(id: number): FbTripItem | null {
for (const t of this.trips) {
if (t.id === id) {
return t;
}
}
return null;
}
private markCoverFailed(id: number): void {
if (this.coverFailedIds.indexOf(id) < 0) {
this.coverFailedIds = this.coverFailedIds.concat([id]);
}
}
private coverFailed(id: number): boolean {
return this.coverFailedIds.indexOf(id) >= 0;
}
/** 一段阶段块(实际 / 计划)+ 它下面的节点时间轴 */
@Builder
stageBlock(title: string, stage: string) {