v77 旅程:列表只留整卡入口 + 动作区改成 2×2 + 地址不截断 + 题图

用户 2026-10-22 的五条:

① 列表卡去掉「看行程」「编辑」——「点进去之后会有编辑,直接点进去
   看行程就可以」。卡尾整行删除,整卡可点变唯一入口(标题行尾留一个 ›
   作提示);openEdit() 一并删掉,不留死代码。

② 详情页动作区重排——「排列得不好看,乱七八糟的」。旧版是四颗彩色实心
   胶囊分两行、右端对不齐。现在 ☰ 挪到标题行右侧,四颗动作做成 2×2
   等宽(layoutWeight(1));主操作保持实心,编辑/删除改浅底同色字。
   配色与顺序仍是 v41 硬要求,一个 hex 都没改。

③ 出行方式:十种方式**不能增删**(网页版也只是只读 + "常用信息",
   而端上加行程表单里根本没有车次/车牌,那条备忘在端上无从使用)
   ⇒ 方向反过来,给加行程表单补上「车次/航班号」(火车/飞机/轮船/
   公交/地铁)和「车牌」(自驾,从车辆库选一台,服务端按 vehicle_id
   join 出车牌)。没有这两项,"出行方式"在端上就只剩一个图标。

④ 行程详情里的地址显示不全——「从哪到哪,如果地址特别长就显示不全」。
   原来出发地与到达地挤在一条 Row 里各 maxLines(1) + 省略号;
   改成「从」「到」上下两行、整段铺开不截断。

⑤ 旅程题图:端上原来根本没有上传链路(服务端只有观影的 poster 槽位)。
   服务端已加 POST /trips/<id>/cover;端上新建/编辑浮层加 16:9 题图选区,
   流程「先建 → 拿 id → 再传图」,传图失败记 id 再点只补传(不会建出
   两趟一样的旅程)。详情页空题图那块也能直接点开去加。

顺带把 multipart 上传收敛成一份(data/FbUpload.ets):原来头像 / 海报 /
观影照片各抄了一遍,旅程题图会是第四份。
This commit is contained in:
2026-10-22 10:15:03 +08:00
parent 79e942d0e4
commit c7bd26dd1b
9 changed files with 743 additions and 318 deletions
@@ -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<string> {
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);
}
/**
+238 -45
View File
@@ -8,23 +8,35 @@
* 点了什么都不发生 —— 用户报的"新建旅程没完成"就是这一条。
*
* ⚠️ 表单只放**网页版真有的三项**:
* 标题 / 简介 / 是否公开。**没有起止时间、没有城市** ——
* 服务端 `Trip::create()` 显式忽略它们,城市与时间一律从行程节点**派生**
* (见 `Trip::derived()`)。加个"时间"输入框只会让人以为填了有用。
* 标题 / 简介 / 是否公开(具体城市与起止时间**不在**这里 ——
* 服务端 `Trip::create()` 显式忽略它们,城市与时间一律从行程节点**派生**,
* 见 `Trip::derived()`)。
*
* ⚠️ 题图这一批**不做**:网页版走 `cover` 文件上传 + `cover_crop` 裁剪,
* 而服务端目前只有观影的 `/movies/<id>/poster` 上传槽位,没有旅程的。
* ⇒ 新建时先不传图;列表卡仍会显示已有数据的题图。
* (补上传端点时要与网页版 `ImageSlot` 的 `cover` 槽位比例 16:9 一致,
* 否则宽高比会和网页端不一样 —— 那条在 app.css 里有专门的断言比对。)
* 🔴🔴 v77(用户 2026-10-22):「新建旅程那里不是有题图吗?
* 为什么这里没有增加题图的功能呢?」
* ⇒ 补上题图。之前没做是因为**服务端没有旅程的图片上传槽位**
* (只有观影的 `/movies/<id>/poster`),现在服务端加了
* `POST /api/v1/trips/<id>/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/<id>/cover`;
* ② 编辑:`PATCH /trips/<id>` → 再传图。
* ⚠️ 传图失败**不撤销**旅程(记录已经存在,删掉反而更糟),
* 而是把错误说清楚 + 记住 id ⇒ 用户再点一次只重传图。
*/
private async save(): Promise<void> {
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<void> {
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) {
@@ -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<void> {
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) {
@@ -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%')