/** * 家书 FamilyBook · 「新建旅程」浮层(鸿蒙端) * * 对应网页版 `templates/trips/form.php`(新建那一路)。 * * 🔴 为什么单独做这一批:端上原来的「新建旅程」按钮**根本没接 `onTap`** * (`FbTripsPage` 里 `FbPrimaryButton({ label: '新建旅程', ... })` 没有回调), * 点了什么都不发生 —— 用户报的"新建旅程没完成"就是这一条。 * * ⚠️ 表单只放**网页版真有的三项**: * 标题 / 简介 / 是否公开。**没有起止时间、没有城市** —— * 服务端 `Trip::create()` 显式忽略它们,城市与时间一律从行程节点**派生** * (见 `Trip::derived()`)。加个"时间"输入框只会让人以为填了有用。 * * ⚠️ 题图这一批**不做**:网页版走 `cover` 文件上传 + `cover_crop` 裁剪, * 而服务端目前只有观影的 `/movies//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 { 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) } }