/** * 图片上传工具 — 超时与并发策略的唯一来源(追加记录 / 驳回弹窗共用)。 * * 此前两个页面各写一份 uni.uploadFile 封装,行为容易漂移;且都是串行上传, * 9 张图要排队一张张传完,弱网下工人要干等半分钟以上。 */ import { getBaseUrl, extractErrorDetail } from "./request"; /** 单张上传超时(毫秒)—— 弱网下防止请求永久挂起,卡住整个上传流程 */ export const UPLOAD_TIMEOUT = 15000; /** * 上传并发数。 * 不用「9 张一起上」的无限并发:弱网带宽本就紧张,同时打满会互相挤占、 * 整体反而更慢,还容易触发服务端限流。3 是等待时间与成功率的折中点。 */ export const UPLOAD_CONCURRENCY = 3; /** * 上传单个文件。 * @param {string} filePath 本地临时文件路径 * @returns {Promise} 成功时 resolve 后端返回的可访问 URL * @throws 失败时 reject —— 不 resolve(null) 静默丢弃,调用方必须显式处理 */ export function uploadImage(filePath) { return new Promise((resolve, reject) => { uni.uploadFile({ url: getBaseUrl() + "/upload/", filePath, name: "file", timeout: UPLOAD_TIMEOUT, success(res) { let data = null; try { data = JSON.parse(res.data); } catch { data = null; } const url = data && data.url; // HTTP 非 2xx 时(类型不支持 400 / 超过 50MB 413 等)后端返回的是 // {detail: ...} 而非 {url},必须当失败处理,否则图片会被静默丢弃 if (res.statusCode >= 200 && res.statusCode < 300 && url) resolve(url); else reject(new Error(extractErrorDetail(data, `图片上传失败 (${res.statusCode || "无响应"})`))); }, fail(err) { const errMsg = (err && err.errMsg) || ""; reject(new Error(/timeout/i.test(errMsg) ? "图片上传超时" : errMsg || "图片上传失败")); }, }); }); } /** * 判断一个图片项是否「已真正上传成功」。 * * 依据来自本模块的契约:uploadImage() 成功时 resolve 的是后端返回的 URL * (相对路径 /api/v1/upload/files/xxx,或带域名的 http(s)://…); * 而未上传完 / 上传失败的项,拿到的是本地临时地址(blob:、file://、 * _doc/、wxfile:// 等)。 * * 各页面的「成功绿勾」角标只应依据本函数显示 —— 绝不能因为「它出现在 * images 数组里」就当作成功,否则将来若有人把失败项也塞进数组(比如为了 * 支持重试而保留占位),角标就会撒谎。 * * @param {*} url 图片项 * @returns {boolean} */ export function isUploadedUrl(url) { if (typeof url !== "string" || !url) return false; return url.startsWith("/") || /^https?:\/\//i.test(url); } /** * 并发上传一组图片(有界并发池,逐个补位,不会一次性打满)。 * * @param {string[]} filePaths 本地临时文件路径列表 * @param {(url: string|null, index: number) => void} [onEachDone] * 每张图片「有结果」时回调一次(成功传 url,失败传 null)—— * 调用方据此回收 ⏳ 占位符,保证成功与失败都恰好回收一次。 * @returns {Promise<{failed: number, total: number}>} */ export async function uploadImages(filePaths, onEachDone) { const queue = filePaths.map((filePath, index) => ({ filePath, index })); let cursor = 0; let failed = 0; const worker = async () => { while (cursor < queue.length) { // 单线程 JS 里 cursor++ 在两次 await 之间是原子的,不会重复取到同一项 const { filePath, index } = queue[cursor++]; let url = null; try { url = await uploadImage(filePath); } catch (e) { failed++; console.error("[upload] 图片上传失败:", e); } if (onEachDone) onEachDone(url, index); } }; const workerCount = Math.min(UPLOAD_CONCURRENCY, queue.length); await Promise.all(Array.from({ length: workerCount }, worker)); return { failed, total: filePaths.length }; }