diff --git a/entry/src/main/ets/components/FbTripsSettingsSheets.ets b/entry/src/main/ets/components/FbTripsSettingsSheets.ets new file mode 100644 index 0000000..ed516ff --- /dev/null +++ b/entry/src/main/ets/components/FbTripsSettingsSheets.ets @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/entry/src/main/ets/data/FbTripSettingsRepo.ets b/entry/src/main/ets/data/FbTripSettingsRepo.ets new file mode 100644 index 0000000..3378d02 --- /dev/null +++ b/entry/src/main/ets/data/FbTripSettingsRepo.ets @@ -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/ 改 + * DELETE /api/v1/vehicles/ 删 + * GET /api/v1/nodetypes 节点类型列表 + * POST /api/v1/nodetypes 新增 + * PATCH /api/v1/nodetypes/ 改(**改名会级联到已有行程**,见服务端注释) + * DELETE /api/v1/nodetypes/ 删(内置的 / 还在用的会被拒) + * + * ⚠️ 删不掉的两种情况**不是错误**,是服务端的保护: + * · 车辆:"还有 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 = r as Record; + 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 = r as Record; + 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 { + 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 { + /* ⚠️ 请求体一律 `Record = {}` 再逐键赋值 —— + ArkTS 的 `arkts-no-untyped-obj-literals` 不认 Record 字面量 + (与 `FbTripRepo.createTrip` 同一写法)。 */ + const body: Record = {}; + 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 { + return await fbApi('/api/v1/vehicles/' + id.toString(), { method: 'DELETE', token: token }); +} + +/** 取节点类型列表 */ +export async function fetchNodeTypes(token: string): Promise { + 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 { + const body: Record = {}; + 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 { + 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 '自家的'; +} diff --git a/entry/src/main/ets/model/FbSettingsMenus.ets b/entry/src/main/ets/model/FbSettingsMenus.ets index 1a91ea6..b5f3d31 100644 --- a/entry/src/main/ets/model/FbSettingsMenus.ets +++ b/entry/src/main/ets/model/FbSettingsMenus.ets @@ -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' } ]; /** 资源(网页版没有独立菜单;这里只放端上真做得到的两个筛选) diff --git a/entry/src/main/ets/model/Types.ets b/entry/src/main/ets/model/Types.ets index 66e70dc..ec4c9ea 100644 --- a/entry/src/main/ets/model/Types.ets +++ b/entry/src/main/ets/model/Types.ets @@ -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) // ============================================================ diff --git a/entry/src/main/ets/pages/trips/FbTripsPage.ets b/entry/src/main/ets/pages/trips/FbTripsPage.ets index 5fbfd1a..3581e0f 100644 --- a/entry/src/main/ets/pages/trips/FbTripsPage.ets +++ b/entry/src/main/ets/pages/trips/FbTripsPage.ets @@ -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%')