v66 续:旅程三项设置(车辆管理 / 出行方式 / 节点类型)

用户 2026-10-20:「旅程的……车辆管理、出行方式、节点设置,这些东西都还没完成。」
这三项的 `action` 一直是**空串**(点了没反应),是本次批量需求里最后一块。

三项的真实能力并不一样,按真实情况做,不摆假入口:
· **车辆管理**:`vehicles` 表 + `Vehicle::save/destroy` 早就有,缺的只是 API
  ⇒ 新增 `GET/POST/PATCH/DELETE /api/v1/vehicles`;端上列表 + 内嵌表单
  (车型 / 车牌 / 自家·租的·借的 / 车主或租车公司 / 订单号 / 默认车 / 备注)
· **节点类型**:`node_types` 表 + `NodeType::save/destroy` 同理
  ⇒ 新增 `GET/POST/PATCH/DELETE /api/v1/nodetypes`;改名会**级联**到已有行程
  (`trip_nodes.node_type` 存的是名字,服务端 `NodeType::save` 里做)
  · 内置类型**不画删除按钮**(服务端会拒,画了就是"点了没反应")
· **出行方式**:**枚举常量**(`Trip::TRAVEL_MODES`),没有"管理"这回事
  ⇒ 只做一份只读对照表 + "加行程时选",**不做可编辑表单**
  (反向验证里有一条专门锁"这个浮层里不许出现保存/删除")

几个实现上的要点:
· 两条新路由是**平级资源**(`/vehicles`、`/nodetypes`),不是 `trips/xxx` 子路由 ——
  `Api::dispatch()` 只解析三段,写成四段会落到「trips 的第 0 条」= 列表
· 删不掉(还有 N 段行程在用 / 内置类型)回 **422 + 服务端原话**,不是 500:
  那是"现在不能删",不是"服务器出错"
· 表单输入框**不按值传 value**:`@Builder` 的值参数不参与状态观察,
  而这个浮层是"列表 + 内嵌表单"(点另一行的「编辑」时表单已经渲染着)
  ⇒ 会显示上一辆车的内容。改成传 key、builder 内部直接读 `this.fXxx`
· 三个浮层都遵守浮层铁律(根 `Stack` 叠放 + `hitTestBehavior(Transparent)`),
  各挂一个 `@Monitor` 让底部悬浮栏让位;`backOnce()` 按层级顺序关

服务端(`app/ApiHandler.php`)已随 `familybook_push.sh` 部署,线上核对:
`/api/v1/ping` 的 features 里 `trips_vehicles` / `trips_nodetypes` 均为 1,
两个端点无令牌时回 **401 unauthorized**(说明路由存在,而不是 404 not_found)。

