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():
  原先按引用赋值,删图会直接改动底层记录对象,此时点「取消」被删的图
  在本地列表里也已经没了
This commit is contained in:
2026-09-15 15:51:44 +08:00
parent eef4e5b72d
commit 7fca315ff4
3 changed files with 241 additions and 36 deletions

View File

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