Files
track/track-uniapp/src/utils/upload.js
duxingchen 7fca315ff4 feat: 图片上传抽离公共模块,统一成功角标并修复记录拍照无法删除
上传模块 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():
  原先按引用赋值,删图会直接改动底层记录对象,此时点「取消」被删的图
  在本地列表里也已经没了
2026-09-15 15:51:44 +08:00

102 lines
4.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 图片上传工具 — 超时与并发策略的唯一来源(追加记录 / 驳回弹窗共用)。
*
* 此前两个页面各写一份 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 };
}