上传模块 utils/upload.js(新增)
此前 detail.vue 与 records.vue 各写一份 uni.uploadFile 封装,超时与并发策略
容易漂移,且都是串行上传 —— 9 张图排队一张张传完,弱网下工人要干等半分钟。
- uploadImage:单张上传,UPLOAD_TIMEOUT 防弱网永久挂起;非 2xx 时后端返回的
是 {detail:...} 而非 {url},必须当失败处理,否则图片被静默丢弃
- uploadImages:有界并发池(并发 3),onEachDone 对每张图恰好回调一次,
成功与失败都精确回收一个 ⏳ 占位符
- isUploadedUrl:判定「是否已真正上传成功」。成功 resolve 的是后端 URL
(/api/v1/upload/files/… 或 http(s)://),未完成的拿到的是本地临时地址
(blob: / file:// / _doc/ / wxfile://)
上传成功绿勾角标
- 应用到 detail.vue(记录表单 + 驳回表单)与 records.vue(编辑记录弹窗)
- 角标仅在 isUploadedUrl 判定为真时渲染;⏳ 占位与失败项结构上不可能出现
角标,因为角标只存在于「已上传」的那个循环里
- 圆角与裁剪改由 .success-badge-wrapper 负责,图片只负责填满
修复:记录/拍照里已选图片无法删除
删除按钮原为 v-if="canDeleteImage",而该项在「任务已定稿
(COMPLETED/REJECTED/ARCHIVED/CANCELED)」或「非本人任务」时返回 false。
但 handleTaskAction 里 type === "record" 是无条件打开弹窗的,工人完全可能
在已完成任务上打开「记录/拍照」,于是刚选、尚未提交的图也被一并锁死 ——
那些图只存在于内存里,删掉不产生任何服务端影响。
- 新增 recordForm.savedCount(打开弹窗时已落库的张数),
新增 canDeleteRecordImage(i) = canDeleteImage || i >= savedCount:
本次新选的图永远可删,已落库的才受「任务已定稿」约束
- 未落库的图免去 5 秒倒计时 —— 该机制本是为不可逆的服务器删除设计的,
刚选错的图重拍一张即可,没必要卡 5 秒
- openEditRecord 由 images: record.images || [] 改为 saved.slice():
原先按引用赋值,删图会直接改动底层记录对象,此时点「取消」被删的图
在本地列表里也已经没了
102 lines
4.0 KiB
JavaScript
102 lines
4.0 KiB
JavaScript
/**
|
||
* 图片上传工具 — 超时与并发策略的唯一来源(追加记录 / 驳回弹窗共用)。
|
||
*
|
||
* 此前两个页面各写一份 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<string>} 成功时 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 };
|
||
}
|