diff --git a/entry/src/main/ets/components/FbPersonEditSheet.ets b/entry/src/main/ets/components/FbPersonEditSheet.ets index 9b731d2..3deb5eb 100644 --- a/entry/src/main/ets/components/FbPersonEditSheet.ets +++ b/entry/src/main/ets/components/FbPersonEditSheet.ets @@ -27,10 +27,9 @@ import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbSheetRing } from './FbSheetRing'; import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; -import { FbApiOptions, FbApiResult, fbApi, fbApiBase } from '../data/FbHttp'; +import { FbApiOptions, FbApiResult, fbApi } from '../data/FbHttp'; +import { fbUploadOne } from '../data/FbUpload'; import { FbAvatar } from './Widgets'; -import { http } from '@kit.NetworkKit'; -import { fileIo } from '@kit.CoreFileKit'; import { picker } from '@kit.CoreFileKit'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; @@ -1256,55 +1255,17 @@ export struct FbPersonEditSheet { } /** - * 真正上传(手工拼 multipart)。 + * 真正上传头像。 * - * ⚠️ HarmonyOS 的 `http` 没有现成的 multipart 封装 ⇒ 手写 boundary 与分片。 - * 结构就是标准 multipart/form-data:`--boundary\r\n头\r\n\r\n体\r\n--boundary--`。 + * 🔴 v77(2026-10-22):multipart 的实现**搬去了 `data/FbUpload.ets`**。 + * 在这之前这段代码在三个地方各有一份(头像 / 海报 / 观影照片), + * 而旅程题图又要用第四份 —— 再抄一遍必然漂移。 + * ⇒ 现在只有一份 `fbUploadOne()`,这里只是带上门路与字段名。 + * ⚠️ 字段名 `avatar` 必须与服务端读的那个键一致(`ApiHandler::people`)。 * @returns 空串 = 成功;否则是错误说明 */ private async uploadAvatar(id: number, uri: string, token: string): Promise { - const url: string = fbApiBase() + '/api/v1/people/' + id.toString() + '/avatar'; - // 读文件(picker 给的是 file:// URI,用 fileIo 读成 ArrayBuffer) - const file: fileIo.File = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY); - const stat: fileIo.Stat = fileIo.statSync(file.fd); - const buf: ArrayBuffer = new ArrayBuffer(stat.size); - fileIo.readSync(file.fd, buf); - fileIo.closeSync(file); - - const boundary: string = '----FbBoundary' + Date.now().toString(); - const head: string = '--' + boundary + '\r\n' - + 'Content-Disposition: form-data; name="avatar"; filename="avatar.jpg"\r\n' - + 'Content-Type: image/jpeg\r\n\r\n'; - const tail: string = '\r\n--' + boundary + '--\r\n'; - - const req: http.HttpRequest = http.createHttp(); - try { - const resp: http.HttpResponse = await req.request(url, { - method: http.RequestMethod.POST, - header: { - 'Content-Type': 'multipart/form-data; boundary=' + boundary, - 'Authorization': 'Bearer ' + token, - 'Accept': 'application/json' - }, - // ⚠️ 顺序必须是 head(字符串) → 文件(ArrayBuffer) → tail(字符串), - // 服务端按这个顺序解析出一个 avatar 分片。 - extraData: [head, buf, tail], - connectTimeout: 20000, - readTimeout: 30000, - expectDataType: http.HttpDataType.STRING - }); - if (resp.responseCode >= 200 && resp.responseCode < 300) { - return ''; - } - const body: string = typeof resp.result === 'string' ? resp.result : ''; - return 'HTTP ' + resp.responseCode.toString() + ' ' + body.substring(0, 120); - } catch (err) { - const e: BusinessError = err as BusinessError; - hilog.warn(DOMAIN, TAG, '头像上传异常:%{public}s', JSON.stringify(e)); - return '网络异常'; - } finally { - req.destroy(); - } + return await fbUploadOne('/api/v1/people/' + id.toString() + '/avatar', 'avatar', uri, token); } /** diff --git a/entry/src/main/ets/components/FbTripNewSheet.ets b/entry/src/main/ets/components/FbTripNewSheet.ets index db924ae..8fedfa9 100644 --- a/entry/src/main/ets/components/FbTripNewSheet.ets +++ b/entry/src/main/ets/components/FbTripNewSheet.ets @@ -8,23 +8,35 @@ * 点了什么都不发生 —— 用户报的"新建旅程没完成"就是这一条。 * * ⚠️ 表单只放**网页版真有的三项**: - * 标题 / 简介 / 是否公开。**没有起止时间、没有城市** —— - * 服务端 `Trip::create()` 显式忽略它们,城市与时间一律从行程节点**派生** - * (见 `Trip::derived()`)。加个"时间"输入框只会让人以为填了有用。 + * 标题 / 简介 / 是否公开(具体城市与起止时间**不在**这里 —— + * 服务端 `Trip::create()` 显式忽略它们,城市与时间一律从行程节点**派生**, + * 见 `Trip::derived()`)。 * - * ⚠️ 题图这一批**不做**:网页版走 `cover` 文件上传 + `cover_crop` 裁剪, - * 而服务端目前只有观影的 `/movies//poster` 上传槽位,没有旅程的。 - * ⇒ 新建时先不传图;列表卡仍会显示已有数据的题图。 - * (补上传端点时要与网页版 `ImageSlot` 的 `cover` 槽位比例 16:9 一致, - * 否则宽高比会和网页端不一样 —— 那条在 app.css 里有专门的断言比对。) + * 🔴🔴 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, FbTripDraft } from '../data/FbTripRepo'; +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 { @@ -40,6 +52,13 @@ export struct FbTripNewSheet { @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 => {}; /** 保存成功:父页面据此刷新列表 */ @@ -51,6 +70,18 @@ export struct FbTripNewSheet { @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; @@ -100,41 +131,60 @@ export struct FbTripNewSheet { .width('100%') .alignItems(VerticalAlign.Center) - // ---- 旅程名(必填)---- - this.textField('旅程名', '如「青岛·崂山 两日」', this.title, - (v: string): void => { - this.title = v; - this.note = ''; - }) + /* ---- 可滚区:题图 + 名字 + 简介 + 公开 ---- + ⚠️ v77 加了题图(16:9 ≈ 186vp 高)之后,整张浮层在小屏上会顶出 + 屏幕(键盘一弹更糟)⇒ 与「加一段行程」浮层同一套处理: + 中间这块给 `Scroll` + `maxHeight`,底部保存按钮永远可见。 + ⚠️ 不写 `maxHeight` 的话,"内容不多"时浮层还是按内容自适应高度, + 不会变成一块留白的死板子。 */ + Scroll() { + Column({ space: 12 }) { + // ---- 题图(v77:点整块选区,选完立刻有预览)---- + this.coverPicker() - // ---- 简介 ---- - this.areaField('简介', '这趟出行想记的几句话(可空)', this.summary, - (v: string): void => { - this.summary = v; - }) + // ---- 旅程名(必填)---- + this.textField('旅程名', '如「青岛·崂山 两日」', this.title, + (v: string): void => { + this.title = v; + this.note = ''; + }) - // ---- 是否公开 ---- - Row({ space: 8 }) { - Column({ space: 2 }) { - Text('在列表里公开') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.ink) - Text('不公开的旅程不会出现在列表里') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) + // ---- 简介 ---- + 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) - .layoutWeight(1) - - Toggle({ type: ToggleType.Switch, isOn: this.isPublic }) - .selectedColor(this.accent) - .onChange((on: boolean): void => { - this.isPublic = on; - }) } + .constraintSize({ maxHeight: 430 }) .width('100%') - .alignItems(VerticalAlign.Center) - .padding({ top: 2, bottom: 2 }) + .scrollBar(BarState.Off) + .align(Alignment.TopStart) // ---- 错误 / 提示 ---- if (this.note !== '') { @@ -180,6 +230,12 @@ export struct FbTripNewSheet { * * ⚠️ 保存中要**挡住重复点击**:连点两下会建出两趟一模一样的旅程 * (服务端不去重)。`this.saving` 在这里是真正的功能,不是装饰。 + * + * 🔴 v77 的两段式:**先落库、再传图**。 + * ① 新建:`POST /trips` 拿到 id(记进 `createdId`)→ 再 `POST /trips//cover`; + * ② 编辑:`PATCH /trips/` → 再传图。 + * ⚠️ 传图失败**不撤销**旅程(记录已经存在,删掉反而更糟), + * 而是把错误说清楚 + 记住 id ⇒ 用户再点一次只重传图。 */ private async save(): Promise { if (this.saving) { @@ -198,17 +254,154 @@ export struct FbTripNewSheet { 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; + + /* ---- ① 旅程本体:上次只差传图(`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) { diff --git a/entry/src/main/ets/components/FbTripNodeSheet.ets b/entry/src/main/ets/components/FbTripNodeSheet.ets index 57f5aa2..5e8f78f 100644 --- a/entry/src/main/ets/components/FbTripNodeSheet.ets +++ b/entry/src/main/ets/components/FbTripNodeSheet.ets @@ -16,6 +16,16 @@ * `from_city`/`to_city`,但网页版的新建表单里这两项是可选的补充, * 端上第一批先只做主干(标题/地点/时间/类型/方式), * 城市仍能通过「地点」体现(列表里会用 place 显示)。 + * + * 🔴 v77(用户 2026-10-22 问「出行方式需不需要增加或者修改」): + * 「方式列表」本身**不能增删**(十种写死在 `Trip::TRAVEL_MODES`,网页版 + * 也一样只能看)—— 但端上这表单确实缺了两样,缺了就让"出行方式"变得没用: + * ① **车次 / 航班号**(火车/飞机/轮船/公交/地铁)—— 网页版一直有, + * 端上没有 ⇒ 手机上记的行程永远看不到 G1234 这种东西; + * ② **车牌**(自驾)—— 从「车辆管理」里选一台,服务端按 `vehicle_id` + * join 出 `vehicle_plate`。 + * ⇒ 下面按选中的方式**条件出现**,选「不填」或走路/骑行时一律不显示 + * (摆一堆用不上的输入框,用户只会以为是自己没填对)。 */ import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme'; @@ -24,6 +34,8 @@ import { FbSheetRing } from './FbSheetRing'; import { FbDateTimeRow, FbDateTimePicker, fbIsoOf, fbJoinDateTime } from './FbDateTimeField'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { addTripNode, FbTripNodeDraft } from '../data/FbTripRepo'; +import { fetchVehicles } from '../data/FbTripSettingsRepo'; +import { FbVehicleItem } from '../model/Types'; import { FbApiResult } from '../data/FbHttp'; /** 节点类型(⚠️ 与 `app/Trip.php` 的 `NODE_TYPES` 保持一致) */ @@ -82,6 +94,12 @@ export struct FbTripNodeSheet { @Local place: string = ''; @Local nodeType: string = 'play'; @Local travelMode: string = ''; + /** 车次 / 航班号(v77) */ + @Local tripNo: string = ''; + /** 自驾选中的车(`vehicles.id`,0 = 没选) */ + @Local vehicleId: number = 0; + /** 车辆库(自驾时才用得上;取不到就空数组,界面会给出说明) */ + @Local vehicles: FbVehicleItem[] = []; /** 'plan' 计划 / 'actual' 实际 */ @Local category: string = 'plan'; /** 日期 `YYYY-MM-DD` */ @@ -97,6 +115,51 @@ export struct FbTripNodeSheet { this.date = fbIsoOf(new Date()); /* 入口指定的阶段(详情页两颗按钮)—— 只认这两个值,别的一律按计划 */ this.category = this.startStage === 'actual' ? 'actual' : 'plan'; + /* 车辆库:只有"自驾"才用得上,但一次请求很便宜,先取好 —— + 选到自驾时立刻就能列出来,不用再等一次网络。 */ + this.loadVehicles(); + } + + private async loadVehicles(): Promise { + const rows: FbVehicleItem[] = await fetchVehicles(FB_TOKEN_STORE.get()); + this.vehicles = rows; + } + + /** + * 这个出行方式要不要额外填点什么。 + * + * @returns `'code'` 车次/航班号 · `'plate'` 车牌(自驾)· `''` 不用填 + */ + private extraKind(): string { + const m: string = this.travelMode; + if (m === 'drive') { + return 'plate'; + } + if (m === 'train' || m === 'plane' || m === 'ship' || m === 'bus' || m === 'subway') { + return 'code'; + } + return ''; + } + + /** 车次那个框的标签 / 占位(不同方式叫法不同,别统一写"车次") */ + private codeLabel(): string { + if (this.travelMode === 'plane') { + return '航班号'; + } + if (this.travelMode === 'ship') { + return '航次'; + } + return '车次'; + } + + private codeHint(): string { + if (this.travelMode === 'plane') { + return '如「CA1858」'; + } + if (this.travelMode === 'ship') { + return '如「渤海轮渡」'; + } + return '如「G1234」'; } build() { @@ -193,6 +256,55 @@ export struct FbTripNodeSheet { .width('100%') .alignItems(HorizontalAlign.Start) + /* ---- 车次 / 航班号(v77:火车、飞机、轮船、公交、地铁)---- + ⚠️ 只在选中这些方式时出现 —— 摆一堆用不上的输入框, + 用户只会以为是自己没填对。 */ + if (this.extraKind() === 'code') { + this.textField(this.codeLabel(), this.codeHint(), this.tripNo, + (v: string): void => { + this.tripNo = v; + }) + } + + /* ---- 车牌(v77:自驾,从车辆库选一台)---- + ⚠️ 这里是**选车**而不是手打车牌:服务端靠 `vehicle_id` + 去 join 出车牌,手打一个字符串进 `trip_no` 详情页根本不显示 + (那条路只有火车/飞机用)。与其让人白填,不如明说去登记。 */ + if (this.extraKind() === 'plate') { + Column({ space: 6 }) { + Text('车牌') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + if (this.vehicles.length === 0) { + Text('车辆库里还没有车 —— 到「旅程设置 → 车辆管理」加一辆,这里就能选') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .lineHeight(17) + .width('100%') + } else { + Scroll() { + Row({ space: 8 }) { + ForEach(this.vehicles, (v: FbVehicleItem) => { + this.textChip( + (v.name !== '' ? v.name : '我的车') + ' ' + v.plate, + this.vehicleId === v.id, + (): void => { + // 再点一次 = 取消选择(别让人选错了没法退) + this.vehicleId = this.vehicleId === v.id ? 0 : v.id; + }) + }, (v: FbVehicleItem): string => 'veh_' + v.id.toString()) + } + .padding({ right: 4 }) + } + .scrollable(ScrollDirection.Horizontal) + .scrollBar(BarState.Off) + .width('100%') + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + // ---- 计划 / 实际 ---- this.chipRow('阶段', [ { key: 'plan', label: '计划', icon: '🗓' }, @@ -286,6 +398,10 @@ export struct FbTripNodeSheet { endsAt: '', nodeType: this.nodeType, travelMode: this.travelMode, + /* 🔴 v77:只提交**当前方式真正用到**的那一项 —— + 切到"自驾"之后又切回"火车"时,残留的车牌不该跟着存上去。 */ + tripNo: this.extraKind() === 'code' ? this.tripNo.trim() : '', + vehicleId: this.extraKind() === 'plate' ? this.vehicleId : 0, category: this.category, detail: '' }; @@ -332,6 +448,26 @@ export struct FbTripNodeSheet { }) } + /** + * 纯文字 chip(v77:车辆列表用)。 + * + * ⚠️ 与 `chip()` 分开而不是复用它:那个要 `FbNodeTypeOpt`(带 icon), + * 车辆是「名字 + 车牌」,塞进那个结构要现造一个假对象。 + */ + @Builder + textChip(label: string, on: boolean, onTap: () => void) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(on ? '#FFFFFF' : FbColors.muted) + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .backgroundColor(on ? this.accent : FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .maxLines(1) + .onClick((): void => { + onTap(); + }) + } + /** 单行输入 */ @Builder textField(label: string, placeholder: string, value: string, onChange: (v: string) => void) { diff --git a/entry/src/main/ets/components/FbTripsSettingsSheets.ets b/entry/src/main/ets/components/FbTripsSettingsSheets.ets index ed516ff..a30833d 100644 --- a/entry/src/main/ets/components/FbTripsSettingsSheets.ets +++ b/entry/src/main/ets/components/FbTripsSettingsSheets.ets @@ -783,7 +783,7 @@ export struct FbTravelModesSheet { .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) - Text('下面是固定的几种,加行程时选') + Text('十种方式是固定的,不能增删(网页版也一样)') .fontSize(FbMetric.fontTiny) .fontColor(this.accent) } @@ -814,8 +814,8 @@ export struct FbTravelModesSheet { } .width('100%') - Text('这类方式没有车牌/车次信息;自驾想记车牌请到「车辆管理」里登记,' - + '车次/航班号在加行程时填。') + Text('自驾的车牌在「车辆管理」里登记;车次 / 航班号在加行程时填。' + + '网页版这页能做的是"每种方式的常用信息",端上暂未接。') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width('100%') diff --git a/entry/src/main/ets/data/FbMovieRepo.ets b/entry/src/main/ets/data/FbMovieRepo.ets index 8bd8081..e36ab84 100644 --- a/entry/src/main/ets/data/FbMovieRepo.ets +++ b/entry/src/main/ets/data/FbMovieRepo.ets @@ -20,12 +20,10 @@ */ import { relationalStore } from '@kit.ArkData'; -import { http } from '@kit.NetworkKit'; -import { fileIo } from '@kit.CoreFileKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; -import { BusinessError } from '@kit.BasicServicesKit'; import { FbDb, FbRow } from './FbDb'; -import { FbApiOptions, FbApiResult, fbApi, fbApiBase } from './FbHttp'; +import { FbApiOptions, FbApiResult, fbApi } from './FbHttp'; +import { fbUploadOne } from './FbUpload'; import { FB_TOKEN_STORE } from './FbTokenStore'; const DOMAIN: number = 0x0000; @@ -1051,7 +1049,7 @@ export class FbMovieRepo { * @returns 空串 = 成功;否则是错误说明 */ async uploadPoster(id: number, uri: string, token: string): Promise { - return uploadOne('/api/v1/movies/' + id.toString() + '/poster', 'poster', uri, token); + return fbUploadOne('/api/v1/movies/' + id.toString() + '/poster', 'poster', uri, token); } /** @@ -1065,7 +1063,7 @@ export class FbMovieRepo { * @returns 空串 = 成功;否则是错误说明 */ async uploadPhoto(id: number, uri: string, token: string): Promise { - return uploadOne('/api/v1/movies/' + id.toString() + '/photos', 'photo', uri, token); + return fbUploadOne('/api/v1/movies/' + id.toString() + '/photos', 'photo', uri, token); } async watermark(table: string): Promise { @@ -1330,93 +1328,6 @@ function strField(o: Record, key: string): string { return typeof v === 'string' ? v : ''; } -/** - * 上传一个文件到某个观影子资源(**手工拼 multipart**)。 - * - * 🔴 与 `FbPersonEditSheet.uploadAvatar` 是同一套写法(HarmonyOS 的 `http` - * 没有现成的 multipart 封装,得手写 boundary 与分片)。 - * ⚠️ 抽到这里而不是在表单里再写一遍:那个方法是 `private` 且绑定了 - * "头像"这个字段名/URL,观影这边是两个不同端点、两个字段名 —— - * 复制一份必然漂移(本项目为"两份实现漂移"栽过好几次)。 - * - * ⚠️ 顺序必须是 head(字符串) → 文件(ArrayBuffer) → tail(字符串): - * 服务端按这个顺序解析出一个分片。 - * ⚠️ `filename` 给固定值(`upload.jpg`):服务端**不信客户端文件名** - * (`Upload::image` 自己生成随机名 + finfo 嗅探真实 MIME), - * 这里给什么都不影响结果 —— 但必须给,否则 multipart 头不合法。 - * - * @param path 接口路径(如 `/api/v1/movies/12/photos`) - * @param field 表单字段名(`poster` / `photo`,**必须与服务端读的一致**) - * @param uri picker 给的文件 URI(`file://...`) - * @returns 空串 = 成功;否则是错误说明 - */ -async function uploadOne(path: string, field: string, uri: string, token: string): Promise { - const base: string = fbApiBase(); - if (base === '') { - return '还没有设置服务器地址'; - } - if (token === '') { - return '还没登录'; - } - let buf: ArrayBuffer; - try { - const file: fileIo.File = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY); - const stat: fileIo.Stat = fileIo.statSync(file.fd); - buf = new ArrayBuffer(stat.size); - fileIo.readSync(file.fd, buf); - fileIo.closeSync(file); - } catch (err) { - hilog.warn(DOMAIN, TAG, '读文件失败:%{public}s', JSON.stringify(err)); - return '读不出这张图'; - } - - const boundary: string = '----FbMovie' + Date.now().toString(); - const head: string = '--' + boundary + '\r\n' - + 'Content-Disposition: form-data; name="' + field + '"; filename="upload.jpg"\r\n' - + 'Content-Type: image/jpeg\r\n\r\n'; - const tail: string = '\r\n--' + boundary + '--\r\n'; - - const req: http.HttpRequest = http.createHttp(); - try { - const resp: http.HttpResponse = await req.request(base + path, { - method: http.RequestMethod.POST, - header: { - 'Content-Type': 'multipart/form-data; boundary=' + boundary, - 'Authorization': 'Bearer ' + token, - 'Accept': 'application/json' - }, - extraData: [head, buf, tail], - connectTimeout: 20000, - // ⚠️ 读超时给足 60 秒:手机原图可能十几 MB,服务端还要裁/缩/转码, - // 15 秒(业务请求那个值)在这里会**假性失败** —— 图其实存下来了, - // 用户却看到"上传失败",再点一次就变成两张。 - readTimeout: 60000, - expectDataType: http.HttpDataType.STRING - }); - if (resp.responseCode >= 200 && resp.responseCode < 300) { - return ''; - } - const body: string = typeof resp.result === 'string' ? resp.result : ''; - // 服务端的错误说明是 JSON 信封里的 `error` —— 尽量把它抠出来给人话 - let msg: string = 'HTTP ' + resp.responseCode.toString(); - try { - const o: Record = JSON.parse(body) as Record; - const e: Object | undefined = o['error']; - if (typeof e === 'string' && e !== '') { - msg = e; - } - } catch (err) { - // 不是 JSON(如 Apache 的错误页)⇒ 保留 HTTP 码 - } - return msg; - } catch (err) { - const e: BusinessError = err as BusinessError; - hilog.warn(DOMAIN, TAG, '上传失败 %{public}s:%{public}s', path, JSON.stringify(e)); - return '网络异常'; - } finally { - req.destroy(); - } -} // ============================================================ // 观影详情的解析 // ============================================================ diff --git a/entry/src/main/ets/data/FbTripRepo.ets b/entry/src/main/ets/data/FbTripRepo.ets index e89156c..cdab9de 100644 --- a/entry/src/main/ets/data/FbTripRepo.ets +++ b/entry/src/main/ets/data/FbTripRepo.ets @@ -14,6 +14,7 @@ * DELETE /api/v1/trips/ 删 * GET /api/v1/trips//nodes 行程节点 * POST /api/v1/trips//nodes 加一段行程 + * POST /api/v1/trips//cover 换题图(v77,multipart 字段 `cover`) * * ⚠️ **城市与起止时间不在这里传**:服务端 `Trip::create()` 显式忽略它们, * 一律从行程节点的 `from_city` / `to_city` / `starts_at` **派生** @@ -22,6 +23,7 @@ */ import { fbApi, FbApiResult, FbApiOptions } from './FbHttp'; +import { fbUploadOne } from './FbUpload'; import { FbTripDetail, FbTripNode, FbTripNodePerson, FbTripNodePhoto, FbTripSummary } from '../model/Types'; /** 新建/修改旅程时提交的字段(与网页版 form.php 对齐) */ @@ -44,6 +46,22 @@ export interface FbTripNodeDraft { nodeType: string; /** 交通方式 key(见 `Trip::TRAVEL_MODES`;空串 = 未填) */ travelMode: string; + /** + * 车次 / 航班号(火车、飞机、轮船、公交、地铁才填)。 + * + * 🔴 v77(用户 2026-10-22 问「出行方式需不需要增加或者修改」时顺手补的): + * 端上的加行程表单**原来没有这一项** ⇒ 手机上记的行程永远没有车次, + * 而网页版一直都有。没有车次,"出行方式"在端上就只剩一个图标。 + */ + tripNo: string; + /** + * 自驾时选的车(`vehicles.id`,0 = 没选)。 + * + * ⚠️ 车牌**不单独传**:服务端 `Trip::nodes()` 是用 `vehicle_id` 去 + * `vehicles` 表 join 出 `vehicle_plate` 的。自己塞一个车牌字符串 + * 进 `trip_no`,详情页不会显示(自驾读的是 `vehicle_plate`)。 + */ + vehicleId: number; /** 'plan' 计划 / 'actual' 实际 */ category: string; detail: string; @@ -83,6 +101,45 @@ export async function deleteTrip(token: string, id: number): Promise/cover`,multipart 字段 `cover`)。 + * + * 🔴 v77(用户 2026-10-22):「新建旅程那里不是有题图吗?为什么这里没有 + * 增加题图的功能呢?」—— 端上原来**根本没有**这条链路:服务端只给观影 + * 开了 `poster` 上传槽位,旅程的 `cover` 只能在网页版传。 + * + * ⚠️ 裁剪(16:9 / 1920×1080)全在**服务端**做(`ImageSlot` 的 `cover` 槽位, + * 与网页版 `trips/form.php` 同一条管线)—— 端上只负责把原图字节送上去。 + * 端上自己裁会让"改造过的客户端"存进非 16:9 的图,两边比例就不一致了。 + * ⚠️ 必须是**已有 id** 的旅程(新建时先建、拿到 id 再传)。 + * + * @returns 空串 = 成功;否则是错误说明 + */ +export async function uploadTripCover(token: string, id: number, uri: string): Promise { + return await fbUploadOne('/api/v1/trips/' + id.toString() + '/cover', 'cover', uri, token); +} + +/** + * 从「新建旅程」的响应里取 id。 + * + * ⚠️ 单独抽一个函数:`FbApiResult.data` 是**原始 JSON 字符串**, + * 三个调用点各写一遍 `JSON.parse` 就是在给"以后忘了判 null"埋伏笔。 + * + * @returns 0 = 取不到(调用方据此跳过后续"传题图"这一步,不挡住创建本身) + */ +export function tripIdOf(res: FbApiResult): number { + if (!res.ok || res.data === '') { + return 0; + } + try { + const o: Record = JSON.parse(res.data) as Record; + const v: Object | undefined = o['id']; + return typeof v === 'number' ? v : 0; + } catch (err) { + return 0; + } +} + /** * 拉某趟旅程的**行程节点**。 * @@ -238,6 +295,15 @@ export async function addTripNode(token: string, tripId: number, d: FbTripNodeDr body['node_type'] = d.nodeType; body['travel_mode'] = d.travelMode; body['category'] = d.category; + /* 🔴 v77:车次 / 车牌。只在**填了**的时候带上 —— + `trip_no` 空串会被服务端写成 NULL,等于把老值清掉; + 而"没填"和"要清空"在新建场景里是一回事,在以后做"改一段"时就不是了。 */ + if (d.tripNo !== '') { + body['trip_no'] = d.tripNo; + } + if (d.vehicleId > 0) { + body['vehicle_id'] = d.vehicleId; + } const opts: FbApiOptions = { method: 'POST', token: token, body: body }; return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes', opts); } diff --git a/entry/src/main/ets/data/FbUpload.ets b/entry/src/main/ets/data/FbUpload.ets new file mode 100644 index 0000000..3f416ae --- /dev/null +++ b/entry/src/main/ets/data/FbUpload.ets @@ -0,0 +1,110 @@ +/** + * 家书 FamilyBook · 图片上传(**唯一的 multipart 实现**) + * + * 🔴 为什么单独一个文件(v77,2026-10-22): + * 在这之前有两份几乎一样的 multipart 手写实现 —— + * `FbPersonEditSheet.uploadAvatar`(换头像)与 `FbMovieRepo.uploadOne` + * (海报 / 观影照片)。再给旅程题图抄第三份必然漂移:本项目为 + * "两份实现漂移"栽过好几次(改了一边、另一边留在旧语义上, + * 而两边都"看着能用",只在边界条件上不同 —— 最难查的一类 bug)。 + * ⇒ 抽成一份,大家共同调用。 + * + * ⚠️ HarmonyOS 的 `http` **没有** multipart 封装,boundary 与分片要手写; + * 顺序必须是 head(字符串) → 文件(ArrayBuffer) → tail(字符串), + * 服务端按这个顺序解析出一个分片。 + * ⚠️ `filename` 给固定值:服务端**不信客户端文件名**(`Upload::image` + * 自己生成随机名 + `finfo` 嗅探真实 MIME),这里给什么都不影响结果, + * 但必须给 —— 否则 multipart 头不合法。 + * ⚠️ 读超时给足 60 秒:手机原图可能十几 MB,服务端还要裁/缩/转码。 + * 用业务请求那个 15 秒会**假性失败**(图其实存下来了,用户却看到 + * "上传失败",再点一次就变成两张)。 + */ + +import { http } from '@kit.NetworkKit'; +import { fileIo } from '@kit.CoreFileKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { fbApiBase } from './FbHttp'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbUpload'; + +/** + * 上传一个文件(**手工拼 multipart**)。 + * + * @param path 接口路径(如 `/api/v1/trips/12/cover`) + * @param field 表单字段名(`cover` / `poster` / `photo` / `avatar` + * —— **必须与服务端读的那个键一致**,否则服务端收到空文件、 + * 报一句"没收到图片",而端上完全看不出是字段名不对) + * @param uri picker 给的文件 URI(`file://...`) + * @param token 访问令牌 + * @returns 空串 = 成功;否则是**给人看**的错误说明(服务端 `error` 原文优先) + */ +export async function fbUploadOne(path: string, field: string, uri: string, + token: string): Promise { + const base: string = fbApiBase(); + if (base === '') { + return '还没有设置服务器地址'; + } + if (token === '') { + return '还没登录'; + } + if (uri === '') { + return '没选到文件'; + } + let buf: ArrayBuffer; + try { + const file: fileIo.File = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY); + const stat: fileIo.Stat = fileIo.statSync(file.fd); + buf = new ArrayBuffer(stat.size); + fileIo.readSync(file.fd, buf); + fileIo.closeSync(file); + } catch (err) { + hilog.warn(DOMAIN, TAG, '读文件失败:%{public}s', JSON.stringify(err)); + return '读不出这张图'; + } + + const boundary: string = '----FbUpload' + Date.now().toString(); + const head: string = '--' + boundary + '\r\n' + + 'Content-Disposition: form-data; name="' + field + '"; filename="upload.jpg"\r\n' + + 'Content-Type: image/jpeg\r\n\r\n'; + const tail: string = '\r\n--' + boundary + '--\r\n'; + + const req: http.HttpRequest = http.createHttp(); + try { + const resp: http.HttpResponse = await req.request(base + path, { + method: http.RequestMethod.POST, + header: { + 'Content-Type': 'multipart/form-data; boundary=' + boundary, + 'Authorization': 'Bearer ' + token, + 'Accept': 'application/json' + }, + extraData: [head, buf, tail], + connectTimeout: 20000, + readTimeout: 60000, + expectDataType: http.HttpDataType.STRING + }); + if (resp.responseCode >= 200 && resp.responseCode < 300) { + return ''; + } + const body: string = typeof resp.result === 'string' ? resp.result : ''; + // 服务端的错误说明是 JSON 信封里的 `error` —— 尽量把它抠出来给人话 + let msg: string = 'HTTP ' + resp.responseCode.toString(); + try { + const o: Record = JSON.parse(body) as Record; + const e: Object | undefined = o['error']; + if (typeof e === 'string' && e !== '') { + msg = e; + } + } catch (err) { + // 不是 JSON(如 Apache 的错误页)⇒ 保留 HTTP 码 + } + return msg; + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '上传失败 %{public}s:%{public}s', path, JSON.stringify(e)); + return '网络异常'; + } finally { + req.destroy(); + } +} diff --git a/entry/src/main/ets/pages/trips/FbTripDetailPage.ets b/entry/src/main/ets/pages/trips/FbTripDetailPage.ets index b28e1ca..ddcfc39 100644 --- a/entry/src/main/ets/pages/trips/FbTripDetailPage.ets +++ b/entry/src/main/ets/pages/trips/FbTripDetailPage.ets @@ -579,7 +579,7 @@ export struct FbTripDetailPage { .transition(fbSheetTransition()) } - // ---- 编辑旅程浮层(标题 / 简介 / 是否公开) ---- + // ---- 编辑旅程浮层(标题 / 简介 / 是否公开 / **题图**)---- if (this.editOpen && this.detail !== null) { FbTripNewSheet({ accent: this.accent, @@ -587,12 +587,15 @@ export struct FbTripDetailPage { editingTitle: this.detail.title, editingSummary: this.detail.summary, editingPublic: this.detail.isPublic, + /* 🔴 v77:把已有题图交给浮层做预览 —— 不传的话用户打开"编辑" + 看到的永远是空框,会以为题图丢了(真传上去又会把原图覆盖掉)。 */ + editingCoverKey: this.detail.coverKey, onClose: (): void => { this.editOpen = false; }, onSaved: (): void => { this.editOpen = false; - // 保存后重拉本体(标题/简介是服务端回填的,本地拼不出来) + // 保存后重拉本体(标题/简介/题图都是服务端回填的,本地拼不出来) this.reload(); } }) @@ -666,52 +669,41 @@ export struct FbTripDetailPage { } /** - * 页头:标题(左)+ 动作区(右)。 + * 页头:标题(+ ☰)在上面,四颗动作按 **2×2 等宽**排在下面。 * * 🔴🔴 v41 用户第 4 条给的**顺序与配色是硬要求**: * 「旅程详情页面右上角的按钮,顺序分别是从左到右: * +计划行程、+实际行程、编辑、删除、返回旅程和设置。 * 计划行程的按钮用蓝色背景;实际行程的按钮用绿色背景; * 编辑用黄色背景;删除用红色背景;设置就用这个图标,不要变。」 - * ⚠️ 手机宽度放不下五颗带字的按钮 ⇒ **分两行**(第一行两颗「+」, - * 第二行编辑/删除),但**相对顺序与配色一字不改**。 - * 这不是"重新设计",是被屏幕宽度逼的排版折行。 + * ⇒ 配色与顺序**一字不改**(守卫里有断言)。 + * + * 🔴🔴 v77(用户 2026-10-22):「详情页面里「旅程详情」「计划行程」「实际行程」 + * 这些按钮排列得不好看,乱七八糟的。」 + * 旧版是「四颗彩色实心胶囊分两行」:第一行两颗 + 尾部一片空白, + * 第二行两颗 + 尾巴上孤零零挂一个 ☰ —— 每行宽度都不一样、右端对不齐, + * 远看就是四块散落的色斑。现在改三件事: + * ① ☰ 挪到**标题行右侧**:它是"整页设置",不该跟业务动作抢位置; + * ② 四颗动作做成 **2×2 等宽**(每颗 `layoutWeight(1)`),上下两行严丝合缝; + * ③ 主操作(+计划 / +实际)保持**实心**;编辑 / 删除改成 + * **浅底同色字** —— 它们是低频与破坏性操作,不该和主操作一样抢眼。 + * ⚠️ 为什么不用 `Text` 直接撑满:`Text` 定高后垂直居中要依赖 `.align()`, + * 而本项目在 `Stack` 子节点上踩过 `.align()` 完全无效的坑; + * 这里用 `Row + justifyContent(Center)` 包住文字,位置只由布局决定。 */ @Builder headBar() { - Column({ space: 8 }) { - Text(this.detail !== null ? this.detail.title : '旅程') - .fontSize(FbMetric.fontH1) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .width('100%') - + Column({ space: 10 }) { + // ---- 标题 + ☰(设置图标**不变**)---- Row({ space: 8 }) { - // ① + 计划行程 —— 蓝 - this.actionBtn('+ 计划行程', '#2F6FB0', (): void => { - this.openNodeSheet('plan'); - }) - // ② + 实际行程 —— 绿 - this.actionBtn('+ 实际行程', '#4E7C3F', (): void => { - this.openNodeSheet('actual'); - }) - Column().layoutWeight(1) - } - .width('100%') + Text(this.detail !== null ? this.detail.title : '旅程') + .fontSize(FbMetric.fontH1) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) - Row({ space: 8 }) { - // ③ 编辑 —— 黄 - this.actionBtn('编辑', '#B8862B', (): void => { - this.editOpen = true; - }) - // ④ 删除 —— 红 - this.actionBtn('删除', '#C0533F', (): void => { - this.confirmDeleteTrip(); - }) - Column().layoutWeight(1) - // ⑤ 设置 —— 图标**不变**(☰) FbIconButton({ icon: $r('app.media.fb_ic_menu'), tint: FbTint.muted, @@ -721,6 +713,29 @@ export struct FbTripDetailPage { }) } .width('100%') + .alignItems(VerticalAlign.Center) + + // ---- 主操作:+ 计划行程(蓝)/ + 实际行程(绿),等宽 ---- + Row({ space: 8 }) { + this.actionBtn('+ 计划行程', '#2F6FB0', true, (): void => { + this.openNodeSheet('plan'); + }) + this.actionBtn('+ 实际行程', '#4E7C3F', true, (): void => { + this.openNodeSheet('actual'); + }) + } + .width('100%') + + // ---- 次操作:编辑(黄)/ 删除(红),等宽、浅底 ---- + Row({ space: 8 }) { + this.actionBtn('编辑', '#B8862B', false, (): void => { + this.editOpen = true; + }) + this.actionBtn('删除', '#C0533F', false, (): void => { + this.confirmDeleteTrip(); + }) + } + .width('100%') } .width('100%') .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 10 }) @@ -728,23 +743,32 @@ export struct FbTripDetailPage { } /** - * 动作区的一颗实心按钮。 + * 动作区的一颗按钮(**等宽**,由调用方的 `Row({space:8})` 均分)。 * - * ⚠️ 自己画而不是用 `FbPrimaryButton`:那颗按钮有 `icon` / `block` 等一堆参数, - * 这里只要"给定底色 + 白字 + 固定高度",自己画反而更少样板、也更好调高度对齐。 + * @param tone 语义色(蓝/绿/黄/红 —— v41 定的,不许改) + * @param solid true = 实心(主操作);false = 浅底同色字(次操作) + * + * ⚠️ 自己画而不是用 `FbPrimaryButton`:那颗是"整行大按钮",样式与这里 + * (半宽、34 高、等宽网格)不是一回事,硬套要覆盖一堆参数。 */ @Builder - actionBtn(label: string, bg: string, onTap: () => void) { - Text(label) - .fontSize(FbMetric.fontTiny) - .fontWeight(FontWeight.Medium) - .fontColor('#FFFFFF') - .padding({ left: 12, right: 12, top: 7, bottom: 7 }) - .backgroundColor(bg) - .borderRadius(FbMetric.radiusPill) - .onClick((): void => { - onTap(); - }) + actionBtn(label: string, tone: string, solid: boolean, onTap: () => void) { + Row() { + Text(label) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Medium) + .fontColor(solid ? '#FFFFFF' : tone) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .height(34) + .justifyContent(FlexAlign.Center) + .backgroundColor(solid ? tone : fbAlpha(tone, 0.12)) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + onTap(); + }) } /** 题图 + 信息区(照 `trips/show.php` 的 `trip-hero`) */ @@ -766,6 +790,12 @@ export struct FbTripDetailPage { }) } if (this.detail === null || this.detail.coverKey === '' || this.coverFailed) { + /* 🔴 v77(用户 2026-10-22):「新建旅程那里不是有题图吗? + 为什么这里没有增加题图的功能呢?」 + ⇒ 空题图这块不再是死图:**点它就打开编辑浮层去选题图** + (浮层里有 16:9 的选区,见 `FbTripNewSheet.coverPicker`)。 + 不改文案的话用户根本不知道这里能点,所以顺手把 + 「这趟旅程还没设题图」改成「点这里加一张」。 */ Column({ space: 6 }) { Image($r('app.media.fb_ic_trips')) .width(34) @@ -775,11 +805,25 @@ export struct FbTripDetailPage { Text('这趟旅程还没设题图') .fontSize(FbMetric.fontTiny) .fontColor('#A8988A') + if (this.detail !== null) { + Text('点这里加一张') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accent) + .padding({ left: 10, right: 10, top: 3, bottom: 3 }) + .backgroundColor(fbAlpha(this.accent, 0.1)) + .borderRadius(FbMetric.radiusPill) + } } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .backgroundColor('#F1E7D6') + .onClick((): void => { + // 还没取到本体的不给点(那时浮层里没数据可编辑) + if (this.detail !== null) { + this.editOpen = true; + } + }) } } .width('100%') @@ -1125,26 +1169,27 @@ export struct FbTripDetailPage { .width('100%') } - // ⑤ 出发地 → 到达地 + /* ⑤ 出发地 → 到达地 + 🔴 v77(用户 2026-10-22):「现在下面的详情,也就是行程详情里面,还是 + 显示不全。比如地址,从哪到哪,如果地址特别长,就显示不全了。」 + ⚠️ 原来是一条 `Row({space:5}) { 出发地; →; 到达地 }`,三段挤在一行: + 两个地址各 `maxLines(1)` + `Ellipsis`,落到「青岛市市南区香港中路… + 浦东国际机场 T2」这种长度时,用户只看得到前半截。 + ⇒ 改成**上下两行**(「从」「到」各一行),各自 `maxLines(2)` 且 + 允许整段铺开。地址长就把卡片撑高 —— 详情页本来就是往下滚的瀑布, + 高度不是稀缺资源,"看不全"才是。 + ⚠️ 两行都空才整块不显示(只填了一头也照样显示,不要因为缺一头就全藏掉)。 */ if (n.fromPlace !== '' || n.toPlace !== '') { - Row({ space: 5 }) { - Text(n.fromPlace !== '' ? n.fromPlace : '—') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.ink) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text('→') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - Text(n.toPlace !== '' ? n.toPlace : '—') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.ink) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) + Column({ space: 4 }) { + if (n.fromPlace !== '') { + this.placeLine('从', n.fromPlace) + } + if (n.toPlace !== '') { + this.placeLine('到', n.toPlace) + } } .width('100%') - .alignItems(VerticalAlign.Center) + .padding({ top: 1, bottom: 1 }) } // ⑥ 详细说明 @@ -1283,6 +1328,32 @@ export struct FbTripDetailPage { .margin({ bottom: 10 }) } + /** + * 一行「从 / 到」地点(`tag` + 地址)。 + * + * 🔴 v77:长地址**不许截断** —— 这是用户点名的那条「地址特别长就显示不全」。 + * ⇒ 不写 `maxLines` + 不写 `Ellipsis`:`Text` 默认整段换行,卡片跟着变高。 + * ⚠️ 标签用固定宽度(16vp):两行的地址左边缘才能对齐,看起来像一张表。 + */ + @Builder + placeLine(tag: string, place: string) { + Row({ space: 6 }) { + Text(tag) + .fontSize(10) + .fontColor(FbColors.faint) + .textAlign(TextAlign.Center) + .width(16) + Text(place) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.ink) + .lineHeight(18) + .layoutWeight(1) + } + .width('100%') + // ⚠️ `Top` 而不是 `Center`:地址换到第二行时,标签要贴着第一行,不要吊在中间 + .alignItems(VerticalAlign.Top) + } + /** * 照片查看器(全屏大图 + 左右滑翻页)。 * diff --git a/entry/src/main/ets/pages/trips/FbTripsPage.ets b/entry/src/main/ets/pages/trips/FbTripsPage.ets index 2f0c7b4..fa4bb1d 100644 --- a/entry/src/main/ets/pages/trips/FbTripsPage.ets +++ b/entry/src/main/ets/pages/trips/FbTripsPage.ets @@ -73,10 +73,6 @@ export struct FbTripsPage { * 「新建旅程啊…这些东西都还没完成」)。现在接上了。 */ @Local newOpen: boolean = false; - /** 正在编辑的旅程(0 = 新建)。点卡片的「编辑」用 */ - @Local editingId: number = 0; - @Local editingTitle: string = ''; - @Local editingSummary: string = ''; /** 题图加载失败集合(一张图坏了不该反复重试,会让列表一直闪) */ @Local coverFailedIds: number[] = []; @@ -223,11 +219,11 @@ export struct FbTripsPage { bg: this.accent, icon: $r('app.media.fb_ic_plus'), /* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** —— 纯摆设, - 点了什么都不发生(用户报的"新建旅程没完成"就是这条)。 */ + 点了什么都不发生(用户报的"新建旅程没完成"就是这条)。 + ⚠️ v77:列表页只做**新建**(`editingId` 走默认值 0)。 + 改旅程统一在详情页点「编辑」—— 那边才带着题图与公开状态, + 列表页没有这些数据,硬开一个"半套编辑"只会把题图冲掉。 */ onTap: (): void => { - this.editingId = 0; - this.editingTitle = ''; - this.editingSummary = ''; this.newOpen = true; } }) @@ -317,13 +313,10 @@ export struct FbTripsPage { .transition(fbSheetTransition()) } - // ---- 新建 / 编辑旅程浮层(v65)---- + // ---- 新建旅程浮层(v65;v77 起列表页只新建,编辑在详情页)---- if (this.newOpen) { FbTripNewSheet({ accent: this.accent, - editingId: this.editingId, - editingTitle: this.editingTitle, - editingSummary: this.editingSummary, onClose: (): void => { this.newOpen = false; }, @@ -430,14 +423,28 @@ export struct FbTripsPage { // ---- 图下面的信息区(挤占问题消失,能放的都放进来)---- Column({ space: 6 }) { - // 标题 - Text(t.title) - .fontSize(FbMetric.fontTitle) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .width('100%') + /* 标题 + 右侧一个 › 提示"整张卡点得进去"。 + 🔴 v77(用户 2026-10-22):「列表页面里面的「看行程」和「编辑」, + 我建议都不要了。因为点进去之后会有编辑,直接点进去看行程就可以。」 + ⇒ 卡尾那两颗按钮**整行删掉**(编辑挪进详情页了,见 `headBar`); + 但"能点进去"这件事还得让人看得出来,所以在标题行尾留一个 + 右向箭头 —— 它只是**指示**,不带 onClick(整卡已经可点)。 */ + Row({ space: 6 }) { + Text(t.title) + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + Image($r('app.media.fb_ic_forward')) + .width(12) + .height(12) + .renderMode(ImageRenderMode.Template) + .colorFilter(fbTint(FbTint.faint)) + } + .width('100%') + .alignItems(VerticalAlign.Center) // 时间(单独一行 —— 城市改成 chip 之后不能再挤在同一行里抢宽度) if (t.dateRange !== '') { @@ -497,36 +504,6 @@ export struct FbTripsPage { .width('100%') .lineHeight(18) } - - // 卡尾:进详情 + 编辑 - Row({ space: 12 }) { - /* 🔴 这颗「看行程」是**主要入口**,必须自己带 onClick(v66 真机踩过: - 屏幕上写着"查看行程"却是纯文字,点它什么都不发生)。 */ - Text('看行程') - .fontSize(FbMetric.fontSmall) - .fontColor(this.accent) - .padding({ left: 6, right: 12, top: 8, bottom: 6 }) - .onClick((): void => { - this.openDetail(t); - }) - Column().layoutWeight(1) - Text('编辑') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.muted) - .padding({ left: 10, right: 10, top: 4, bottom: 4 }) - .backgroundColor(FbColors.inputBg) - .borderRadius(FbMetric.radiusPill) - .onClick((): void => { - this.openEdit(t); - }) - Image($r('app.media.fb_ic_forward')) - .width(12) - .height(12) - .renderMode(ImageRenderMode.Template) - .colorFilter(fbTint(FbTint.faint)) - } - .width('100%') - .alignItems(VerticalAlign.Center) } .width('100%') .padding(FbMetric.cardPad) @@ -538,9 +515,10 @@ export struct FbTripsPage { .clip(true) .alignItems(HorizontalAlign.Start) .onClick((): void => { - /* 整卡可点(比只点那行小字好按得多)。⚠️ 卡里的「编辑」自己带 onClick, - 点它会冒泡到这里吗?ArkUI 的 onClick 不冒泡到父容器(子组件消费掉), - 所以「编辑」不会误进详情 —— 但两者的热区都在卡内,别把编辑挪出去。 */ + /* 整卡可点 = 唯一的入口(v77 起卡尾的「看行程」「编辑」都删了)。 + ⚠️ 卡里**不再有**任何自带 onClick 的子元素,所以不存在"点小按钮 + 误触发进详情"的问题;将来要在卡上加动作按钮,记得它不会冒泡 + (ArkUI 的子节点 onClick 会消费掉事件)。 */ this.openDetail(t); }) } @@ -560,14 +538,13 @@ export struct FbTripsPage { this.app.switchModule('__trip_detail'); } - /** 打开编辑浮层(预填当前值) */ - private openEdit(t: FbTripItem): void { - this.editingId = t.id; - this.editingTitle = t.title; - this.editingSummary = t.summary; - this.newOpen = true; - } - + /** + * 记一张坏掉的题图(一张图坏了不该在每次重渲染时反复重试)。 + * + * ⚠️ v77:原来的 `openEdit()` 已删除 —— 列表上不再有「编辑」入口, + * 改旅程统一进详情页点「编辑」(带题图的那套表单只在详情页打开, + * 因为它还带着 `editingCoverKey`)。 + */ private markCoverFailed(id: number): void { if (this.coverFailedIds.indexOf(id) < 0) { this.coverFailedIds = this.coverFailedIds.concat([id]);