upload.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. import { useUserStore } from "@/stores/modules/user";
  2. import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
  3. import { ElMessage } from "element-plus";
  4. import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_URL } from "@/utils/config";
  5. import { withSimpleUploadOverlay } from "@/utils/withSimpleUploadOverlay";
  6. /** 简单上传的路径(不含 base) */
  7. const SIMPLE_UPLOAD_PATH = "/upload/simple";
  8. /**
  9. * 已由 uploadFilesToOss 弹出过 ElMessage,调用方勿重复 error
  10. * @param {unknown} err
  11. */
  12. export function isUploadApiErrorAlreadyMessaged(err) {
  13. return Boolean(err && typeof err === "object" && /** @type {{ __uploadMessageShown?: boolean }} */ (err).__uploadMessageShown);
  14. }
  15. /**
  16. * 用户取消上传(关闭弹层、AbortSignal)或请求被顶替:不向用户弹出技术性英文(如 signal is aborted without reason)
  17. * @param {unknown} err
  18. * @returns {boolean}
  19. */
  20. export function isUploadUserCancelledError(err) {
  21. if (err == null || typeof err !== "object") return false;
  22. const o = /** @type {{ name?: unknown; message?: unknown }} */ (err);
  23. if (String(o.name ?? "") === "AbortError") return true;
  24. const msg = String(o.message ?? "").toLowerCase();
  25. if (!msg) return false;
  26. return (
  27. msg.includes("signal is aborted") ||
  28. msg.includes("the user aborted") ||
  29. msg.includes("aborted a request") ||
  30. msg.includes("body stream was aborted")
  31. );
  32. }
  33. /** 从路径或 fileType 解析文件后缀(如 jpg、mp4),用于生成带格式的文件名 */
  34. export function getFileExtension(filePath, fileType) {
  35. const pathPart = (filePath || "").split("?")[0];
  36. const lastSegment = pathPart.split("/").pop() || "";
  37. const dot = lastSegment.lastIndexOf(".");
  38. if (dot > 0) {
  39. const ext = lastSegment.slice(dot + 1).toLowerCase();
  40. if (/^[a-z0-9]+$/i.test(ext) && ext.length <= 5) return ext;
  41. }
  42. if (fileType === "video") return "mp4";
  43. if (fileType === "image") return "jpg";
  44. return "jpg";
  45. }
  46. /**
  47. * 将入参统一为 File[]
  48. * @param {File | File[] | FileList} input
  49. * @returns {File[]}
  50. */
  51. function normalizeFiles(input) {
  52. if (input == null) return [];
  53. if (input instanceof File) return [input];
  54. if (typeof FileList !== "undefined" && input instanceof FileList) {
  55. return Array.from(input);
  56. }
  57. if (Array.isArray(input)) {
  58. return input.filter(item => item instanceof File);
  59. }
  60. throw new Error("请传入 File、File[] 或 FileList");
  61. }
  62. /**
  63. * 常见「可直接用于 img/video src」的字段(含蛇形命名)
  64. * 注意:勿把 upload_id / filename 放前面——/upload/simple 会返回 upload_id 与完整 URL 并存,
  65. * 若先按「裸 id」拼到 /files/ 会得到错误地址,图片会裂图。
  66. */
  67. const URL_LIKE_KEYS = [
  68. "download_url",
  69. "downloadUrl",
  70. "preview_url",
  71. "previewUrl",
  72. "url",
  73. "fileUrl",
  74. "file_url",
  75. "accessUrl",
  76. "access_url",
  77. "ossUrl",
  78. "cdnUrl",
  79. "path",
  80. "filePath",
  81. "file_path",
  82. "objectKey",
  83. "object_key",
  84. "key",
  85. "location",
  86. "href"
  87. ];
  88. /**
  89. * 将接口返回的路径或 id 规范为可访问 URL
  90. * @param {string} raw
  91. * @returns {string}
  92. */
  93. function normalizeToFileUrl(raw) {
  94. const t = String(raw ?? "").trim();
  95. if (!t) return "";
  96. if (/^https?:\/\//i.test(t)) return t;
  97. if (t.startsWith("//")) return `https:${t}`;
  98. const filesBase = String(AI_UPLOAD_FILES_PUBLIC_BASE || "").replace(/\/$/, "");
  99. if (filesBase.startsWith("http")) {
  100. try {
  101. if (t.startsWith("/")) {
  102. return new URL(t, new URL(`${filesBase}/`).origin).href;
  103. }
  104. if (/^files\//i.test(t)) {
  105. return new URL(`/${t.replace(/^\/+/, "")}`, new URL(`${filesBase}/`).origin).href;
  106. }
  107. // 相对路径 dir/file.ext(无协议)
  108. if (t.includes("/") && !t.startsWith("/") && !/^[a-z]+:/i.test(t)) {
  109. return `${filesBase}/${t.replace(/^\/+/, "")}`;
  110. }
  111. // 仅返回 uuid/文件名 等,按「对外文件基址 + 片段」拼接
  112. if (!t.includes("/") && !t.includes("\\") && t.length <= 512) {
  113. return `${filesBase}/${encodeURIComponent(t)}`;
  114. }
  115. } catch {
  116. /* ignore */
  117. }
  118. }
  119. return "";
  120. }
  121. /**
  122. * 从对象上按已知字段取「可能是地址」的字符串
  123. * @param {unknown} o
  124. * @returns {string}
  125. */
  126. function pickUrlLikeFromObject(o) {
  127. if (!o || typeof o !== "object" || Array.isArray(o)) return "";
  128. const r = /** @type {Record<string, unknown>} */ (o);
  129. for (const k of URL_LIKE_KEYS) {
  130. const v = r[k];
  131. if (typeof v === "string" && v.trim()) {
  132. const abs = normalizeToFileUrl(v);
  133. if (abs) return abs;
  134. }
  135. }
  136. return "";
  137. }
  138. /**
  139. * 深度查找第一个 http(s) 字符串
  140. * @param {unknown} val
  141. * @param {number} depth
  142. * @param {number} maxDepth
  143. * @returns {string}
  144. */
  145. function deepFindHttpUrl(val, depth = 0, maxDepth = 8) {
  146. if (depth > maxDepth || val == null) return "";
  147. if (typeof val === "string") {
  148. const t = val.trim();
  149. return /^https?:\/\//i.test(t) ? t : "";
  150. }
  151. if (Array.isArray(val)) {
  152. for (const item of val) {
  153. const u = deepFindHttpUrl(item, depth + 1, maxDepth);
  154. if (u) return u;
  155. }
  156. return "";
  157. }
  158. if (typeof val === "object") {
  159. for (const k of Object.keys(val)) {
  160. const u = deepFindHttpUrl(/** @type {Record<string, unknown>} */ (val)[k], depth + 1, maxDepth);
  161. if (u) return u;
  162. }
  163. }
  164. return "";
  165. }
  166. /**
  167. * 递归查找上传接口返回的 `download_url` / `downloadUrl`(后台保存相册等常以该字段为 imgUrl)
  168. * @param {unknown} val
  169. * @param {number} depth
  170. * @param {number} maxDepth
  171. * @returns {string}
  172. */
  173. function preferDownloadUrlFromBody(val, depth = 0, maxDepth = 10) {
  174. if (depth > maxDepth || val == null) return "";
  175. if (Array.isArray(val)) {
  176. for (const item of val) {
  177. const u = preferDownloadUrlFromBody(item, depth + 1, maxDepth);
  178. if (u) return u;
  179. }
  180. return "";
  181. }
  182. if (typeof val !== "object") return "";
  183. const o = /** @type {Record<string, unknown>} */ (val);
  184. for (const k of ["download_url", "downloadUrl"]) {
  185. const v = o[k];
  186. if (typeof v === "string" && v.trim()) {
  187. const t = v.trim();
  188. const abs = normalizeToFileUrl(t);
  189. if (abs) return abs;
  190. if (/^https?:\/\//i.test(t)) return t;
  191. }
  192. }
  193. for (const v of Object.values(o)) {
  194. const u = preferDownloadUrlFromBody(v, depth + 1, maxDepth);
  195. if (u) return u;
  196. }
  197. return "";
  198. }
  199. /**
  200. * 从 JSON 体中解析文件访问地址(兼容多种后端约定)
  201. * @param {unknown} body
  202. * @returns {string}
  203. */
  204. function pickUrlFromJsonBody(body) {
  205. if (body == null) return "";
  206. if (typeof body === "string") {
  207. return normalizeToFileUrl(body) || (body.trim().startsWith("http") ? body.trim() : "");
  208. }
  209. if (typeof body === "object" && !Array.isArray(body)) {
  210. const dUrl = preferDownloadUrlFromBody(body);
  211. if (dUrl) return dUrl;
  212. }
  213. if (Array.isArray(body)) {
  214. const dUrl = preferDownloadUrlFromBody(body);
  215. if (dUrl) return dUrl;
  216. return deepFindHttpUrl(body);
  217. }
  218. if (typeof body !== "object" || Array.isArray(body)) {
  219. return deepFindHttpUrl(body);
  220. }
  221. const b = /** @type {Record<string, unknown>} */ (body);
  222. const unwrap = v => {
  223. if (v == null) return null;
  224. if (typeof v === "object" && !Array.isArray(v) && /** @type {Record<string, unknown>} */ (v).data !== undefined) {
  225. return /** @type {Record<string, unknown>} */ (v).data;
  226. }
  227. return v;
  228. };
  229. const candidates = [unwrap(b.data), b.data, b.result, unwrap(b.result), b.payload, b.body, b];
  230. for (const d of candidates) {
  231. if (d == null) continue;
  232. if (typeof d === "string") {
  233. const u = normalizeToFileUrl(d) || (d.trim().startsWith("http") ? d.trim() : "");
  234. if (u) return u;
  235. } else if (typeof d === "object") {
  236. let u = pickUrlLikeFromObject(d);
  237. if (u) return u;
  238. u = deepFindHttpUrl(d);
  239. if (u) return u;
  240. }
  241. }
  242. return deepFindHttpUrl(body);
  243. }
  244. /** 审核服务返回的违规大类 → 面向用户的短说明(与业务文案风格一致) */
  245. const VIOLATION_CATEGORY_USER_HINT = {
  246. GAMBLING: "图片涉及赌博等违规内容",
  247. PORNOGRAPHY: "图片涉及色情等违规内容",
  248. PORN: "图片涉及色情等违规内容",
  249. ADULT: "图片涉及色情等违规内容",
  250. SEXUAL: "图片涉及色情等违规内容",
  251. DRUGS: "图片涉及毒品等违规内容",
  252. DRUG: "图片涉及毒品等违规内容",
  253. VIOLENCE: "图片涉及暴力、血腥等违规内容",
  254. GORE: "图片涉及暴力、血腥等违规内容",
  255. POLITICAL: "图片涉及不当政治或敏感信息",
  256. POLITICS: "图片涉及不当政治或敏感信息",
  257. SPAM: "图片涉及违规推广或垃圾信息",
  258. ABUSE: "图片涉及辱骂、人身攻击等不文明内容",
  259. ILLEGAL: "图片含违法违规内容"
  260. };
  261. /**
  262. * 根据 reason / 关键词生成友好提示(与 aiImageUpload 中审核话术对齐)
  263. * @param {string} raw
  264. * @returns {string}
  265. */
  266. /** 仅返回「原因」短句,后缀由 formatSimpleUploadModerationMessage 统一拼接 */
  267. function mapSimpleModerationReasonToTip(raw) {
  268. const s = raw == null ? "" : String(raw).trim();
  269. if (!s) return "";
  270. if (/赌|赌博|casino|gambl|GAMBLING/i.test(s)) return "图片涉及赌博等违规内容";
  271. if (/色情|淫秽|porn|sex|PORNOGRAPHY|PORN/i.test(s)) return "图片涉及色情等违规内容";
  272. if (/毒品|涉毒|drug|DRUG/i.test(s)) return "图片涉及毒品等违规内容";
  273. if (/暴力|血腥|violence|gore|VIOLENCE/i.test(s)) return "图片涉及暴力、血腥等违规内容";
  274. if (/辱骂|谩骂|人身攻击|abuse|insult/i.test(s)) return "图片涉及不文明内容";
  275. if (/政治|谣言/i.test(s)) return "图片涉及违规信息";
  276. if (/广告|spam|营销/i.test(s)) return "图片涉及违规推广内容";
  277. if (/违法|违禁|illegal/i.test(s)) return "图片含违法违规内容";
  278. if (/黄赌毒/i.test(s)) return "请勿上传涉黄、涉赌、涉毒等违规内容";
  279. if (/审核|moderat|violation|flagged|VLM/i.test(s)) return "图片未通过内容审核";
  280. return "";
  281. }
  282. /**
  283. * 解析 /upload/simple 审核未通过等响应(saved: false + moderation)
  284. * @param {unknown} parsed
  285. * @returns {string} 非空则可直接作为 ElMessage / Error 文案
  286. */
  287. function formatSimpleUploadModerationMessage(parsed) {
  288. if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
  289. const p = /** @type {Record<string, unknown>} */ (parsed);
  290. const notSaved = p.saved === false;
  291. const mod = p.moderation;
  292. const hasModeration = mod != null && typeof mod === "object";
  293. let flagged = false;
  294. if (hasModeration) {
  295. const results = /** @type {Record<string, unknown>} */ (mod).results;
  296. if (Array.isArray(results) && results[0] && typeof results[0] === "object") {
  297. flagged = /** @type {Record<string, unknown>} */ (results[0]).flagged === true;
  298. }
  299. }
  300. if (!notSaved && !flagged) return "";
  301. const apiMsg = String(p.message ?? p.msg ?? "").trim();
  302. let firstResult = null;
  303. if (hasModeration) {
  304. const results = /** @type {Record<string, unknown>} */ (mod).results;
  305. if (Array.isArray(results) && results[0] && typeof results[0] === "object") {
  306. firstResult = /** @type {Record<string, unknown>} */ (results[0]);
  307. }
  308. }
  309. let reason = "";
  310. let firstCategory = "";
  311. if (firstResult) {
  312. reason = String(firstResult.reason ?? "").trim();
  313. const cats = firstResult.violation_categories;
  314. if (Array.isArray(cats) && cats[0] && typeof cats[0] === "object") {
  315. firstCategory = String(/** @type {Record<string, unknown>} */ (cats[0]).category ?? "")
  316. .trim()
  317. .toUpperCase();
  318. }
  319. }
  320. const suffix = "未通过审核,文件未保存。请更换后重试";
  321. if (firstCategory && VIOLATION_CATEGORY_USER_HINT[firstCategory]) {
  322. return `${VIOLATION_CATEGORY_USER_HINT[firstCategory]},${suffix}`;
  323. }
  324. const fromReason = mapSimpleModerationReasonToTip(reason);
  325. if (fromReason) {
  326. return `${fromReason.replace(/。$/, "")},${suffix}`;
  327. }
  328. if (apiMsg) {
  329. if (/请更换|更换后|重新选择|换一张/i.test(apiMsg)) return apiMsg;
  330. if (/未保存|未通过/.test(apiMsg)) return `${apiMsg.replace(/。$/, "")}。请更换后重试`;
  331. return `${apiMsg.replace(/。$/, "")},${suffix}`;
  332. }
  333. return "图片未通过内容审核,文件未保存。请更换后重试";
  334. }
  335. /**
  336. * 业务层 code / success(HTTP 200 但业务失败时常有)
  337. * @param {unknown} parsed
  338. */
  339. function assertSimpleUploadBusinessOk(parsed) {
  340. if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
  341. const p = /** @type {Record<string, unknown>} */ (parsed);
  342. if (p.success === false) {
  343. throw new Error(String(p.msg ?? p.message ?? p.error ?? "上传失败"));
  344. }
  345. const c = p.code;
  346. if (c === undefined || c === null) return;
  347. const ok = c === 200 || c === 0 || c === "200" || c === "0";
  348. if (!ok) {
  349. throw new Error(String(p.msg ?? p.message ?? p.error ?? "上传失败"));
  350. }
  351. }
  352. /**
  353. * POST multipart:字段 file;解析展示用地址与业务保存用的 download_url
  354. * @param {File} file
  355. * @param {{ signal?: AbortSignal }} [fetchOptions]
  356. * @returns {Promise<{ url: string; downloadUrl: string; parsed: unknown }>}
  357. */
  358. async function postFileToSimpleUploadDetailed(file, fetchOptions = {}) {
  359. const base = String(BASE_AI_URL || "").replace(/\/$/, "");
  360. if (!base) {
  361. throw new Error("未配置上传服务地址(VITE_AI_UPLOAD_BASE 或默认 /ai-upload)");
  362. }
  363. const formData = new FormData();
  364. formData.append("file", file, file.name || "file");
  365. const userStore = useUserStore();
  366. const headers = {};
  367. const token = userStore.token || "";
  368. if (token) {
  369. headers.Authorization = token;
  370. }
  371. const { signal } = fetchOptions;
  372. const uploadUrl = `${base}${SIMPLE_UPLOAD_PATH}`;
  373. let res;
  374. try {
  375. res = await fetch(uploadUrl, {
  376. method: "POST",
  377. headers,
  378. /** 不带跨域 Cookie,减轻上传服务 CORS 要求(鉴权仅用 Authorization) */
  379. credentials: "omit",
  380. body: formData,
  381. signal: signal ?? undefined
  382. });
  383. } catch (err) {
  384. if (isUploadUserCancelledError(err)) {
  385. throw err instanceof Error ? err : new Error(String(err ?? "AbortError"));
  386. }
  387. console.error("[upload/simple] 上传请求失败:", uploadUrl, err);
  388. throw new Error("上传失败");
  389. }
  390. const rawText = await res.text();
  391. let parsed = null;
  392. const trimmed = rawText.trim();
  393. if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
  394. try {
  395. parsed = JSON.parse(rawText);
  396. } catch (_) {
  397. /* ignore */
  398. }
  399. }
  400. if (!res.ok) {
  401. const modHint = formatSimpleUploadModerationMessage(parsed);
  402. let msg = modHint;
  403. if (!msg && parsed && typeof parsed === "object") {
  404. const p = /** @type {Record<string, unknown>} */ (parsed);
  405. const m = p.msg ?? p.message ?? p.error;
  406. if (m != null && String(m)) msg = String(m);
  407. }
  408. if (!msg && trimmed) msg = trimmed.slice(0, 200);
  409. if (!msg) msg = "上传失败";
  410. throw new Error(msg);
  411. }
  412. try {
  413. assertSimpleUploadBusinessOk(parsed);
  414. } catch (bizErr) {
  415. console.error("[upload/simple] 业务失败", bizErr, trimmed.slice(0, 500));
  416. throw bizErr;
  417. }
  418. const moderationUserTip = formatSimpleUploadModerationMessage(parsed);
  419. if (moderationUserTip) {
  420. throw new Error(moderationUserTip);
  421. }
  422. const downloadUrl = (preferDownloadUrlFromBody(parsed) || "").trim();
  423. let url = pickUrlFromJsonBody(parsed);
  424. if (!url && trimmed.startsWith("http")) {
  425. url = trimmed;
  426. }
  427. if (!url) {
  428. const fromHeader =
  429. res.headers.get("x-file-url") ||
  430. res.headers.get("x-url") ||
  431. res.headers.get("x-oss-url") ||
  432. res.headers.get("file-url") ||
  433. "";
  434. if (fromHeader?.trim()) {
  435. const h = fromHeader.trim();
  436. url = normalizeToFileUrl(h) || (h.startsWith("http") ? h : "");
  437. }
  438. }
  439. if (!url) {
  440. const loc = res.headers.get("location") || res.headers.get("Location") || "";
  441. if (loc) {
  442. url = normalizeToFileUrl(loc.trim()) || (loc.trim().startsWith("http") ? loc.trim() : "");
  443. }
  444. }
  445. if (!url) {
  446. console.error("[upload/simple] 无法解析文件地址,响应片段:", trimmed.slice(0, 800));
  447. throw new Error("上传失败,未返回文件地址");
  448. }
  449. return { url, downloadUrl, parsed };
  450. }
  451. /**
  452. * POST multipart:字段 file;可选 filename(此处不传则服务端用原名)
  453. * @param {File} file
  454. * @param {{ signal?: AbortSignal }} [fetchOptions]
  455. * @returns {Promise<string>} 文件访问 URL
  456. */
  457. async function postFileToSimpleUpload(file, fetchOptions = {}) {
  458. const { url } = await postFileToSimpleUploadDetailed(file, fetchOptions);
  459. return url;
  460. }
  461. /**
  462. * 上传文件:图片与视频均走同一接口 POST /upload/simple,formData 键 file。
  463. * @param {File | File[] | FileList} files 浏览器文件对象;支持单个 File、数组或 FileList
  464. * @param {string} [_fileType] 保留参数,兼容旧调用(当前不参与分支)
  465. * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean; uploadSuccessMessage?: string | null; uploadOverlayTitle?: string }} [options]
  466. * showLoading:在未使用全局上传弹层时,用 ElMessage 提示上传中
  467. * skipSimpleUploadOverlay:为 true 时不展示 PopupLoading(不弹「上传成功」)
  468. * uploadSuccessMessage:传给弹层,`null` 表示上传成功不 toast(默认「上传成功」)
  469. * uploadOverlayTitle:弹层标题
  470. * @returns {Promise<string[]>} 上传成功后的文件 URL 列表
  471. */
  472. export async function uploadFilesToOss(files, _fileType, options = {}) {
  473. const { showLoading = false, skipSimpleUploadOverlay = false, uploadSuccessMessage, uploadOverlayTitle } = options;
  474. const fileArr = normalizeFiles(files);
  475. if (fileArr.length === 0) {
  476. throw new Error("请选择要上传的文件");
  477. }
  478. let closeLoading = () => {};
  479. if (showLoading && skipSimpleUploadOverlay) {
  480. const loading = ElMessage({ message: "上传中...", type: "info", duration: 0, showClose: false });
  481. closeLoading = () => loading.close();
  482. }
  483. try {
  484. const runUpload = async signal => {
  485. const uploadedUrls = [];
  486. for (const file of fileArr) {
  487. const url = await postFileToSimpleUpload(file, signal ? { signal } : {});
  488. uploadedUrls.push(url);
  489. }
  490. return uploadedUrls;
  491. };
  492. let uploadedUrls;
  493. if (skipSimpleUploadOverlay) {
  494. const overlaySignal = useSimpleUploadOverlayStore().getActiveAbortSignal?.();
  495. uploadedUrls = await runUpload(overlaySignal ?? null);
  496. } else {
  497. const overlayOpts =
  498. uploadSuccessMessage !== undefined || uploadOverlayTitle
  499. ? {
  500. title: uploadOverlayTitle,
  501. successMessage: uploadSuccessMessage
  502. }
  503. : undefined;
  504. uploadedUrls = await withSimpleUploadOverlay(signal => runUpload(signal), overlayOpts);
  505. }
  506. closeLoading();
  507. return uploadedUrls;
  508. } catch (e) {
  509. closeLoading();
  510. console.error("上传失败", e);
  511. if (isUploadUserCancelledError(e)) {
  512. throw e;
  513. }
  514. const msg = e?.message || "上传失败";
  515. ElMessage.error(msg);
  516. try {
  517. if (e && typeof e === "object") {
  518. Object.defineProperty(e, "__uploadMessageShown", { value: true, enumerable: false, configurable: true });
  519. }
  520. } catch (_) {
  521. /* ignore */
  522. }
  523. throw e;
  524. }
  525. }
  526. /**
  527. * 单文件上传:返回列表展示用地址与业务保存用的 `download_url`(响应中无则为空字符串)
  528. * @param {File} file
  529. * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean; uploadSuccessMessage?: string | null; uploadOverlayTitle?: string }} [options]
  530. * @returns {Promise<{ fileUrl: string; downloadUrl: string }>}
  531. */
  532. export async function uploadFileToOssWithDownloadMeta(file, options = {}) {
  533. const { showLoading = false, skipSimpleUploadOverlay = false, uploadSuccessMessage, uploadOverlayTitle } = options;
  534. if (!(file instanceof File)) {
  535. throw new Error("请选择要上传的文件");
  536. }
  537. let closeLoading = () => {};
  538. if (showLoading && skipSimpleUploadOverlay) {
  539. const loading = ElMessage({ message: "上传中...", type: "info", duration: 0, showClose: false });
  540. closeLoading = () => loading.close();
  541. }
  542. try {
  543. const runUpload = async signal => {
  544. const { url, downloadUrl } = await postFileToSimpleUploadDetailed(file, signal ? { signal } : {});
  545. return { fileUrl: url, downloadUrl };
  546. };
  547. let result;
  548. if (skipSimpleUploadOverlay) {
  549. const overlaySignal = useSimpleUploadOverlayStore().getActiveAbortSignal?.();
  550. result = await runUpload(overlaySignal ?? null);
  551. } else {
  552. const overlayOpts =
  553. uploadSuccessMessage !== undefined || uploadOverlayTitle
  554. ? {
  555. title: uploadOverlayTitle,
  556. successMessage: uploadSuccessMessage
  557. }
  558. : undefined;
  559. result = await withSimpleUploadOverlay(signal => runUpload(signal), overlayOpts);
  560. }
  561. closeLoading();
  562. return result;
  563. } catch (e) {
  564. closeLoading();
  565. console.error("上传失败", e);
  566. if (isUploadUserCancelledError(e)) {
  567. throw e;
  568. }
  569. const msg = e?.message || "上传失败";
  570. ElMessage.error(msg);
  571. try {
  572. if (e && typeof e === "object") {
  573. Object.defineProperty(e, "__uploadMessageShown", { value: true, enumerable: false, configurable: true });
  574. }
  575. } catch (_) {
  576. /* ignore */
  577. }
  578. throw e;
  579. }
  580. }
  581. /**
  582. * 上传单个文件,返回 URL 字符串
  583. * @param {File} file
  584. * @param {string} [fileType]
  585. * @param {{ showLoading?: boolean }} [options]
  586. * @returns {Promise<string>}
  587. */
  588. export async function uploadFileToOss(file, fileType, options) {
  589. const urls = await uploadFilesToOss(file, fileType, options);
  590. return urls[0];
  591. }
  592. /**
  593. * 兼容 UploadImg 等组件的 api 格式:接收 FormData,返回 { data: { fileUrl } }
  594. * @param {FormData} formData 包含 file 字段
  595. * @param {string} [fileType] 不传或传空时按 file.type 自动区分 image / video
  596. * @param {{ showLoading?: boolean }} [options]
  597. * @returns {Promise<{ data: { fileUrl: string } }>}
  598. */
  599. export async function uploadFormDataToOss(formData, fileType, options = {}) {
  600. const file = formData.get("file");
  601. if (!file || !(file instanceof File)) {
  602. throw new Error("请选择要上传的文件");
  603. }
  604. const inferred =
  605. fileType === undefined || fileType === null || fileType === ""
  606. ? String(file.type || "").startsWith("video/")
  607. ? "video"
  608. : "image"
  609. : fileType;
  610. const url = await uploadFileToOss(file, inferred, options);
  611. return { data: { fileUrl: url } };
  612. }
  613. /**
  614. * FormData 走 `/upload/simple` 后,包装成旧组件习惯的 `{ code, msg, data: { fileUrl }, fileUrl }`(并非请求 `/file/uploadMore`)
  615. * @param {FormData} formData
  616. * @param {{ showLoading?: boolean }} [options]
  617. * @returns {Promise<{ code: number; msg: string; data: { fileUrl: string }; fileUrl: string }>}
  618. */
  619. export async function uploadFormDataSimpleCompat(formData, options = {}) {
  620. const file = formData.get("file");
  621. if (!file || !(file instanceof File)) {
  622. throw new Error("请选择要上传的文件");
  623. }
  624. const isVideo = String(file.type || "").startsWith("video/");
  625. const inner = await uploadFormDataToOss(formData, isVideo ? "video" : "image", options);
  626. const fileUrl = inner.data.fileUrl;
  627. return {
  628. code: 200,
  629. msg: "success",
  630. data: inner.data,
  631. fileUrl
  632. };
  633. }