用户 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` 为空),真机尚未验收。
266 lines
9.2 KiB
Plaintext
266 lines
9.2 KiB
Plaintext
/**
|
||
* 家书 FamilyBook · 「新建旅程」浮层(鸿蒙端)
|
||
*
|
||
* 对应网页版 `templates/trips/form.php`(新建那一路)。
|
||
*
|
||
* 🔴 为什么单独做这一批:端上原来的「新建旅程」按钮**根本没接 `onTap`**
|
||
* (`FbTripsPage` 里 `FbPrimaryButton({ label: '新建旅程', ... })` 没有回调),
|
||
* 点了什么都不发生 —— 用户报的"新建旅程没完成"就是这一条。
|
||
*
|
||
* ⚠️ 表单只放**网页版真有的三项**:
|
||
* 标题 / 简介 / 是否公开。**没有起止时间、没有城市** ——
|
||
* 服务端 `Trip::create()` 显式忽略它们,城市与时间一律从行程节点**派生**
|
||
* (见 `Trip::derived()`)。加个"时间"输入框只会让人以为填了有用。
|
||
*
|
||
* ⚠️ 题图这一批**不做**:网页版走 `cover` 文件上传 + `cover_crop` 裁剪,
|
||
* 而服务端目前只有观影的 `/movies/<id>/poster` 上传槽位,没有旅程的。
|
||
* ⇒ 新建时先不传图;列表卡仍会显示已有数据的题图。
|
||
* (补上传端点时要与网页版 `ImageSlot` 的 `cover` 槽位比例 16:9 一致,
|
||
* 否则宽高比会和网页端不一样 —— 那条在 app.css 里有专门的断言比对。)
|
||
*/
|
||
|
||
import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme';
|
||
import { FbSheetClose } from './FbSheetClose';
|
||
import { FbSheetRing } from './FbSheetRing';
|
||
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||
import { createTrip, updateTrip, FbTripDraft } from '../data/FbTripRepo';
|
||
import { FbApiResult } from '../data/FbHttp';
|
||
|
||
@ComponentV2
|
||
export struct FbTripNewSheet {
|
||
/** 模块主题色(旅程 = 绿) */
|
||
@Param accent: string = '#3F7D5A';
|
||
/**
|
||
* 编辑模式传进来的旅程(`null`/`undefined` = 新建)。
|
||
*
|
||
* ⚠️ 用 `id` 判断而不是靠"标题是否为空" —— 空标题是**合法**的中间状态
|
||
* (用户刚清空输入框,还没打完),那时不该被当成"新建"。
|
||
*/
|
||
@Param editingId: number = 0;
|
||
@Param editingTitle: string = '';
|
||
@Param editingSummary: string = '';
|
||
@Param editingPublic: boolean = true;
|
||
|
||
@Event onClose: () => void = (): void => {};
|
||
/** 保存成功:父页面据此刷新列表 */
|
||
@Event onSaved: () => void = (): void => {};
|
||
|
||
@Local title: string = '';
|
||
@Local summary: string = '';
|
||
@Local isPublic: boolean = true;
|
||
@Local saving: boolean = false;
|
||
/** 错误/提示(红字) */
|
||
@Local note: string = '';
|
||
|
||
aboutToAppear(): void {
|
||
this.title = this.editingTitle;
|
||
this.summary = this.editingSummary;
|
||
this.isPublic = this.editingPublic;
|
||
}
|
||
|
||
build() {
|
||
/* 🔴🔴 根必须是 `Stack`(见 `pitfalls.md` 的"全屏遮罩不能当 Column 子项排")。
|
||
`Column { 整高遮罩; 卡片 }` 会把卡片挤出屏幕 ⇒ 浮层"点了没反应"。 */
|
||
Stack({ alignContent: Alignment.Center }) {
|
||
Column()
|
||
.width('100%')
|
||
.height('100%')
|
||
.backgroundColor(FB_MASK)
|
||
.onClick((): void => {
|
||
this.onClose();
|
||
})
|
||
|
||
Column() {
|
||
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) {
|
||
Column({ space: 12 }) {
|
||
// ---- 标题栏(统一版式:左标题 + 右 ✕)----
|
||
Row({ space: 8 }) {
|
||
Column({ space: 2 }) {
|
||
Text(this.editingId > 0 ? '编辑旅程' : '新建旅程')
|
||
.fontSize(FbMetric.fontTitle)
|
||
.fontWeight(FontWeight.Medium)
|
||
.fontColor(FbColors.ink)
|
||
.maxLines(1)
|
||
Text('先起个名字;城市与时间等加了行程自然就有了')
|
||
.fontSize(FbMetric.fontTiny)
|
||
.fontColor(this.accent)
|
||
.maxLines(2)
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.layoutWeight(1)
|
||
.flexShrink(1)
|
||
|
||
FbSheetClose({
|
||
onClose: (): void => {
|
||
this.onClose();
|
||
},
|
||
color: this.accent
|
||
})
|
||
}
|
||
.width('100%')
|
||
.alignItems(VerticalAlign.Center)
|
||
|
||
// ---- 旅程名(必填)----
|
||
this.textField('旅程名', '如「青岛·崂山 两日」', this.title,
|
||
(v: string): void => {
|
||
this.title = v;
|
||
this.note = '';
|
||
})
|
||
|
||
// ---- 简介 ----
|
||
this.areaField('简介', '这趟出行想记的几句话(可空)', this.summary,
|
||
(v: string): void => {
|
||
this.summary = v;
|
||
})
|
||
|
||
// ---- 是否公开 ----
|
||
Row({ space: 8 }) {
|
||
Column({ space: 2 }) {
|
||
Text('在列表里公开')
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(FbColors.ink)
|
||
Text('不公开的旅程不会出现在列表里')
|
||
.fontSize(FbMetric.fontTiny)
|
||
.fontColor(FbColors.faint)
|
||
}
|
||
.alignItems(HorizontalAlign.Start)
|
||
.layoutWeight(1)
|
||
|
||
Toggle({ type: ToggleType.Switch, isOn: this.isPublic })
|
||
.selectedColor(this.accent)
|
||
.onChange((on: boolean): void => {
|
||
this.isPublic = on;
|
||
})
|
||
}
|
||
.width('100%')
|
||
.alignItems(VerticalAlign.Center)
|
||
.padding({ top: 2, bottom: 2 })
|
||
|
||
// ---- 错误 / 提示 ----
|
||
if (this.note !== '') {
|
||
Text(this.note)
|
||
.fontSize(FbMetric.fontTiny)
|
||
.fontColor(FbColors.red)
|
||
.width('100%')
|
||
.maxLines(3)
|
||
}
|
||
|
||
// ---- 底部:保存(关闭统一在右上角 ✕)----
|
||
Row() {
|
||
Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '建好这趟旅程'))
|
||
.fontSize(FbMetric.fontBody)
|
||
.fontWeight(FontWeight.Medium)
|
||
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
|
||
.textAlign(TextAlign.Center)
|
||
.width('100%')
|
||
.height(44)
|
||
.borderRadius(FbMetric.radiusS)
|
||
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98))
|
||
.onClick((): void => {
|
||
this.save();
|
||
})
|
||
}
|
||
.width('100%')
|
||
.padding({ top: 4 })
|
||
}
|
||
.width('100%')
|
||
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
|
||
.alignItems(HorizontalAlign.Start)
|
||
}
|
||
}
|
||
.width('92%')
|
||
}
|
||
.width('100%')
|
||
.height('100%')
|
||
.hitTestBehavior(HitTestMode.Transparent)
|
||
}
|
||
|
||
/**
|
||
* 提交。
|
||
*
|
||
* ⚠️ 保存中要**挡住重复点击**:连点两下会建出两趟一模一样的旅程
|
||
* (服务端不去重)。`this.saving` 在这里是真正的功能,不是装饰。
|
||
*/
|
||
private async save(): Promise<void> {
|
||
if (this.saving) {
|
||
return;
|
||
}
|
||
const title: string = this.title.trim();
|
||
if (title === '') {
|
||
this.note = '旅程得有个名字。';
|
||
return;
|
||
}
|
||
this.saving = true;
|
||
this.note = '';
|
||
const token: string = FB_TOKEN_STORE.get();
|
||
const d: FbTripDraft = {
|
||
title: title,
|
||
summary: this.summary.trim(),
|
||
isPublic: this.isPublic
|
||
};
|
||
const res: FbApiResult = this.editingId > 0
|
||
? await updateTrip(token, this.editingId, d)
|
||
: await createTrip(token, d);
|
||
this.saving = false;
|
||
if (!res.ok) {
|
||
this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。';
|
||
return;
|
||
}
|
||
this.onSaved();
|
||
}
|
||
|
||
/** 单行输入 */
|
||
@Builder
|
||
textField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
|
||
Row({ space: 8 }) {
|
||
Text(label)
|
||
.fontSize(FbMetric.fontTiny)
|
||
.fontColor(FbColors.faint)
|
||
.width(48)
|
||
|
||
TextInput({ text: value, placeholder: placeholder })
|
||
.placeholderColor(FbColors.faint)
|
||
.placeholderFont({ size: FbMetric.fontTiny })
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(FbColors.ink)
|
||
.backgroundColor(FbColors.inputBg)
|
||
.borderRadius(FbMetric.radiusS)
|
||
.height(42)
|
||
.layoutWeight(1)
|
||
.padding({ left: 10, right: 10 })
|
||
.onChange((v: string): void => {
|
||
onChange(v);
|
||
})
|
||
}
|
||
.width('100%')
|
||
.alignItems(VerticalAlign.Center)
|
||
}
|
||
|
||
/** 多行输入(简介) */
|
||
@Builder
|
||
areaField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
|
||
Row({ space: 8 }) {
|
||
Text(label)
|
||
.fontSize(FbMetric.fontTiny)
|
||
.fontColor(FbColors.faint)
|
||
.width(48)
|
||
|
||
TextArea({ text: value, placeholder: placeholder })
|
||
.placeholderColor(FbColors.faint)
|
||
.placeholderFont({ size: FbMetric.fontTiny })
|
||
.fontSize(FbMetric.fontSmall)
|
||
.fontColor(FbColors.ink)
|
||
.backgroundColor(FbColors.inputBg)
|
||
.borderRadius(FbMetric.radiusS)
|
||
.height(84)
|
||
.layoutWeight(1)
|
||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||
.onChange((v: string): void => {
|
||
onChange(v);
|
||
})
|
||
}
|
||
.width('100%')
|
||
.alignItems(VerticalAlign.Top)
|
||
}
|
||
}
|