#48 计划/实际双轨:nodeAction(start/done/cancel),完成即转实际行程, 实际行程不再显示状态;新增 actualStartedAt/actualEndedAt #49 类型/出行方式/车辆 chip 改内部读状态(@Builder 值传参不刷新) #50 类型点击立即变色、出行方式平铺、参与人显示已选头像 #51 节点类型动态加载(原硬编码5种)、地址 onPick 拿详细地址与坐标 #52 标签改主题色 Bold、必填红无星号、编辑行程标题同步 #53 表单两排版式(标签独占一行解决左右对不齐);电影弹层 layoutWeight 修滚动 #54 地址联想框加时间窗抑制(600ms 内不搜)、候选限高 180 含附件回填诊断日志(DIAG-ATTACH/FILL),待真机确认后移除
235 lines
9.1 KiB
Plaintext
235 lines
9.1 KiB
Plaintext
/**
|
||
* 家书 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 r: FbUploadRaw = await fbUploadRaw(path, field, uri, token);
|
||
return r.ok ? '' : r.error;
|
||
}
|
||
|
||
/**
|
||
* 暂存上传:把文件传到暂存区,返回**令牌**。
|
||
*
|
||
* 🔴 2026-11-05。用户:「先上传,拿到地址,然后提交的时候,连带着跟其他参数一起提交,
|
||
* 后台接口拿到数据,先插入行程,拿到 id,再把图片插入进去。」
|
||
*
|
||
* 这是「新增时也能挂照片」的关键:新增行程时节点还不存在,
|
||
* 只能先把文件暂存起来拿 stoken,等保存表单时把 stoken 一起提交,
|
||
* 服务端建完节点再 `claim` 上去。
|
||
*
|
||
* @param uri picker 给的文件 URI
|
||
* @param token 访问令牌
|
||
* @returns stoken 为空串 = 失败(看 error)
|
||
*/
|
||
export interface FbStageUpload {
|
||
/** 暂存令牌(空 = 失败) */
|
||
stoken: string;
|
||
/** 原始文件名 */
|
||
name: string;
|
||
/** 是否图片(服务端按 MIME 嗅探) */
|
||
isImage: boolean;
|
||
/** 预览地址(相对路径) */
|
||
url: string;
|
||
/** 错误说明(成功时为空) */
|
||
error: string;
|
||
}
|
||
|
||
export async function fbUploadStage(uri: string, token: string): Promise<FbStageUpload> {
|
||
const out: FbStageUpload = {
|
||
stoken: '', name: '', isImage: false, url: '', error: ''
|
||
};
|
||
const r: FbUploadRaw = await fbUploadRaw('/api/v1/uploads/stage', 'file', uri, token);
|
||
if (!r.ok) {
|
||
out.error = r.error !== '' ? r.error : '上传失败';
|
||
return out;
|
||
}
|
||
/* 服务端信封:{ok:true, data:{stoken,...}}(`fbApi` 层已拆过一层,
|
||
但 multipart 这条路是自己解析的,要按原始信封读) */
|
||
try {
|
||
const env: Record<string, Object> = JSON.parse(r.body) as Record<string, Object>;
|
||
let dataObj: Record<string, Object> = env;
|
||
const nested: Object | undefined = env['data'];
|
||
if (nested !== undefined) {
|
||
dataObj = nested as Record<string, Object>;
|
||
}
|
||
const st: Object | undefined = dataObj['stoken'];
|
||
if (typeof st === 'string' && st !== '') {
|
||
out.stoken = st as string;
|
||
}
|
||
const nm: Object | undefined = dataObj['name'];
|
||
if (typeof nm === 'string') {
|
||
out.name = nm as string;
|
||
}
|
||
out.isImage = dataObj['is_image'] === true;
|
||
const u: Object | undefined = dataObj['url'];
|
||
if (typeof u === 'string') {
|
||
out.url = u as string;
|
||
}
|
||
if (out.stoken === '') {
|
||
out.error = '服务端没返回令牌';
|
||
}
|
||
} catch (err) {
|
||
out.error = '响应解析失败';
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 上传图片并**拿回响应载荷**(工单 #23 用)。
|
||
*
|
||
* 笔记插图要拿到服务端刚存的那张图的附件 key(存进 `image` 块),
|
||
* 而 `fbUploadOne` 只返回"成没成"、把 key 丢了。这里复用同一个
|
||
* multipart 核心(`fbUploadRaw`),成功时把信封里的 `key` 吐出来。
|
||
*
|
||
* @returns key = 附件相对键;空串且 error 非空 = 失败
|
||
*/
|
||
export interface FbUploadKey {
|
||
/** 附件 key(成功时有值) */
|
||
key: string;
|
||
/** 错误说明(空 = 成功) */
|
||
error: string;
|
||
}
|
||
|
||
export async function fbUploadKey(path: string, field: string, uri: string,
|
||
token: string): Promise<FbUploadKey> {
|
||
const r: FbUploadRaw = await fbUploadRaw(path, field, uri, token);
|
||
if (!r.ok) {
|
||
return { key: '', error: r.error };
|
||
}
|
||
try {
|
||
const o: Record<string, Object> = JSON.parse(r.body) as Record<string, Object>;
|
||
const k: Object | undefined = o['key'];
|
||
if (typeof k === 'string' && k !== '') {
|
||
return { key: k, error: '' };
|
||
}
|
||
// 没拿到 key:再按信封的 `data` 兜一把(不同端点习惯不同)
|
||
const d: Object | undefined = o['data'];
|
||
if (d !== undefined && typeof d === 'object' && d !== null) {
|
||
const dk: Object | undefined = (d as Record<string, Object>)['key'];
|
||
if (typeof dk === 'string' && dk !== '') {
|
||
return { key: dk, error: '' };
|
||
}
|
||
}
|
||
return { key: '', error: '服务端没回图片地址' };
|
||
} catch (err) {
|
||
return { key: '', error: '响应不是合法 JSON' };
|
||
}
|
||
}
|
||
|
||
/** `fbUploadOne` 与 `fbUploadKey` 共用的 multipart 核心(勿复制,见文件头说明) */
|
||
interface FbUploadRaw {
|
||
ok: boolean;
|
||
/** 响应正文(成功时是非空 JSON) */
|
||
body: string;
|
||
/** 给人看的错误说明 */
|
||
error: string;
|
||
}
|
||
|
||
async function fbUploadRaw(path: string, field: string, uri: string,
|
||
token: string): Promise<FbUploadRaw> {
|
||
const base: string = fbApiBase();
|
||
if (base === '') {
|
||
return { ok: false, body: '', error: '还没有设置服务器地址' };
|
||
}
|
||
if (token === '') {
|
||
return { ok: false, body: '', error: '还没登录' };
|
||
}
|
||
if (uri === '') {
|
||
return { ok: false, body: '', error: '没选到文件' };
|
||
}
|
||
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 { ok: false, body: '', error: '读不出这张图' };
|
||
}
|
||
|
||
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 { ok: true, body: typeof resp.result === 'string' ? resp.result : '', error: '' };
|
||
}
|
||
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 { ok: false, body: body, error: msg };
|
||
} catch (err) {
|
||
const e: BusinessError = err as BusinessError;
|
||
hilog.warn(DOMAIN, TAG, '上传失败 %{public}s:%{public}s', path, JSON.stringify(e));
|
||
return { ok: false, body: '', error: '网络异常' };
|
||
} finally {
|
||
req.destroy();
|
||
}
|
||
}
|