upload.js 18 KB

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