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
+4 -93
View File
@@ -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();
}
}
// ============================================================
// 观影详情的解析
// ============================================================
+66
View File
@@ -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);
}
+110
View File
@@ -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();
}
}