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:
@@ -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();
|
||||
}
|
||||
}
|
||||
// ============================================================
|
||||
// 观影详情的解析
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user