/** * 家书 FamilyBook · 「新建旅程」浮层(鸿蒙端) * * 对应网页版 `templates/trips/form.php`(新建那一路)。 * * 🔴 为什么单独做这一批:端上原来的「新建旅程」按钮**根本没接 `onTap`** * (`FbTripsPage` 里 `FbPrimaryButton({ label: '新建旅程', ... })` 没有回调), * 点了什么都不发生 —— 用户报的"新建旅程没完成"就是这一条。 * * ⚠️ 表单只放**网页版真有的三项**: * 标题 / 简介 / 是否公开(具体城市与起止时间**不在**这里 —— * 服务端 `Trip::create()` 显式忽略它们,城市与时间一律从行程节点**派生**, * 见 `Trip::derived()`)。 * * 🔴🔴 v77(用户 2026-10-22):「新建旅程那里不是有题图吗? * 为什么这里没有增加题图的功能呢?」 * ⇒ 补上题图。之前没做是因为**服务端没有旅程的图片上传槽位** * (只有观影的 `/movies//poster`),现在服务端加了 * `POST /api/v1/trips//cover`(槽位 `cover`,1920×1080 / 16:9), * 与网页版 `trips/form.php` 走**同一条** `Upload::image` 管线。 * ⚠️ 端上**不裁剪**:裁/缩/转 JPEG 全在服务端,否则"改造过的客户端" * 能存进非 16:9 的图,网页版与端上显示比例就不一致了。 * ⚠️ 新建时还没有 id ⇒ 流程是「先建 → 拿 id → 再传图」。 * 传图失败**不算创建失败**(记录已经落库了),所以要把 id 记住, * 再点一次只重传题图,不会建出两趟一样的旅程。 * ⚠️ 暂时**不给"移除题图"**:服务端还没有"清空题图"的端点, * 摆一颗点了不生效的按钮是本项目的红线。 */ 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, uploadTripCover, tripIdOf, FbTripDraft } from '../data/FbTripRepo'; import { FbApiResult } from '../data/FbHttp'; import { fbImageSrc } from '../data/FbAttachQueue'; import { picker } from '@kit.CoreFileKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; @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; /** * 编辑时已有的题图(**裸 key**,如 `covers/xxx.jpg`;空串 = 还没设题图)。 * * ⚠️ 传裸 key 而不是完整 URL:完整 URL 带令牌、会过期, * 而且端上统一走 `fbImageSrc()` 拼(见 `FbAttachQueue`)。 */ @Param editingCoverKey: string = ''; @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 = ''; /** 刚选的题图 URI(`file://…`;空串 = 这次没换图) */ @Local pickedCover: string = ''; /** 老题图加载失败(别每次都重试) */ @Local coverFailed: boolean = false; /** * 已经建好的旅程 id(新建流程用)。 * * ⚠️ 为什么要记住:题图上传失败时记录**已经落库**了, * 这时再点一次保存不能再 `POST /trips`(会建出两趟一样的旅程), * 只能接着补传图 —— 所以要把 id 存下来。 */ @Local createdId: number = 0; 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) /* ---- 可滚区:题图 + 名字 + 简介 + 公开 ---- ⚠️ v77 加了题图(16:9 ≈ 186vp 高)之后,整张浮层在小屏上会顶出 屏幕(键盘一弹更糟)⇒ 与「加一段行程」浮层同一套处理: 中间这块给 `Scroll` + `maxHeight`,底部保存按钮永远可见。 ⚠️ 不写 `maxHeight` 的话,"内容不多"时浮层还是按内容自适应高度, 不会变成一块留白的死板子。 */ Scroll() { Column({ space: 12 }) { // ---- 题图(v77:点整块选区,选完立刻有预览)---- this.coverPicker() // ---- 旅程名(必填)---- 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 }) } .width('100%') .alignItems(HorizontalAlign.Start) } .constraintSize({ maxHeight: 430 }) .width('100%') .scrollBar(BarState.Off) .align(Alignment.TopStart) // ---- 错误 / 提示 ---- 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` 在这里是真正的功能,不是装饰。 * * 🔴 v77 的两段式:**先落库、再传图**。 * ① 新建:`POST /trips` 拿到 id(记进 `createdId`)→ 再 `POST /trips//cover`; * ② 编辑:`PATCH /trips/` → 再传图。 * ⚠️ 传图失败**不撤销**旅程(记录已经存在,删掉反而更糟), * 而是把错误说清楚 + 记住 id ⇒ 用户再点一次只重传图。 */ 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 }; /* ---- ① 旅程本体:上次只差传图(`createdId > 0`)就不要再建一条 ---- */ let id: number = this.editingId > 0 ? this.editingId : this.createdId; if (id <= 0) { const res: FbApiResult = await createTrip(token, d); if (!res.ok) { this.saving = false; this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。'; return; } id = tripIdOf(res); this.createdId = id; } else if (this.editingId > 0) { /* 编辑:标题 / 简介 / 公开每次都要重写(用户可能刚改过) */ const res: FbApiResult = await updateTrip(token, this.editingId, d); if (!res.ok) { this.saving = false; this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。'; return; } } /* ---- ② 题图(这次没换图就跳过)---- */ if (id > 0 && this.pickedCover !== '') { const err: string = await uploadTripCover(token, id, this.pickedCover); if (err !== '') { this.saving = false; this.note = '旅程已经保存好了,但题图没传上去:' + err + '。再点一次可以只重传题图。'; return; } } this.saving = false; this.onSaved(); } /** * 这张图到底有没有(决定显示预览还是"点这里选题图")。 * * ⚠️ 优先级是**刚选的** > 服务端已有的:刚选的那张一定是用户眼前想要的, * 此时老图还没被替换(要等保存后才传上去)。 */ private hasCover(): boolean { if (this.pickedCover !== '') { return true; } return this.editingCoverKey !== '' && !this.coverFailed; } /** * 选题图(系统图库选择器)。 * * ⚠️ 用 `photoViewPicker` 而不是相机:选图不需要额外权限, * 也不会因为没授相机权限卡住(与换头像同一套)。 * ⚠️ 这里**只记 URI、不上传** —— 新建时还没有 id, * 统一在 `save()` 里"先建后传"。 */ private async pickCover(): Promise { try { const opts: picker.PhotoSelectOptions = new picker.PhotoSelectOptions(); opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; opts.maxSelectNumber = 1; const p: picker.PhotoViewPicker = new picker.PhotoViewPicker(); const sel: picker.PhotoSelectResult = await p.select(opts); if (sel.photoUris.length === 0) { return; // 用户取消了 } this.pickedCover = sel.photoUris[0]; this.note = ''; } catch (err) { const e: BusinessError = err as BusinessError; hilog.warn(0x0000, 'FamilyBook', '选题图失败:%{public}s', JSON.stringify(e)); this.note = '选图失败,请再试一次。'; } } /** * 题图选择块(16:9,与 `ImageSlot` 的 `cover` 槽位一致)。 * * ⚠️ 整块可点:手机上"点一张大图换一张大图"比"去找一个小按钮"自然得多。 * ⚠️ 预览用 `Cover`:槽位是按 16:9 裁好的,`Cover` 正好铺满不留边 * (只有查看器那种"要看清整张"的场合才用 `Contain`)。 */ @Builder coverPicker() { Column({ space: 6 }) { Row({ space: 6 }) { Text('题图') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .layoutWeight(1) Text(this.hasCover() ? '换一张' : '选一张') .fontSize(FbMetric.fontTiny) .fontColor(this.accent) } .width('100%') .alignItems(VerticalAlign.Center) Stack({ alignContent: Alignment.Center }) { Column() .width('100%') .height('100%') .backgroundColor('#F1E7D6') if (this.pickedCover !== '') { Image(this.pickedCover) .width('100%') .height('100%') .objectFit(ImageFit.Cover) } else if (this.editingCoverKey !== '' && !this.coverFailed) { Image(fbImageSrc(this.editingCoverKey, FB_TOKEN_STORE.get())) .width('100%') .height('100%') .objectFit(ImageFit.Cover) .onError((): void => { this.coverFailed = true; }) } if (!this.hasCover()) { Column({ space: 4 }) { Text('+') .fontSize(20) .fontColor('#A8988A') Text('点这里选题图') .fontSize(FbMetric.fontTiny) .fontColor('#A8988A') Text('16:9,上传后按这个比例裁好') .fontSize(10) .fontColor('#B8AA9C') } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } } .width('100%') .aspectRatio(16 / 9) .borderRadius(FbMetric.radiusS) .clip(true) .onClick((): void => { this.pickCover(); }) } .width('100%') .alignItems(HorizontalAlign.Start) } /** 单行输入 */ @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) } }