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:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,6 +131,17 @@ export struct FbTripNewSheet {
|
||||
.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 => {
|
||||
@@ -135,6 +177,14 @@ export struct FbTripNewSheet {
|
||||
.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 !== '') {
|
||||
@@ -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;
|
||||
|
||||
/* ---- ① 旅程本体:上次只差传图(`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%')
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
@@ -1330,93 +1328,6 @@ function strField(o: Record<string, Object>, 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<string> {
|
||||
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<string, Object> = JSON.parse(body) as Record<string, Object>;
|
||||
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();
|
||||
}
|
||||
}
|
||||
// ============================================================
|
||||
// 观影详情的解析
|
||||
// ============================================================
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* DELETE /api/v1/trips/<id> 删
|
||||
* GET /api/v1/trips/<id>/nodes 行程节点
|
||||
* POST /api/v1/trips/<id>/nodes 加一段行程
|
||||
* POST /api/v1/trips/<id>/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<FbApiResult
|
||||
return await fbApi('/api/v1/trips/' + id.toString(), opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 换题图(`POST /api/v1/trips/<id>/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<string> {
|
||||
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<string, Object> = JSON.parse(res.data) as Record<string, Object>;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, Object> = JSON.parse(body) as Record<string, Object>;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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 }) {
|
||||
Column({ space: 10 }) {
|
||||
// ---- 标题 + ☰(设置图标**不变**)----
|
||||
Row({ 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%')
|
||||
.layoutWeight(1)
|
||||
|
||||
Row({ space: 8 }) {
|
||||
// ① + 计划行程 —— 蓝
|
||||
this.actionBtn('+ 计划行程', '#2F6FB0', (): void => {
|
||||
this.openNodeSheet('plan');
|
||||
})
|
||||
// ② + 实际行程 —— 绿
|
||||
this.actionBtn('+ 实际行程', '#4E7C3F', (): void => {
|
||||
this.openNodeSheet('actual');
|
||||
})
|
||||
Column().layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
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,20 +743,29 @@ 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) {
|
||||
actionBtn(label: string, tone: string, solid: boolean, onTap: () => void) {
|
||||
Row() {
|
||||
Text(label)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
|
||||
.backgroundColor(bg)
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.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();
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 照片查看器(全屏大图 + 左右滑翻页)。
|
||||
*
|
||||
|
||||
@@ -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 }) {
|
||||
// 标题
|
||||
/* 标题 + 右侧一个 › 提示"整张卡点得进去"。
|
||||
🔴 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]);
|
||||
|
||||
Reference in New Issue
Block a user