chore: fork from IRIS track 供 LICA 部门独立运行

- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update)
- 组织隔离目标: LICA
- 端口规划: 前端 8030 / 后端 8031 / 数据库 8032
- 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本)
- 已排除工作区未提交改动,取干净的 192c8ee 状态
This commit is contained in:
2026-09-21 15:56:52 +08:00
commit 3286a11bc7
212 changed files with 44060 additions and 0 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 };
}