验证:`hvigorw assembleHap` BUILD SUCCESSFUL;静态守卫 769 条全绿;
反向验证 116 条注入全部能让守卫变红(含"字段名拼错""按值传 value"等假绿陷阱)。
⚠️ 设备仍断连,真机未验收。
This commit is contained in:
2026-10-20 16:36:30 +08:00
parent b3e1330244
commit 58f5509c1c
5 changed files with 1198 additions and 5 deletions
@@ -0,0 +1,835 @@
/**
* 家书 FamilyBook · 旅程设置三项(鸿蒙端)
*
* 对应网页版 `trips/vehicles.php` / `trips/types.php` / `trips/modes.php`。
*
* 🔴 v66(用户 2026-10-20):「旅程的……车辆管理、出行方式、节点设置,
* 这些东西都还没完成。」—— 网页端早就有,端上这三个菜单项的 `action`
* 一直是**空串**(点了没反应)。
*
* 三个浮层一个文件,因为它们是**同一件事的三个页签**(同一套版面、同一套读写):
* · `FbVehicleSheet` 车辆:增 / 改 / 删(列表 + 内嵌表单)
* · `FbNodeTypeSheet` 节点类型:增 / 改 / 删
* · `FbTravelModesSheet` 出行方式:**只读枚举**(服务端根本没有这张表)
*
* ⚠️ 三个都遵守本项目浮层铁律:根是 `Stack({ alignContent: Center })`
* (遮罩与卡片"叠"、不能"排"),外层 `hitTestBehavior(Transparent)`,
* 写操作带 `saving` 防连点。见 `pitfalls.md` 的 v64 真机事故。
*/
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 { FbApiResult } from '../data/FbHttp';
import { FbNodeTypeItem, FbVehicleItem } from '../model/Types';
import {
deleteNodeType, deleteVehicle, FB_TRAVEL_MODES, FbNodeTypeDraft, FbTravelModeDef,
FbVehicleDraft, fbVehicleTypeName, fetchNodeTypes, fetchVehicles, saveNodeType, saveVehicle
} from '../data/FbTripSettingsRepo';
/** 删除红(与 `color.json` 的 red 一致;`fbAlpha` 只吃 hex,不吃 Resource) */
const DEL_RED: string = '#C0533F';
/** 车辆来源三项(`Vehicle::TYPES` 的镜像) */
const VTYPES: string[] = ['own', 'rental', 'borrowed'];
@ComponentV2
export struct FbVehicleSheet {
@Param accent: string = '#3F7D5A';
@Event onClose: () => void = (): void => {};
/** 有改动就通知父页面(父页面据此不必重拉,车不影响旅程列表;留着备扩展) */
@Event onChanged: () => void = (): void => {};
@Local items: FbVehicleItem[] = [];
@Local loading: boolean = true;
@Local note: string = '';
/** 正在编辑的车(0 = 新建表单收起) */
@Local editingId: number = 0;
@Local formOpen: boolean = false;
@Local saving: boolean = false;
/** 待确认删除的车 id(0 = 没有) */
@Local confirmId: number = 0;
// ---- 表单字段 ----
@Local fName: string = '';
@Local fPlate: string = '';
@Local fVtype: string = 'own';
@Local fVendor: string = '';
@Local fOrderNo: string = '';
@Local fDefault: boolean = false;
@Local fNote: string = '';
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
const rows: FbVehicleItem[] = await fetchVehicles(FB_TOKEN_STORE.get());
this.items = rows;
this.loading = false;
if (rows.length === 0 && this.note === '') {
this.note = '还没有登记车辆。自家的、租的、借的都可以记在这里。';
}
}
private openNew(): void {
this.editingId = 0;
this.formOpen = true;
this.note = '';
this.confirmId = 0;
this.fName = '';
this.fPlate = '';
this.fVtype = 'own';
this.fVendor = '';
this.fOrderNo = '';
this.fDefault = false;
this.fNote = '';
}
private openEdit(v: FbVehicleItem): void {
this.editingId = v.id;
this.formOpen = true;
this.note = '';
this.confirmId = 0;
this.fName = v.name;
this.fPlate = v.plate;
this.fVtype = v.vtype;
this.fVendor = v.vendor;
this.fOrderNo = v.orderNo;
this.fDefault = v.isDefault;
this.fNote = v.note;
}
private async save(): Promise<void> {
if (this.saving) {
return;
}
const name: string = this.fName.trim();
if (name === '') {
this.note = '车型名不能为空。';
return;
}
this.saving = true;
const d: FbVehicleDraft = {
plate: this.fPlate.trim(),
name: name,
vtype: this.fVtype,
vendor: this.fVendor.trim(),
orderNo: this.fOrderNo.trim(),
isDefault: this.fDefault,
note: this.fNote.trim()
};
const res: FbApiResult = await saveVehicle(FB_TOKEN_STORE.get(), d, this.editingId);
this.saving = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。';
return;
}
this.formOpen = false;
this.note = '';
this.onChanged();
await this.load();
}
private async remove(id: number): Promise<void> {
const res: FbApiResult = await deleteVehicle(FB_TOKEN_STORE.get(), id);
this.confirmId = 0;
if (!res.ok) {
// 服务端的话已经是人话("还有 N 段行程在用这辆车"),原样显示
this.note = res.error !== '' ? res.error : '删除失败。';
return;
}
this.note = '';
this.onChanged();
await this.load();
}
/** 一辆车的一行 */
@Builder
rowOf(v: FbVehicleItem) {
Column({ space: 6 }) {
Row({ space: 6 }) {
Text(v.plate !== '' ? v.plate : v.name)
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
.flexShrink(1)
if (v.plate !== '') {
Text(v.name)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.maxLines(1)
.layoutWeight(1)
} else {
Blank()
}
if (v.isDefault) {
Text('默认')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.backgroundColor(fbAlpha(this.accent, 0.12))
.borderRadius(4)
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row({ space: 8 }) {
Text(fbVehicleTypeName(v.vtype) + (v.vendor !== '' ? ' · ' + v.vendor : ''))
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
.layoutWeight(1)
if (this.confirmId === v.id) {
Text('确认删除')
.fontSize(FbMetric.fontTiny)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(DEL_RED)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.remove(v.id);
})
Text('取消')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.onClick((): void => {
this.confirmId = 0;
})
} else {
Text('编辑')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.onClick((): void => {
this.openEdit(v);
})
Text('删除')
.fontSize(FbMetric.fontTiny)
.fontColor(DEL_RED)
.onClick((): void => {
this.confirmId = v.id;
})
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.backgroundColor(FbColors.surfaceAlt)
.borderRadius(FbMetric.radiusS)
.alignItems(HorizontalAlign.Start)
}
/**
* 一行输入框(**builder 里直接读 `this.fXxx`**,不要按值传进来)。
*
* 🔴 按值传的 `@Builder` 参数**不参与状态观察**:点另一辆车的「编辑」时表单
* 已经渲染着,按值传的 `value` 不会更新 ⇒ 输入框还显示上一辆车的内容。
* ⇒ 传一个 `key`,由 builder 自己 switch 到对应字段(读的是 `this.`)。
*/
@Builder
field(key: string, label: string, placeholder: string) {
Row({ space: 8 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(52)
TextInput({ text: this.fieldValue(key), placeholder: placeholder })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(40)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
this.applyField(key, v);
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
/** 取某个字段的当前值(`field()` 用它;读的是 `this.`,依赖才会被登记) */
private fieldValue(key: string): string {
if (key === 'plate') { return this.fPlate; }
if (key === 'vendor') { return this.fVendor; }
if (key === 'order') { return this.fOrderNo; }
if (key === 'note') { return this.fNote; }
return this.fName;
}
/** 写某个字段(同时清掉错误提示) */
private applyField(key: string, v: string): void {
if (key === 'plate') { this.fPlate = v; return; }
if (key === 'vendor') { this.fVendor = v; return; }
if (key === 'order') { this.fOrderNo = v; return; }
if (key === 'note') { this.fNote = v; return; }
this.fName = v;
this.note = '';
}
build() {
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: 10 }) {
// ---- 标题栏 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('车辆管理')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
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)
Scroll() {
Column({ space: 8 }) {
if (this.loading) {
Text('正在取车辆…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
} else if (this.items.length === 0) {
Text('还没有登记车辆')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
} else {
ForEach(this.items, (v: FbVehicleItem) => {
this.rowOf(v)
}, (v: FbVehicleItem) => v.id.toString())
}
// ---- 内嵌表单(点「添加」或某行的「编辑」才出来)----
if (this.formOpen) {
Column({ space: 8 }) {
this.field('name', '车型', '如「大众朗逸」')
this.field('plate', '车牌', '可空(借的车常记不全)')
Row({ space: 6 }) {
Text('来源')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(52)
ForEach(VTYPES, (k: string) => {
Text(fbVehicleTypeName(k))
.fontSize(FbMetric.fontTiny)
.fontColor(this.fVtype === k ? '#FFFFFF' : FbColors.muted)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.fVtype === k ? this.accent : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.fVtype = k;
})
}, (k: string) => k)
}
.width('100%')
.alignItems(VerticalAlign.Center)
this.field('vendor', '车主/租车', '租车公司 或 车主(可空)')
this.field('order', '订单号', '租车订单号(可空)')
this.field('note', '备注', '可空')
Row({ space: 8 }) {
Column({ space: 1 }) {
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.fDefault })
.selectedColor(this.accent)
.onChange((on: boolean): void => {
this.fDefault = on;
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '添加这辆车'))
.fontSize(FbMetric.fontBody)
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98))
.onClick((): void => {
this.save();
})
}
.width('100%')
}
.width('100%')
.padding(10)
.backgroundColor(FbColors.surfaceAlt)
.borderRadius(FbMetric.radiusS)
.alignItems(HorizontalAlign.Start)
}
if (this.note !== '') {
Text(this.note)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.width('100%')
.maxLines(3)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
// ---- 底部:添加(关闭在右上角 ✕)----
if (!this.formOpen) {
Row() {
Text('添加一辆')
.fontSize(FbMetric.fontBody)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, 0.98))
.onClick((): void => {
this.openNew();
})
}
.width('100%')
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 16 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
.constraintSize({ maxHeight: '84%' })
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
}
@ComponentV2
export struct FbNodeTypeSheet {
@Param accent: string = '#3F7D5A';
@Event onClose: () => void = (): void => {};
@Event onChanged: () => void = (): void => {};
@Local items: FbNodeTypeItem[] = [];
@Local loading: boolean = true;
@Local note: string = '';
@Local editingId: number = 0;
@Local formOpen: boolean = false;
@Local saving: boolean = false;
@Local confirmId: number = 0;
@Local fName: string = '';
@Local fIcon: string = '';
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
this.items = await fetchNodeTypes(FB_TOKEN_STORE.get());
this.loading = false;
}
private openNew(): void {
this.editingId = 0;
this.formOpen = true;
this.note = '';
this.confirmId = 0;
this.fName = '';
this.fIcon = '';
}
private openEdit(t: FbNodeTypeItem): void {
this.editingId = t.id;
this.formOpen = true;
this.note = '';
this.confirmId = 0;
this.fName = t.name;
this.fIcon = t.icon;
}
private async save(): Promise<void> {
if (this.saving) {
return;
}
const name: string = this.fName.trim();
if (name === '') {
this.note = '类型名字不能为空。';
return;
}
this.saving = true;
const d: FbNodeTypeDraft = { name: name, icon: this.fIcon.trim() };
const res: FbApiResult = await saveNodeType(FB_TOKEN_STORE.get(), d, this.editingId);
this.saving = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。';
return;
}
this.formOpen = false;
this.note = '';
this.onChanged();
await this.load();
}
private async remove(id: number): Promise<void> {
const res: FbApiResult = await deleteNodeType(FB_TOKEN_STORE.get(), id);
this.confirmId = 0;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '删除失败。';
return;
}
this.note = '';
this.onChanged();
await this.load();
}
@Builder
rowOf(t: FbNodeTypeItem) {
Row({ space: 8 }) {
Text(t.icon !== '' ? t.icon : '·')
.fontSize(FbMetric.fontBody)
.width(22)
.textAlign(TextAlign.Center)
Column({ space: 1 }) {
Text(t.name)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.maxLines(1)
if (t.isSystem || t.usedCount > 0) {
Text(t.isSystem ? '内置类型(可改名,不能删)' : ('已被 ' + t.usedCount.toString() + ' 段行程用过'))
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (this.confirmId === t.id) {
Text('确认删除')
.fontSize(FbMetric.fontTiny)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(DEL_RED)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.remove(t.id);
})
Text('取消')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.onClick((): void => {
this.confirmId = 0;
})
} else {
Text('改')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.onClick((): void => {
this.openEdit(t);
})
// 🔴 内置类型直接不画删除 —— 画了再被服务端拒是"点了没反应"
if (!t.isSystem) {
Text('删')
.fontSize(FbMetric.fontTiny)
.fontColor(DEL_RED)
.onClick((): void => {
this.confirmId = t.id;
})
}
}
}
.width('100%')
.padding({ left: 10, right: 10, top: 9, bottom: 9 })
.backgroundColor(FbColors.surfaceAlt)
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
}
/** 一行输入框(同 `FbVehicleSheet.field`:builder 里直接读 `this.`,别按值传) */
@Builder
field(key: string, label: string, placeholder: string) {
Row({ space: 8 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(40)
TextInput({ text: key === 'icon' ? this.fIcon : this.fName, placeholder: placeholder })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(40)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
if (key === 'icon') {
this.fIcon = v;
return;
}
this.fName = v;
this.note = '';
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
build() {
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: 10 }) {
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('节点类型')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
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)
Scroll() {
Column({ space: 8 }) {
if (this.loading) {
Text('正在取类型…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
} else {
ForEach(this.items, (t: FbNodeTypeItem) => {
this.rowOf(t)
}, (t: FbNodeTypeItem) => t.id.toString())
}
if (this.formOpen) {
Column({ space: 8 }) {
this.field('name', '名称', '如「拍照」')
this.field('icon', '图标', '一个表情,如 📷')
Row() {
Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '添加类型'))
.fontSize(FbMetric.fontBody)
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98))
.onClick((): void => {
this.save();
})
}
.width('100%')
}
.width('100%')
.padding(10)
.backgroundColor(FbColors.surfaceAlt)
.borderRadius(FbMetric.radiusS)
.alignItems(HorizontalAlign.Start)
}
if (this.note !== '') {
Text(this.note)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.width('100%')
.maxLines(3)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
if (!this.formOpen) {
Row() {
Text('添加类型')
.fontSize(FbMetric.fontBody)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, 0.98))
.onClick((): void => {
this.openNew();
})
}
.width('100%')
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 16 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
.constraintSize({ maxHeight: '84%' })
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
}
/**
* 出行方式:**只读**。
*
* 🔴 为什么没有增删改:出行方式是服务端的**枚举常量**
* (`Trip::TRAVEL_MODES`),加一项要改代码、要重新部署 ——
* 端上给它做一个"可新增"的表单只会让人以为能加。
* ⇒ 这里就是一份对照表 + 一句"在加行程时选"。
*/
@ComponentV2
export struct FbTravelModesSheet {
@Param accent: string = '#3F7D5A';
@Event onClose: () => void = (): void => {};
build() {
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: 10 }) {
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.onClose();
},
color: this.accent
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(FB_TRAVEL_MODES, (m: FbTravelModeDef) => {
Text(m.name)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.padding({ left: 11, right: 11, top: 6, bottom: 6 })
.backgroundColor(FbColors.surfaceAlt)
.borderRadius(FbMetric.radiusPill)
.margin({ right: 6, bottom: 6 })
}, (m: FbTravelModeDef) => m.key)
}
.width('100%')
Text('这类方式没有车牌/车次信息;自驾想记车牌请到「车辆管理」里登记,'
+ '车次/航班号在加行程时填。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.maxLines(4)
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 16 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
}
@@ -0,0 +1,219 @@
/**
* 家书 FamilyBook · 旅程三项设置的**写操作**(鸿蒙端)
*
* 🔴 v66(用户 2026-10-20):「旅程的……车辆管理、出行方式、节点设置,
* 这些东西都还没完成。」
*
* 三件事的实际情况并不一样,这里按真实情况办:
* · **车辆管理** → `vehicles` 表 + `Vehicle::save/destroy`,端上补增删改;
* · **节点类型** → `node_types` 表 + `NodeType::save/destroy`,端上补增删改;
* · **出行方式** → **枚举常量**(`Trip::TRAVEL_MODES`),根本没有"管理"这回事
* ⇒ 只做一份只读说明 + 提示"在行程里选",**不摆假入口**。
*
* 服务端契约(v66 新增,与网页版 `trips/vehicles.php` / `trips/types.php` 同一套模型):
* GET /api/v1/vehicles 车辆列表(含 used_count)
* POST /api/v1/vehicles 新增
* PATCH /api/v1/vehicles/<id> 改
* DELETE /api/v1/vehicles/<id> 删
* GET /api/v1/nodetypes 节点类型列表
* POST /api/v1/nodetypes 新增
* PATCH /api/v1/nodetypes/<id> 改(**改名会级联到已有行程**,见服务端注释)
* DELETE /api/v1/nodetypes/<id> 删(内置的 / 还在用的会被拒)
*
* ⚠️ 删不掉的两种情况**不是错误**,是服务端的保护:
* · 车辆:"还有 N 段行程在用这辆车"
* · 类型:内置类型 / 还有 N 段行程在用
* ⇒ 端上把 `error` 原文显示出来即可(服务端那句话已经是人话)。
*/
import { fbApi, FbApiResult } from './FbHttp';
import { FbNodeTypeItem, FbVehicleItem } from '../model/Types';
/** 新建/修改车辆时提交的字段(与网页版 `vehicles.php` 的表单对齐) */
export interface FbVehicleDraft {
plate: string;
name: string;
/** `own` / `rental` / `borrowed` */
vtype: string;
vendor: string;
orderNo: string;
isDefault: boolean;
note: string;
}
/** 新建/修改节点类型时提交的字段 */
export interface FbNodeTypeDraft {
name: string;
icon: string;
}
/** 字符串读值(服务端可能给 null) */
function s(v: Object | null | undefined): string {
return typeof v === 'string' ? v : '';
}
/** 数字读值 */
function n(v: Object | null | undefined): number {
return typeof v === 'number' ? v : 0;
}
/** 车辆列表 → 端上类型 */
export function parseVehicles(json: string): FbVehicleItem[] {
let rows: Object[] = [];
try {
const v: Object = JSON.parse(json) as Object;
if (Array.isArray(v)) {
rows = v as Object[];
}
} catch (err) {
return [];
}
const out: FbVehicleItem[] = [];
for (const r of rows) {
const o: Record<string, Object> = r as Record<string, Object>;
const it: FbVehicleItem = {
id: n(o['id'] as number),
plate: s(o['plate'] as string),
name: s(o['name'] as string),
vtype: s(o['vtype'] as string) !== '' ? s(o['vtype'] as string) : 'own',
vendor: s(o['vendor'] as string),
orderNo: s(o['order_no'] as string),
isDefault: n(o['is_default'] as number) > 0,
note: s(o['note'] as string),
// ⚠️ 服务端不一定给 `used_count`(老服务端没有这条接口时会 404,
// 这里拿不到就按 0 处理;删的时候仍由服务端把关)
usedCount: n(o['used_count'] as number)
};
out.push(it);
}
return out;
}
/** 节点类型列表 → 端上类型 */
export function parseNodeTypes(json: string): FbNodeTypeItem[] {
let rows: Object[] = [];
try {
const v: Object = JSON.parse(json) as Object;
if (Array.isArray(v)) {
rows = v as Object[];
}
} catch (err) {
return [];
}
const out: FbNodeTypeItem[] = [];
for (const r of rows) {
const o: Record<string, Object> = r as Record<string, Object>;
const it: FbNodeTypeItem = {
id: n(o['id'] as number),
name: s(o['name'] as string),
icon: s(o['icon'] as string),
isSystem: n(o['is_system'] as number) > 0,
usedCount: n(o['used_count'] as number)
};
if (it.name !== '') {
out.push(it);
}
}
return out;
}
/** 取车辆列表 */
export async function fetchVehicles(token: string): Promise<FbVehicleItem[]> {
const r: FbApiResult = await fbApi('/api/v1/vehicles', { token: token });
if (!r.ok) {
return [];
}
return parseVehicles(r.data);
}
/** 新增 / 修改一辆车(`id > 0` = 改) */
export async function saveVehicle(token: string, d: FbVehicleDraft,
id: number = 0): Promise<FbApiResult> {
/* ⚠️ 请求体一律 `Record<string, Object> = {}` 再逐键赋值 ——
ArkTS 的 `arkts-no-untyped-obj-literals` 不认 Record 字面量
(与 `FbTripRepo.createTrip` 同一写法)。 */
const body: Record<string, Object> = {};
body['plate'] = d.plate;
body['name'] = d.name;
body['vtype'] = d.vtype;
body['vendor'] = d.vendor;
body['order_no'] = d.orderNo;
body['is_default'] = d.isDefault ? 1 : 0;
body['note'] = d.note;
const path: string = id > 0 ? '/api/v1/vehicles/' + id.toString() : '/api/v1/vehicles';
return await fbApi(path, {
method: id > 0 ? 'PATCH' : 'POST',
token: token,
body: body
});
}
/** 删一辆车(服务端会拦"还有行程在用") */
export async function deleteVehicle(token: string, id: number): Promise<FbApiResult> {
return await fbApi('/api/v1/vehicles/' + id.toString(), { method: 'DELETE', token: token });
}
/** 取节点类型列表 */
export async function fetchNodeTypes(token: string): Promise<FbNodeTypeItem[]> {
const r: FbApiResult = await fbApi('/api/v1/nodetypes', { token: token });
if (!r.ok) {
return [];
}
return parseNodeTypes(r.data);
}
/** 新增 / 修改一种节点类型(`id > 0` = 改;改名会级联到已有行程) */
export async function saveNodeType(token: string, d: FbNodeTypeDraft,
id: number = 0): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['name'] = d.name;
body['icon'] = d.icon;
const path: string = id > 0 ? '/api/v1/nodetypes/' + id.toString() : '/api/v1/nodetypes';
return await fbApi(path, {
method: id > 0 ? 'PATCH' : 'POST',
token: token,
body: body
});
}
/** 删一种节点类型(内置 / 还在用的会被拒) */
export async function deleteNodeType(token: string, id: number): Promise<FbApiResult> {
return await fbApi('/api/v1/nodetypes/' + id.toString(), { method: 'DELETE', token: token });
}
/**
* 出行方式(**只读枚举**,与 `app/Trip.php` 的 `TRAVEL_MODES` **同一套 key**)。
*
* ⚠️ 它不是一张表,没有增删改 —— 「出行方式设置」这项在端上就是一份说明:
* 列出这十种、并告诉用户"在加行程时选"。
* 🔴 不要照"车辆管理"的样子给它做一个能新增的表单:服务端根本没这回事,
* 做出来只是又一个"点了没用"的入口。
*/
export interface FbTravelModeDef {
key: string;
name: string;
}
export const FB_TRAVEL_MODES: FbTravelModeDef[] = [
{ key: 'walk', name: '步行' },
{ key: 'drive', name: '自驾' },
{ key: 'bus', name: '公交' },
{ key: 'subway', name: '地铁' },
{ key: 'train', name: '火车' },
{ key: 'plane', name: '飞机' },
{ key: 'ship', name: '轮船' },
{ key: 'taxi', name: '打车' },
{ key: 'bike', name: '骑行' },
{ key: 'other', name: '其他' }
];
/** 车辆来源的中文名(`Vehicle::TYPES` 的镜像) */
export function fbVehicleTypeName(vtype: string): string {
if (vtype === 'rental') {
return '租的';
}
if (vtype === 'borrowed') {
return '借的';
}
return '自家的';
}
+5 -3
View File
@@ -70,9 +70,11 @@ export const FB_SET_TASKS: FbSettingItem[] = [
/** 旅程(templates/trips/_settings_menu.php) */
export const FB_SET_TRIPS: FbSettingItem[] = [
{ icon: 'nav', label: '车辆管理', hint: '自驾时的车与油耗', action: '' },
{ icon: 'trips', label: '出行方式', hint: '飞机 / 高铁 / 自驾…', action: '' },
{ icon: 'dot', label: '节点类型', hint: '行程节点的类型', action: '' }
{ icon: 'nav', label: '车辆管理', hint: '自家的 / 租的 / 借的车', action: 'vehicles' },
/* ⚠️ 出行方式是**枚举常量**(`Trip::TRAVEL_MODES`),没有增删改 ——
这个浮层只是一份对照表 + "加行程时选",**不做成可编辑的表单**。 */
{ icon: 'trips', label: '出行方式', hint: '飞机 / 高铁 / 自驾…(只读)', action: 'travelmodes' },
{ icon: 'dot', label: '节点类型', hint: '游玩 / 用餐 / 赶路…(可自定义)', action: 'nodetypes' }
];
/** 资源(网页版没有独立菜单;这里只放端上真做得到的两个筛选)
+49
View File
@@ -475,6 +475,55 @@ export interface FbTripNode {
expenseCent: number;
}
/**
* 一辆"出行用的车"(`vehicles` 表)。
*
* 🔴 v66(用户 2026-10-20):「车辆管理、出行方式、节点设置,这些东西都还没完成。」
* 网页端早就有这块(`trips/vehicles.php`),端上一直没有入口 ——
* 表在 `Schema.php` 里、模型方法(`Vehicle::save/destroy`)也齐,
* 缺的只是 **API 路由**(v66 补了 `/api/v1/vehicles`)。
*
* ⚠️ 与财务里的「车」是**两回事**(服务端注释写得很清楚):
* 财务那条 `kind='car'` 的账目记的是"这台车值多少钱",没有车牌车型;
* 这里的车才有车牌、车型、是自家/租的/借的。
* 用户 2026-10-05 明确要求选车下拉里**不要**混进财务的车。
*/
export interface FbVehicleItem {
id: number;
/** 车牌(借的/租的可能没记全,允许空) */
plate: string;
/** 车型名,如「大众朗逸」 */
name: string;
/** `own` 自家的 / `rental` 租的 / `borrowed` 借的 */
vtype: string;
/** 租车公司 或 车主 */
vendor: string;
/** 租车订单号 */
orderNo: string;
/** 默认车(新建行程选车时预选它;服务端保证全家只有一台) */
isDefault: boolean;
note: string;
/** 已被多少段行程引用(>0 时服务端不许删,端上先灰掉删除按钮) */
usedCount: number;
}
/**
* 一种行程节点类型(`node_types` 表,可自定义)。
*
* ⚠️ `isSystem` 的内置类型**不许删**(服务端会拒),端上就别画删除按钮 ——
* 画了再报错是"点了没反应 + 弹一句看不懂的话"。
*/
export interface FbNodeTypeItem {
id: number;
name: string;
/** 一个表情符号(可空) */
icon: string;
/** 内置类型:不许删,允许改名/改图标 */
isSystem: boolean;
/** 已被多少段行程用过(>0 时服务端不许删) */
usedCount: number;
}
// ============================================================
// 日程(calendar)
// ============================================================
+90 -2
View File
@@ -31,6 +31,8 @@ import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { fetchTripNodes } from '../../data/FbTripRepo';
import { FbTripNewSheet } from '../../components/FbTripNewSheet';
import { FbTripNodeSheet } from '../../components/FbTripNodeSheet';
/* v66:旅程三项设置(车辆 / 出行方式 / 节点类型) */
import { FbNodeTypeSheet, FbTravelModesSheet, FbVehicleSheet } from '../../components/FbTripsSettingsSheets';
@ComponentV2
export struct FbTripsPage {
@@ -94,6 +96,15 @@ export struct FbTripsPage {
/** 「加一段行程」浮层是否打开,以及它属于哪一趟旅程(0 = 关着) */
@Local nodeSheetTripId: number = 0;
/**
* 🔴 v66(用户 2026-10-20):「旅程的……车辆管理、出行方式、节点设置,
* 这些东西都还没完成。」
* 三项设置各管一个开关(同时只会开一个 —— 设置菜单点一项就关菜单再开它)。
*/
@Local vehiclesOpen: boolean = false;
@Local travelModesOpen: boolean = false;
@Local nodeTypesOpen: boolean = false;
/**
* 页面出现:先缓存、再网络(与观影/名册同一套"本地优先"流程)。
*
@@ -117,6 +128,20 @@ export struct FbTripsPage {
this.nodeSheetTripId = 0;
return true;
}
/* v66:三项设置浮层。⚠️ 内部还有"表单展开 / 待确认删除"两层,
按返回先由浮层自己处理(组件的遮罩 onClick 也会关)—— 这里只负责关整层。 */
if (this.vehiclesOpen) {
this.vehiclesOpen = false;
return true;
}
if (this.travelModesOpen) {
this.travelModesOpen = false;
return true;
}
if (this.nodeTypesOpen) {
this.nodeTypesOpen = false;
return true;
}
if (this.newOpen) {
this.newOpen = false;
return true;
@@ -178,6 +203,28 @@ export struct FbTripsPage {
fbAppState().sheetCount = this.nodeSheetTripId > 0 ? 1 : 0;
}
/**
* 三个设置浮层同样要让悬浮栏让位。
*
* ⚠️ 三个 `@Monitor` 各写一个(V2 里一个 `@Monitor` 只能盯一个变量);
* 它们**不会同时为真**(设置菜单点一项就关菜单、只开一个浮层),
* 所以每次都直接赋值即可 —— 不用"有几个开着就加几"。
*/
@Monitor('vehiclesOpen')
onVehiclesChanged(): void {
fbAppState().sheetCount = this.vehiclesOpen ? 1 : 0;
}
@Monitor('travelModesOpen')
onTravelModesChanged(): void {
fbAppState().sheetCount = this.travelModesOpen ? 1 : 0;
}
@Monitor('nodeTypesOpen')
onNodeTypesChanged(): void {
fbAppState().sheetCount = this.nodeTypesOpen ? 1 : 0;
}
build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
@@ -282,8 +329,18 @@ export struct FbTripsPage {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
/* 🔴 v66:三项设置终于接上了(原来这里的注释写着"目前都还没有")。
⚠️ 先关设置菜单再开目标浮层:两个浮层叠在一起时,
`sheetCount` 会被后赋值的那个覆盖,悬浮栏让位就乱了。
(本页每个 @Monitor 都是"直接赋值",不是累加。) */
this.settingsOpen = false;
if (action === 'vehicles') {
this.vehiclesOpen = true;
} else if (action === 'travelmodes') {
this.travelModesOpen = true;
} else if (action === 'nodetypes') {
this.nodeTypesOpen = true;
}
}
})
.transition(fbSheetTransition())
@@ -327,6 +384,37 @@ export struct FbTripsPage {
})
.transition(fbSheetTransition())
}
// ---- 旅程三项设置浮层(v66)----
if (this.vehiclesOpen) {
FbVehicleSheet({
accent: this.accent,
onClose: (): void => {
this.vehiclesOpen = false;
}
})
.transition(fbSheetTransition())
}
if (this.travelModesOpen) {
FbTravelModesSheet({
accent: this.accent,
onClose: (): void => {
this.travelModesOpen = false;
}
})
.transition(fbSheetTransition())
}
if (this.nodeTypesOpen) {
FbNodeTypeSheet({
accent: this.accent,
onClose: (): void => {
this.nodeTypesOpen = false;
}
})
.transition(fbSheetTransition())
}
}
.width('100%')
.height('100%')