upload.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  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. * 从 JSON 体中解析文件访问地址(兼容多种后端约定)
  168. * @param {unknown} body
  169. * @returns {string}
  170. */
  171. function pickUrlFromJsonBody(body) {
  172. if (body == null) return "";
  173. if (typeof body === "string") {
  174. return normalizeToFileUrl(body) || (body.trim().startsWith("http") ? body.trim() : "");
  175. }
  176. if (typeof body !== "object" || Array.isArray(body)) {
  177. return deepFindHttpUrl(body);
  178. }
  179. const b = /** @type {Record<string, unknown>} */ (body);
  180. const unwrap = v => {
  181. if (v == null) return null;
  182. if (typeof v === "object" && !Array.isArray(v) && /** @type {Record<string, unknown>} */ (v).data !== undefined) {
  183. return /** @type {Record<string, unknown>} */ (v).data;
  184. }
  185. return v;
  186. };
  187. const candidates = [unwrap(b.data), b.data, b.result, unwrap(b.result), b.payload, b.body, b];
  188. for (const d of candidates) {
  189. if (d == null) continue;
  190. if (typeof d === "string") {
  191. const u = normalizeToFileUrl(d) || (d.trim().startsWith("http") ? d.trim() : "");
  192. if (u) return u;
  193. } else if (typeof d === "object") {
  194. let u = pickUrlLikeFromObject(d);
  195. if (u) return u;
  196. u = deepFindHttpUrl(d);
  197. if (u) return u;
  198. }
  199. }
  200. return deepFindHttpUrl(body);
  201. }
  202. /** 审核服务返回的违规大类 → 面向用户的短说明(与业务文案风格一致) */
  203. const VIOLATION_CATEGORY_USER_HINT = {
  204. GAMBLING: "图片涉及赌博等违规内容",
  205. PORNOGRAPHY: "图片涉及色情等违规内容",
  206. PORN: "图片涉及色情等违规内容",
  207. ADULT: "图片涉及色情等违规内容",
  208. SEXUAL: "图片涉及色情等违规内容",
  209. DRUGS: "图片涉及毒品等违规内容",
  210. DRUG: "图片涉及毒品等违规内容",
  211. VIOLENCE: "图片涉及暴力、血腥等违规内容",
  212. GORE: "图片涉及暴力、血腥等违规内容",
  213. POLITICAL: "图片涉及不当政治或敏感信息",
  214. POLITICS: "图片涉及不当政治或敏感信息",
  215. SPAM: "图片涉及违规推广或垃圾信息",
  216. ABUSE: "图片涉及辱骂、人身攻击等不文明内容",
  217. ILLEGAL: "图片含违法违规内容"
  218. };
  219. /**
  220. * 根据 reason / 关键词生成友好提示(与 aiImageUpload 中审核话术对齐)
  221. * @param {string} raw
  222. * @returns {string}
  223. */
  224. /** 仅返回「原因」短句,后缀由 formatSimpleUploadModerationMessage 统一拼接 */
  225. function mapSimpleModerationReasonToTip(raw) {
  226. const s = raw == null ? "" : String(raw).trim();
  227. if (!s) return "";
  228. if (/赌|赌博|casino|gambl|GAMBLING/i.test(s)) return "图片涉及赌博等违规内容";
  229. if (/色情|淫秽|porn|sex|PORNOGRAPHY|PORN/i.test(s)) return "图片涉及色情等违规内容";
  230. if (/毒品|涉毒|drug|DRUG/i.test(s)) return "图片涉及毒品等违规内容";
  231. if (/暴力|血腥|violence|gore|VIOLENCE/i.test(s)) return "图片涉及暴力、血腥等违规内容";
  232. if (/辱骂|谩骂|人身攻击|abuse|insult/i.test(s)) return "图片涉及不文明内容";
  233. if (/政治|谣言/i.test(s)) return "图片涉及违规信息";
  234. if (/广告|spam|营销/i.test(s)) return "图片涉及违规推广内容";
  235. if (/违法|违禁|illegal/i.test(s)) return "图片含违法违规内容";
  236. if (/黄赌毒/i.test(s)) return "请勿上传涉黄、涉赌、涉毒等违规内容";
  237. if (/审核|moderat|violation|flagged|VLM/i.test(s)) return "图片未通过内容审核";
  238. return "";
  239. }
  240. /**
  241. * 解析 /upload/simple 审核未通过等响应(saved: false + moderation)
  242. * @param {unknown} parsed
  243. * @returns {string} 非空则可直接作为 ElMessage / Error 文案
  244. */
  245. function formatSimpleUploadModerationMessage(parsed) {
  246. if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
  247. const p = /** @type {Record<string, unknown>} */ (parsed);
  248. const notSaved = p.saved === false;
  249. const mod = p.moderation;
  250. const hasModeration = mod != null && typeof mod === "object";
  251. let flagged = false;
  252. if (hasModeration) {
  253. const results = /** @type {Record<string, unknown>} */ (mod).results;
  254. if (Array.isArray(results) && results[0] && typeof results[0] === "object") {
  255. flagged = /** @type {Record<string, unknown>} */ (results[0]).flagged === true;
  256. }
  257. }
  258. if (!notSaved && !flagged) return "";
  259. const apiMsg = String(p.message ?? p.msg ?? "").trim();
  260. let firstResult = null;
  261. if (hasModeration) {
  262. const results = /** @type {Record<string, unknown>} */ (mod).results;
  263. if (Array.isArray(results) && results[0] && typeof results[0] === "object") {
  264. firstResult = /** @type {Record<string, unknown>} */ (results[0]);
  265. }
  266. }
  267. let reason = "";
  268. let firstCategory = "";
  269. if (firstResult) {
  270. reason = String(firstResult.reason ?? "").trim();
  271. const cats = firstResult.violation_categories;
  272. if (Array.isArray(cats) && cats[0] && typeof cats[0] === "object") {
  273. firstCategory = String(/** @type {Record<string, unknown>} */ (cats[0]).category ?? "")
  274. .trim()
  275. .toUpperCase();
  276. }
  277. }
  278. const suffix = "未通过审核,文件未保存。请更换后重试";
  279. if (firstCategory && VIOLATION_CATEGORY_USER_HINT[firstCategory]) {
  280. return `${VIOLATION_CATEGORY_USER_HINT[firstCategory]},${suffix}`;
  281. }
  282. const fromReason = mapSimpleModerationReasonToTip(reason);
  283. if (fromReason) {
  284. return `${fromReason.replace(/。$/, "")},${suffix}`;
  285. }
  286. if (apiMsg) {
  287. if (/请更换|更换后|重新选择|换一张/i.test(apiMsg)) return apiMsg;
  288. if (/未保存|未通过/.test(apiMsg)) return `${apiMsg.replace(/。$/, "")}。请更换后重试`;
  289. return `${apiMsg.replace(/。$/, "")},${suffix}`;
  290. }
  291. return "图片未通过内容审核,文件未保存。请更换后重试";
  292. }
  293. /**
  294. * 业务层 code / success(HTTP 200 但业务失败时常有)
  295. * @param {unknown} parsed
  296. */
  297. function assertSimpleUploadBusinessOk(parsed) {
  298. if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
  299. const p = /** @type {Record<string, unknown>} */ (parsed);
  300. if (p.success === false) {
  301. throw new Error(String(p.msg ?? p.message ?? p.error ?? "上传失败"));
  302. }
  303. const c = p.code;
  304. if (c === undefined || c === null) return;
  305. const ok = c === 200 || c === 0 || c === "200" || c === "0";
  306. if (!ok) {
  307. throw new Error(String(p.msg ?? p.message ?? p.error ?? "上传失败"));
  308. }
  309. }
  310. /**
  311. * POST multipart:字段 file;可选 filename(此处不传则服务端用原名)
  312. * @param {File} file
  313. * @param {{ signal?: AbortSignal }} [fetchOptions]
  314. * @returns {Promise<string>} 文件访问 URL
  315. */
  316. async function postFileToSimpleUpload(file, fetchOptions = {}) {
  317. const base = String(BASE_AI_URL || "").replace(/\/$/, "");
  318. if (!base) {
  319. throw new Error("未配置上传服务地址(VITE_AI_UPLOAD_BASE 或默认 /ai-upload)");
  320. }
  321. const formData = new FormData();
  322. formData.append("file", file, file.name || "file");
  323. const userStore = useUserStore();
  324. const headers = {};
  325. const token = userStore.token || "";
  326. if (token) {
  327. headers.Authorization = token;
  328. }
  329. const { signal } = fetchOptions;
  330. const uploadUrl = `${base}${SIMPLE_UPLOAD_PATH}`;
  331. let res;
  332. try {
  333. res = await fetch(uploadUrl, {
  334. method: "POST",
  335. headers,
  336. /** 不带跨域 Cookie,减轻上传服务 CORS 要求(鉴权仅用 Authorization) */
  337. credentials: "omit",
  338. body: formData,
  339. signal: signal ?? undefined
  340. });
  341. } catch (err) {
  342. if (isUploadUserCancelledError(err)) {
  343. throw err instanceof Error ? err : new Error(String(err ?? "AbortError"));
  344. }
  345. console.error("[upload/simple] 上传请求失败:", uploadUrl, err);
  346. throw new Error("上传失败");
  347. }
  348. const rawText = await res.text();
  349. let parsed = null;
  350. const trimmed = rawText.trim();
  351. if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
  352. try {
  353. parsed = JSON.parse(rawText);
  354. } catch (_) {
  355. /* ignore */
  356. }
  357. }
  358. if (!res.ok) {
  359. const modHint = formatSimpleUploadModerationMessage(parsed);
  360. let msg = modHint;
  361. if (!msg && parsed && typeof parsed === "object") {
  362. const p = /** @type {Record<string, unknown>} */ (parsed);
  363. const m = p.msg ?? p.message ?? p.error;
  364. if (m != null && String(m)) msg = String(m);
  365. }
  366. if (!msg && trimmed) msg = trimmed.slice(0, 200);
  367. if (!msg) msg = "上传失败";
  368. throw new Error(msg);
  369. }
  370. try {
  371. assertSimpleUploadBusinessOk(parsed);
  372. } catch (bizErr) {
  373. console.error("[upload/simple] 业务失败", bizErr, trimmed.slice(0, 500));
  374. throw bizErr;
  375. }
  376. const moderationUserTip = formatSimpleUploadModerationMessage(parsed);
  377. if (moderationUserTip) {
  378. throw new Error(moderationUserTip);
  379. }
  380. let url = pickUrlFromJsonBody(parsed);
  381. if (!url && trimmed.startsWith("http")) {
  382. url = trimmed;
  383. }
  384. if (!url) {
  385. const fromHeader =
  386. res.headers.get("x-file-url") ||
  387. res.headers.get("x-url") ||
  388. res.headers.get("x-oss-url") ||
  389. res.headers.get("file-url") ||
  390. "";
  391. if (fromHeader?.trim()) {
  392. const h = fromHeader.trim();
  393. url = normalizeToFileUrl(h) || (h.startsWith("http") ? h : "");
  394. }
  395. }
  396. if (!url) {
  397. const loc = res.headers.get("location") || res.headers.get("Location") || "";
  398. if (loc) {
  399. url = normalizeToFileUrl(loc.trim()) || (loc.trim().startsWith("http") ? loc.trim() : "");
  400. }
  401. }
  402. if (!url) {
  403. console.error("[upload/simple] 无法解析文件地址,响应片段:", trimmed.slice(0, 800));
  404. throw new Error("上传失败,未返回文件地址");
  405. }
  406. return url;
  407. }
  408. /**
  409. * 上传文件:图片与视频均走同一接口 POST /upload/simple,formData 键 file。
  410. * @param {File | File[] | FileList} files 浏览器文件对象;支持单个 File、数组或 FileList
  411. * @param {string} [_fileType] 保留参数,兼容旧调用(当前不参与分支)
  412. * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean; uploadSuccessMessage?: string | null; uploadOverlayTitle?: string }} [options]
  413. * showLoading:在未使用全局上传弹层时,用 ElMessage 提示上传中
  414. * skipSimpleUploadOverlay:为 true 时不展示 PopupLoading(不弹「上传成功」)
  415. * uploadSuccessMessage:传给弹层,`null` 表示上传成功不 toast(默认「上传成功」)
  416. * uploadOverlayTitle:弹层标题
  417. * @returns {Promise<string[]>} 上传成功后的文件 URL 列表
  418. */
  419. export async function uploadFilesToOss(files, _fileType, options = {}) {
  420. const { showLoading = false, skipSimpleUploadOverlay = false, uploadSuccessMessage, uploadOverlayTitle } = options;
  421. const fileArr = normalizeFiles(files);
  422. if (fileArr.length === 0) {
  423. throw new Error("请选择要上传的文件");
  424. }
  425. let closeLoading = () => {};
  426. if (showLoading && skipSimpleUploadOverlay) {
  427. const loading = ElMessage({ message: "上传中...", type: "info", duration: 0, showClose: false });
  428. closeLoading = () => loading.close();
  429. }
  430. try {
  431. const runUpload = async signal => {
  432. const uploadedUrls = [];
  433. for (const file of fileArr) {
  434. const url = await postFileToSimpleUpload(file, signal ? { signal } : {});
  435. uploadedUrls.push(url);
  436. }
  437. return uploadedUrls;
  438. };
  439. let uploadedUrls;
  440. if (skipSimpleUploadOverlay) {
  441. const overlaySignal = useSimpleUploadOverlayStore().getActiveAbortSignal?.();
  442. uploadedUrls = await runUpload(overlaySignal ?? null);
  443. } else {
  444. const overlayOpts =
  445. uploadSuccessMessage !== undefined || uploadOverlayTitle
  446. ? {
  447. title: uploadOverlayTitle,
  448. successMessage: uploadSuccessMessage
  449. }
  450. : undefined;
  451. uploadedUrls = await withSimpleUploadOverlay(signal => runUpload(signal), overlayOpts);
  452. }
  453. closeLoading();
  454. return uploadedUrls;
  455. } catch (e) {
  456. closeLoading();
  457. console.error("上传失败", e);
  458. if (isUploadUserCancelledError(e)) {
  459. throw e;
  460. }
  461. const msg = e?.message || "上传失败";
  462. ElMessage.error(msg);
  463. try {
  464. if (e && typeof e === "object") {
  465. Object.defineProperty(e, "__uploadMessageShown", { value: true, enumerable: false, configurable: true });
  466. }
  467. } catch (_) {
  468. /* ignore */
  469. }
  470. throw e;
  471. }
  472. }
  473. /**
  474. * 上传单个文件,返回 URL 字符串
  475. * @param {File} file
  476. * @param {string} [fileType]
  477. * @param {{ showLoading?: boolean }} [options]
  478. * @returns {Promise<string>}
  479. */
  480. export async function uploadFileToOss(file, fileType, options) {
  481. const urls = await uploadFilesToOss(file, fileType, options);
  482. return urls[0];
  483. }
  484. /**
  485. * 兼容 UploadImg 等组件的 api 格式:接收 FormData,返回 { data: { fileUrl } }
  486. * @param {FormData} formData 包含 file 字段
  487. * @param {string} [fileType] 不传或传空时按 file.type 自动区分 image / video
  488. * @param {{ showLoading?: boolean }} [options]
  489. * @returns {Promise<{ data: { fileUrl: string } }>}
  490. */
  491. export async function uploadFormDataToOss(formData, fileType, options = {}) {
  492. const file = formData.get("file");
  493. if (!file || !(file instanceof File)) {
  494. throw new Error("请选择要上传的文件");
  495. }
  496. const inferred =
  497. fileType === undefined || fileType === null || fileType === ""
  498. ? String(file.type || "").startsWith("video/")
  499. ? "video"
  500. : "image"
  501. : fileType;
  502. const url = await uploadFileToOss(file, inferred, options);
  503. return { data: { fileUrl: url } };
  504. }
  505. /**
  506. * FormData 走 `/upload/simple` 后,包装成旧组件习惯的 `{ code, msg, data: { fileUrl }, fileUrl }`(并非请求 `/file/uploadMore`)
  507. * @param {FormData} formData
  508. * @param {{ showLoading?: boolean }} [options]
  509. * @returns {Promise<{ code: number; msg: string; data: { fileUrl: string }; fileUrl: string }>}
  510. */
  511. export async function uploadFormDataSimpleCompat(formData, options = {}) {
  512. const file = formData.get("file");
  513. if (!file || !(file instanceof File)) {
  514. throw new Error("请选择要上传的文件");
  515. }
  516. const isVideo = String(file.type || "").startsWith("video/");
  517. const inner = await uploadFormDataToOss(formData, isVideo ? "video" : "image", options);
  518. const fileUrl = inner.data.fileUrl;
  519. return {
  520. code: 200,
  521. msg: "success",
  522. data: inner.data,
  523. fileUrl
  524. };
  525. }