|
|
@@ -1,9 +1,162 @@
|
|
|
+import OSS from "ali-oss";
|
|
|
import { useUserStore } from "@/stores/modules/user";
|
|
|
import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
|
|
|
import { ElMessage } from "element-plus";
|
|
|
-import { AI_UPLOAD_FILES_PUBLIC_BASE, SIMPLE_UPLOAD_URL } from "@/utils/config";
|
|
|
+import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_URL } from "@/utils/config";
|
|
|
import { withSimpleUploadOverlay } from "@/utils/withSimpleUploadOverlay";
|
|
|
|
|
|
+/** 简单上传的路径(不含 base) */
|
|
|
+const SIMPLE_UPLOAD_PATH = "/upload/simple";
|
|
|
+
|
|
|
+/** OSS STS 临时凭证(官方相册等直传 OSS) */
|
|
|
+const OSS_STS_TOKEN_PATH = "/upload/oss/sts-token";
|
|
|
+
|
|
|
+/** OSS 直传成功后的 AI 审核(与 sts-token 同基址 BASE_AI_URL) */
|
|
|
+const OSS_FINALIZE_PATH = "/upload/oss/finalize";
|
|
|
+
|
|
|
+/** 浏览器默认可达;内网可配 VITE_OSS_UPLOAD_ENDPOINT=oss-cn-beijing-internal.aliyuncs.com */
|
|
|
+const DEFAULT_OSS_UPLOAD_ENDPOINT = "oss-cn-beijing.aliyuncs.com";
|
|
|
+
|
|
|
+/** 商户 Web 端默认上限:图 20MB、视频 200MB */
|
|
|
+const WEB_UPLOAD_IMAGE_MAX_BYTES = 20 * 1024 * 1024;
|
|
|
+const WEB_UPLOAD_VIDEO_MAX_BYTES = 200 * 1024 * 1024;
|
|
|
+const WEB_UPLOAD_TIP_IMAGE = "图片建议不超过 20MB";
|
|
|
+const WEB_UPLOAD_TIP_VIDEO = "视频建议不超过 200MB";
|
|
|
+
|
|
|
+/** 仅门店封面、发布动态等业务放宽至 500MB(通过 options.maxVideoMb 传入) */
|
|
|
+export const WEB_UPLOAD_VIDEO_MAX_MB_DEFAULT = 200;
|
|
|
+export const WEB_UPLOAD_VIDEO_MAX_MB_LARGE = 500;
|
|
|
+
|
|
|
+/** STS / finalize 等 fetch 超时(毫秒) */
|
|
|
+const OSS_STS_FETCH_TIMEOUT_MS = 120000;
|
|
|
+const OSS_FINALIZE_FETCH_TIMEOUT_IMAGE_MS = 180000;
|
|
|
+const OSS_FINALIZE_FETCH_TIMEOUT_VIDEO_MS = 600000;
|
|
|
+/** ali-oss 直传超时:图片 2 分钟;大视频最长 30 分钟 */
|
|
|
+const OSS_PUT_TIMEOUT_IMAGE_MS = 120000;
|
|
|
+const OSS_PUT_TIMEOUT_VIDEO_MAX_MS = 30 * 60 * 1000;
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {unknown} err
|
|
|
+ * @returns {boolean}
|
|
|
+ */
|
|
|
+export function isUploadTimeoutError(err) {
|
|
|
+ if (err == null) return false;
|
|
|
+ const o = typeof err === "object" ? /** @type {{ name?: unknown; message?: unknown; code?: unknown }} */ (err) : null;
|
|
|
+ const name = String(o?.name ?? "").toLowerCase();
|
|
|
+ const code = String(o?.code ?? "").toLowerCase();
|
|
|
+ const msg = String((err instanceof Error ? err.message : "") || o?.message || err || "").toLowerCase();
|
|
|
+ return (
|
|
|
+ name === "timeouterror" ||
|
|
|
+ code === "connectiontimeouterror" ||
|
|
|
+ code === "responsetimeouterror" ||
|
|
|
+ msg.includes("timeout") ||
|
|
|
+ msg.includes("timed out") ||
|
|
|
+ msg.includes("超时")
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 合并用户取消 signal 与上传超时
|
|
|
+ * @param {AbortSignal | null | undefined} userSignal
|
|
|
+ * @param {number} timeoutMs
|
|
|
+ * @returns {AbortSignal | undefined}
|
|
|
+ */
|
|
|
+function createUploadFetchSignal(userSignal, timeoutMs) {
|
|
|
+ if (!timeoutMs || timeoutMs <= 0) return userSignal ?? undefined;
|
|
|
+ const controller = new AbortController();
|
|
|
+ let timer = null;
|
|
|
+ const fireAbort = reason => {
|
|
|
+ if (timer) {
|
|
|
+ clearTimeout(timer);
|
|
|
+ timer = null;
|
|
|
+ }
|
|
|
+ if (!controller.signal.aborted) {
|
|
|
+ try {
|
|
|
+ controller.abort(reason);
|
|
|
+ } catch (_) {
|
|
|
+ /* ignore */
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ timer = setTimeout(() => {
|
|
|
+ fireAbort(new DOMException("上传超时,请检查网络后重试", "TimeoutError"));
|
|
|
+ }, timeoutMs);
|
|
|
+ if (userSignal) {
|
|
|
+ if (userSignal.aborted) {
|
|
|
+ fireAbort(userSignal.reason);
|
|
|
+ } else {
|
|
|
+ userSignal.addEventListener("abort", () => fireAbort(userSignal.reason), { once: true });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return controller.signal;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {File} file
|
|
|
+ * @returns {number}
|
|
|
+ */
|
|
|
+function resolveOssPutTimeoutMs(file) {
|
|
|
+ const mime = String(file?.type || "").toLowerCase();
|
|
|
+ const size = Number(file?.size) || 0;
|
|
|
+ const isVideo = mime.startsWith("video/");
|
|
|
+ if (!isVideo && size <= WEB_UPLOAD_IMAGE_MAX_BYTES) {
|
|
|
+ return OSS_PUT_TIMEOUT_IMAGE_MS;
|
|
|
+ }
|
|
|
+ const estimatedMs = Math.ceil(size / (200 * 1024)) * 1000;
|
|
|
+ return Math.min(OSS_PUT_TIMEOUT_VIDEO_MAX_MS, Math.max(10 * 60 * 1000, estimatedMs));
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {File} file
|
|
|
+ * @returns {number}
|
|
|
+ */
|
|
|
+function resolveOssFinalizeFetchTimeoutMs(file) {
|
|
|
+ const mime = String(file?.type || "").toLowerCase();
|
|
|
+ return mime.startsWith("video/") ? OSS_FINALIZE_FETCH_TIMEOUT_VIDEO_MS : OSS_FINALIZE_FETCH_TIMEOUT_IMAGE_MS;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {{ maxVideoMb?: number }} [options]
|
|
|
+ * @returns {number}
|
|
|
+ */
|
|
|
+function resolveMaxVideoBytes(options = {}) {
|
|
|
+ const mb = Number(options.maxVideoMb);
|
|
|
+ if (Number.isFinite(mb) && mb > 0) {
|
|
|
+ return mb * 1024 * 1024;
|
|
|
+ }
|
|
|
+ return WEB_UPLOAD_VIDEO_MAX_BYTES;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {File[]} fileArr
|
|
|
+ * @param {string} [fileType] image | video
|
|
|
+ * @param {{ maxVideoMb?: number }} [options]
|
|
|
+ */
|
|
|
+function assertWebUploadFilesWithinLimit(fileArr, fileType, options = {}) {
|
|
|
+ const kind = fileType === "video" ? "video" : fileType === "image" ? "image" : "";
|
|
|
+ const maxVideoBytes = resolveMaxVideoBytes(options);
|
|
|
+ const maxVideoMb = Math.round(maxVideoBytes / 1024 / 1024);
|
|
|
+ const videoTip =
|
|
|
+ maxVideoMb === WEB_UPLOAD_VIDEO_MAX_MB_LARGE
|
|
|
+ ? "视频建议不超过 500MB"
|
|
|
+ : maxVideoMb === WEB_UPLOAD_VIDEO_MAX_MB_DEFAULT
|
|
|
+ ? WEB_UPLOAD_TIP_VIDEO
|
|
|
+ : `视频建议不超过 ${maxVideoMb}MB`;
|
|
|
+ for (const file of fileArr) {
|
|
|
+ const size = Number(file?.size);
|
|
|
+ if (!Number.isFinite(size) || size <= 0) continue;
|
|
|
+ const mime = String(file?.type || "").toLowerCase();
|
|
|
+ const isVideo = kind === "video" || mime.startsWith("video/");
|
|
|
+ const isImage = kind === "image" || mime.startsWith("image/");
|
|
|
+ if (isVideo && size > maxVideoBytes) {
|
|
|
+ throw new Error(videoTip);
|
|
|
+ }
|
|
|
+ if (isImage && size > WEB_UPLOAD_IMAGE_MAX_BYTES) {
|
|
|
+ throw new Error(WEB_UPLOAD_TIP_IMAGE);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* 已由 uploadFilesToOss 弹出过 ElMessage,调用方勿重复 error
|
|
|
* @param {unknown} err
|
|
|
@@ -300,13 +453,31 @@ function mapSimpleModerationReasonToTip(raw) {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 解析 /upload/simple 审核未通过等响应(saved: false + moderation)
|
|
|
+ * 审核结果可能在根节点或 data 内(HTTP 200 + code 200 时常见)
|
|
|
+ * @param {unknown} parsed
|
|
|
+ * @returns {Record<string, unknown> | null}
|
|
|
+ */
|
|
|
+function unwrapModerationPayload(parsed) {
|
|
|
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
|
+ const root = /** @type {Record<string, unknown>} */ (parsed);
|
|
|
+ const data = root.data;
|
|
|
+ if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
|
+ const inner = /** @type {Record<string, unknown>} */ (data);
|
|
|
+ if (inner.saved !== undefined || inner.moderation || inner.upload_id) {
|
|
|
+ return inner;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return root;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 解析 /upload/simple、/upload/oss/finalize 审核未通过(saved: false + moderation)
|
|
|
* @param {unknown} parsed
|
|
|
* @returns {string} 非空则可直接作为 ElMessage / Error 文案
|
|
|
*/
|
|
|
function formatSimpleUploadModerationMessage(parsed) {
|
|
|
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
|
|
|
- const p = /** @type {Record<string, unknown>} */ (parsed);
|
|
|
+ const p = unwrapModerationPayload(parsed);
|
|
|
+ if (!p) return "";
|
|
|
const notSaved = p.saved === false;
|
|
|
const mod = p.moderation;
|
|
|
const hasModeration = mod != null && typeof mod === "object";
|
|
|
@@ -380,15 +551,528 @@ function assertSimpleUploadBusinessOk(parsed) {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
+ * @param {string} region
|
|
|
+ * @returns {string}
|
|
|
+ */
|
|
|
+function normalizeOssRegion(region) {
|
|
|
+ const r = String(region ?? "").trim();
|
|
|
+ if (!r) return "oss-cn-beijing";
|
|
|
+ if (r.startsWith("oss-")) return r;
|
|
|
+ return `oss-${r}`;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {string} stsEndpoint 接口返回的 endpoint
|
|
|
+ * @returns {string} 不含协议的 host,如 oss-cn-beijing.aliyuncs.com
|
|
|
+ */
|
|
|
+function resolveOssUploadEndpoint(stsEndpoint) {
|
|
|
+ const override = String(import.meta.env.VITE_OSS_UPLOAD_ENDPOINT || "").trim();
|
|
|
+ const raw = (override || stsEndpoint || DEFAULT_OSS_UPLOAD_ENDPOINT).trim();
|
|
|
+ return raw.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {unknown} body
|
|
|
+ */
|
|
|
+function parseOssStsPayload(body) {
|
|
|
+ if (!body || typeof body !== "object") {
|
|
|
+ throw new Error("STS 响应无效");
|
|
|
+ }
|
|
|
+ const root = /** @type {Record<string, unknown>} */ (body);
|
|
|
+ if (root.code !== undefined && root.code !== null) {
|
|
|
+ const ok = root.code === 200 || root.code === 0 || root.code === "200" || root.code === "0";
|
|
|
+ if (!ok) {
|
|
|
+ throw new Error(String(root.msg ?? root.message ?? "获取 OSS 签名失败"));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const raw =
|
|
|
+ root.credentials && typeof root.credentials === "object"
|
|
|
+ ? root
|
|
|
+ : root.data && typeof root.data === "object"
|
|
|
+ ? /** @type {Record<string, unknown>} */ (root.data)
|
|
|
+ : root;
|
|
|
+ const creds = /** @type {Record<string, unknown>} */ (raw).credentials;
|
|
|
+ if (!creds || typeof creds !== "object") {
|
|
|
+ throw new Error("STS 凭证不完整");
|
|
|
+ }
|
|
|
+ const c = /** @type {Record<string, unknown>} */ (creds);
|
|
|
+ const accessKeyId = String(c.AccessKeyId ?? c.accessKeyId ?? "").trim();
|
|
|
+ const accessKeySecret = String(c.AccessKeySecret ?? c.accessKeySecret ?? "").trim();
|
|
|
+ const securityToken = String(c.SecurityToken ?? c.securityToken ?? c.stsToken ?? "").trim();
|
|
|
+ if (!accessKeyId || !accessKeySecret || !securityToken) {
|
|
|
+ throw new Error("STS 凭证不完整");
|
|
|
+ }
|
|
|
+ const bucket = String(/** @type {Record<string, unknown>} */ (raw).bucket ?? "").trim();
|
|
|
+ if (!bucket) {
|
|
|
+ throw new Error("STS 响应缺少 bucket");
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ region: String(/** @type {Record<string, unknown>} */ (raw).region ?? "cn-beijing"),
|
|
|
+ endpoint: String(/** @type {Record<string, unknown>} */ (raw).endpoint ?? DEFAULT_OSS_UPLOAD_ENDPOINT),
|
|
|
+ bucket,
|
|
|
+ keyPrefix: String(
|
|
|
+ /** @type {Record<string, unknown>} */ (raw).key_prefix ??
|
|
|
+ /** @type {Record<string, unknown>} */ (raw).keyPrefix ??
|
|
|
+ "uploads/"
|
|
|
+ ),
|
|
|
+ credentials: { accessKeyId, accessKeySecret, securityToken }
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /upload/oss/sts-token
|
|
|
+ * @param {{ signal?: AbortSignal }} [fetchOptions]
|
|
|
+ */
|
|
|
+async function fetchOssStsToken(fetchOptions = {}) {
|
|
|
+ const base = String(BASE_AI_URL || "").replace(/\/$/, "");
|
|
|
+ if (!base) {
|
|
|
+ throw new Error("未配置上传服务地址(VITE_AI_UPLOAD_BASE 或默认 /ai-upload)");
|
|
|
+ }
|
|
|
+ const userStore = useUserStore();
|
|
|
+ const headers = { Accept: "application/json" };
|
|
|
+ const token = userStore.token || "";
|
|
|
+ if (token) {
|
|
|
+ headers.Authorization = token;
|
|
|
+ }
|
|
|
+ const url = `${base}${OSS_STS_TOKEN_PATH}`;
|
|
|
+ let res;
|
|
|
+ try {
|
|
|
+ res = await fetch(url, {
|
|
|
+ method: "GET",
|
|
|
+ headers,
|
|
|
+ credentials: "omit",
|
|
|
+ signal: createUploadFetchSignal(fetchOptions.signal, OSS_STS_FETCH_TIMEOUT_MS)
|
|
|
+ });
|
|
|
+ } catch (err) {
|
|
|
+ if (isUploadUserCancelledError(err)) {
|
|
|
+ throw err instanceof Error ? err : new Error(String(err ?? "AbortError"));
|
|
|
+ }
|
|
|
+ if (isUploadTimeoutError(err)) {
|
|
|
+ throw new Error("获取上传凭证超时,请检查网络后重试");
|
|
|
+ }
|
|
|
+ console.error("[oss/sts-token] 请求失败:", url, err);
|
|
|
+ throw new Error("获取 OSS 签名失败");
|
|
|
+ }
|
|
|
+ const rawText = await res.text();
|
|
|
+ let parsed = null;
|
|
|
+ const trimmed = rawText.trim();
|
|
|
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
|
+ try {
|
|
|
+ parsed = JSON.parse(rawText);
|
|
|
+ } catch (_) {
|
|
|
+ /* ignore */
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!res.ok) {
|
|
|
+ let msg = "获取 OSS 签名失败";
|
|
|
+ if (parsed && typeof parsed === "object") {
|
|
|
+ const p = /** @type {Record<string, unknown>} */ (parsed);
|
|
|
+ const m = p.msg ?? p.message ?? p.error;
|
|
|
+ if (m != null && String(m)) msg = String(m);
|
|
|
+ }
|
|
|
+ throw new Error(msg);
|
|
|
+ }
|
|
|
+ return parseOssStsPayload(parsed ?? {});
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {string} prefix
|
|
|
+ * @param {File} file
|
|
|
+ */
|
|
|
+function buildOssObjectKey(prefix, file) {
|
|
|
+ const dir = String(prefix || "uploads/").replace(/^\/+/, "");
|
|
|
+ const normalizedDir = dir.endsWith("/") ? dir : `${dir}/`;
|
|
|
+ const fileKind = String(file?.type || "").startsWith("video/") ? "video" : "image";
|
|
|
+ const ext = getFileExtension(file.name, fileKind);
|
|
|
+ const safeName = (file.name || "file").replace(/[/\\?%*:|"<>]/g, "_").replace(/\s+/g, "_");
|
|
|
+ const base = safeName.includes(".") ? safeName.slice(0, safeName.lastIndexOf(".")) : safeName;
|
|
|
+ return `${normalizedDir}${Date.now()}_${Math.random().toString(36).slice(2, 10)}_${base || "file"}.${ext}`;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {string} bucket
|
|
|
+ * @param {string} endpointHost
|
|
|
+ * @param {string} objectKey
|
|
|
+ */
|
|
|
+function buildOssObjectPublicUrl(bucket, endpointHost, objectKey) {
|
|
|
+ const key = String(objectKey || "").replace(/^\/+/, "");
|
|
|
+ const host = resolveOssUploadEndpoint(endpointHost);
|
|
|
+ const encodedKey = key
|
|
|
+ .split("/")
|
|
|
+ .map(seg => encodeURIComponent(seg))
|
|
|
+ .join("/");
|
|
|
+ return `https://${bucket}.${host}/${encodedKey}`;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * STS 签名后直传 OSS(官方相册)
|
|
|
+ * @param {File} file
|
|
|
+ * @param {{ signal?: AbortSignal }} [fetchOptions]
|
|
|
+ * @returns {Promise<{ fileUrl: string; downloadUrl: string }>}
|
|
|
+ */
|
|
|
+function reportOssUploadProgress(progressRatio, fetchOptions = {}) {
|
|
|
+ const p = Number(progressRatio);
|
|
|
+ if (!Number.isFinite(p)) return;
|
|
|
+ const overlay = useSimpleUploadOverlayStore();
|
|
|
+ if (!overlay.show) return;
|
|
|
+ const batch = fetchOptions.batchProgress;
|
|
|
+ if (batch && typeof batch.index === "number" && typeof batch.total === "number") {
|
|
|
+ overlay.setMultiFileUploadProgress(batch.index, batch.total, p);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ overlay.setUploadProgress(p, { skipAudit: Boolean(fetchOptions.skipAuditProgress) });
|
|
|
+}
|
|
|
+
|
|
|
+async function putFileToOssWithSts(file, fetchOptions = {}) {
|
|
|
+ const sts = await fetchOssStsToken(fetchOptions);
|
|
|
+ const endpoint = resolveOssUploadEndpoint(sts.endpoint);
|
|
|
+ const objectKey = buildOssObjectKey(sts.keyPrefix, file);
|
|
|
+ const putTimeoutMs = resolveOssPutTimeoutMs(file);
|
|
|
+ const client = new OSS({
|
|
|
+ region: normalizeOssRegion(sts.region),
|
|
|
+ accessKeyId: sts.credentials.accessKeyId,
|
|
|
+ accessKeySecret: sts.credentials.accessKeySecret,
|
|
|
+ stsToken: sts.credentials.securityToken,
|
|
|
+ bucket: sts.bucket,
|
|
|
+ endpoint,
|
|
|
+ secure: true,
|
|
|
+ timeout: putTimeoutMs
|
|
|
+ });
|
|
|
+ let result;
|
|
|
+ try {
|
|
|
+ result = await client.put(objectKey, file, {
|
|
|
+ mime: file.type || undefined,
|
|
|
+ timeout: putTimeoutMs,
|
|
|
+ progress: p => {
|
|
|
+ reportOssUploadProgress(p, fetchOptions);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ } catch (err) {
|
|
|
+ if (isUploadUserCancelledError(err)) {
|
|
|
+ throw err instanceof Error ? err : new Error(String(err ?? "AbortError"));
|
|
|
+ }
|
|
|
+ if (isUploadTimeoutError(err)) {
|
|
|
+ throw new Error("视频上传超时,请保持网络稳定后重试");
|
|
|
+ }
|
|
|
+ throw err;
|
|
|
+ }
|
|
|
+ const fileUrl =
|
|
|
+ (typeof result.url === "string" && result.url.trim()) || buildOssObjectPublicUrl(sts.bucket, endpoint, objectKey);
|
|
|
+ return { fileUrl, downloadUrl: fileUrl, objectKey };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {File} file
|
|
|
+ * @returns {string}
|
|
|
+ */
|
|
|
+function resolveFileContentType(file) {
|
|
|
+ const t = String(file?.type ?? "").trim();
|
|
|
+ if (t) return t;
|
|
|
+ const ext = getFileExtension(file?.name, "image");
|
|
|
+ const videoMap = { mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", ogg: "video/ogg" };
|
|
|
+ if (videoMap[ext]) return videoMap[ext];
|
|
|
+ const imageMap = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp" };
|
|
|
+ return imageMap[ext] || "image/jpeg";
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * POST /upload/oss/finalize(与 fetchOssStsToken 同基址、同鉴权方式)
|
|
|
+ * @param {File} file
|
|
|
+ * @param {string} objectKey
|
|
|
+ * @param {{ signal?: AbortSignal; ocrType?: string }} [fetchOptions]
|
|
|
+ * @returns {Promise<{ downloadUrl?: string; parsed?: unknown }>}
|
|
|
+ */
|
|
|
+async function requestOssFinalizeAudit(file, objectKey, fetchOptions = {}) {
|
|
|
+ const base = String(BASE_AI_URL || "").replace(/\/$/, "");
|
|
|
+ if (!base) {
|
|
|
+ throw new Error("未配置上传服务地址(VITE_AI_UPLOAD_BASE 或默认 /ai-upload)");
|
|
|
+ }
|
|
|
+ const userStore = useUserStore();
|
|
|
+ const headers = {
|
|
|
+ Accept: "application/json",
|
|
|
+ "Content-Type": "application/json"
|
|
|
+ };
|
|
|
+ const token = userStore.token || "";
|
|
|
+ if (token) {
|
|
|
+ headers.Authorization = token;
|
|
|
+ }
|
|
|
+ const url = `${base}${OSS_FINALIZE_PATH}`;
|
|
|
+ const ocrType = String(fetchOptions.ocrType ?? "").trim();
|
|
|
+ const body = {
|
|
|
+ object_key: String(objectKey ?? "").trim(),
|
|
|
+ filename: String(file?.name ?? "file").trim() || "file",
|
|
|
+ content_type: resolveFileContentType(file),
|
|
|
+ size: Number(file?.size) || 0
|
|
|
+ };
|
|
|
+ if (ocrType) {
|
|
|
+ body.ocr_type = ocrType;
|
|
|
+ body.ocrType = ocrType;
|
|
|
+ }
|
|
|
+ let res;
|
|
|
+ try {
|
|
|
+ res = await fetch(url, {
|
|
|
+ method: "POST",
|
|
|
+ headers,
|
|
|
+ credentials: "omit",
|
|
|
+ body: JSON.stringify(body),
|
|
|
+ signal: createUploadFetchSignal(fetchOptions.signal, resolveOssFinalizeFetchTimeoutMs(file))
|
|
|
+ });
|
|
|
+ } catch (err) {
|
|
|
+ if (isUploadUserCancelledError(err)) {
|
|
|
+ throw err instanceof Error ? err : new Error(String(err ?? "AbortError"));
|
|
|
+ }
|
|
|
+ if (isUploadTimeoutError(err)) {
|
|
|
+ throw new Error("内容审核超时,请稍后重试");
|
|
|
+ }
|
|
|
+ console.error("[oss/finalize] 请求失败:", url, err);
|
|
|
+ throw new Error("内容审核请求失败");
|
|
|
+ }
|
|
|
+
|
|
|
+ const rawText = await res.text();
|
|
|
+ let parsed = null;
|
|
|
+ const trimmed = rawText.trim();
|
|
|
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
|
+ try {
|
|
|
+ parsed = JSON.parse(rawText);
|
|
|
+ } catch (_) {
|
|
|
+ /* ignore */
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!res.ok) {
|
|
|
+ const modHint = formatSimpleUploadModerationMessage(parsed);
|
|
|
+ let msg = modHint;
|
|
|
+ if (!msg && parsed && typeof parsed === "object") {
|
|
|
+ const p = /** @type {Record<string, unknown>} */ (parsed);
|
|
|
+ const m = p.msg ?? p.message ?? p.error;
|
|
|
+ if (m != null && String(m)) msg = String(m);
|
|
|
+ }
|
|
|
+ if (!msg && trimmed) msg = trimmed.slice(0, 200);
|
|
|
+ if (!msg) msg = "内容审核请求失败";
|
|
|
+ throw new Error(msg);
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ assertSimpleUploadBusinessOk(parsed);
|
|
|
+ } catch (bizErr) {
|
|
|
+ console.error("[oss/finalize] 业务失败", bizErr, trimmed.slice(0, 500));
|
|
|
+ throw bizErr;
|
|
|
+ }
|
|
|
+
|
|
|
+ const moderationUserTip = formatSimpleUploadModerationMessage(parsed);
|
|
|
+ if (moderationUserTip) {
|
|
|
+ throw new Error(moderationUserTip);
|
|
|
+ }
|
|
|
+
|
|
|
+ const downloadUrl = (preferDownloadUrlFromBody(parsed) || "").trim();
|
|
|
+ return { ...(downloadUrl ? { downloadUrl } : {}), parsed };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 商家进件证照:STS 直传 OSS 后 POST /upload/oss/finalize(内容审核 + OCR)
|
|
|
+ * @param {File} file
|
|
|
+ * @param {string} ocrType 如 BUSINESS_LICENSE、ID_CARD
|
|
|
+ * @param {{ signal?: AbortSignal }} [fetchOptions]
|
|
|
+ * @returns {Promise<{ fileUrl: string; downloadUrl: string; parsed: unknown }>}
|
|
|
+ */
|
|
|
+export async function stsUploadAndFinalizeOcr(file, ocrType, fetchOptions = {}) {
|
|
|
+ const uploaded = await putFileToOssWithSts(file, fetchOptions);
|
|
|
+ const audit = await requestOssFinalizeAudit(file, uploaded.objectKey, {
|
|
|
+ ...fetchOptions,
|
|
|
+ ocrType: String(ocrType ?? "").trim()
|
|
|
+ });
|
|
|
+ const downloadUrl = (audit.downloadUrl || uploaded.downloadUrl || uploaded.fileUrl || "").trim();
|
|
|
+ const fileUrl = downloadUrl || uploaded.fileUrl;
|
|
|
+ if (!fileUrl) {
|
|
|
+ throw new Error("上传失败,未返回地址");
|
|
|
+ }
|
|
|
+ return { fileUrl, downloadUrl: downloadUrl || fileUrl, parsed: audit.parsed ?? null };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * STS 直传 OSS(不 finalize),可选上传弹层
|
|
|
+ * @param {File} file
|
|
|
+ * @param {{ skipSimpleUploadOverlay?: boolean; uploadSuccessMessage?: string | null; uploadOverlayTitle?: string }} [options]
|
|
|
+ * @returns {Promise<{ fileUrl: string; downloadUrl: string; objectKey: string }>}
|
|
|
+ */
|
|
|
+export async function uploadFileToOssStsOnlyWithMeta(file, options = {}) {
|
|
|
+ return uploadSingleFileWithOssOverlay(file, {
|
|
|
+ skipFinalize: true,
|
|
|
+ uploadSuccessMessage: options.uploadSuccessMessage ?? null,
|
|
|
+ ...options
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * OSS 直传完成后 POST finalize(内容审核 + 可选 OCR)
|
|
|
+ * @param {File} file
|
|
|
+ * @param {string} objectKey
|
|
|
+ * @param {string} [ocrType]
|
|
|
+ * @param {{ signal?: AbortSignal }} [fetchOptions]
|
|
|
+ */
|
|
|
+export async function requestOssFinalizeWithOcr(file, objectKey, ocrType, fetchOptions = {}) {
|
|
|
+ return requestOssFinalizeAudit(file, objectKey, {
|
|
|
+ ...fetchOptions,
|
|
|
+ ocrType: String(ocrType ?? "").trim()
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * STS 直传 OSS 后 POST finalize 审核(单文件核心,无弹层)
|
|
|
+ * @param {File} file
|
|
|
+ * @param {{ signal?: AbortSignal }} [fetchOptions]
|
|
|
+ * @returns {Promise<{ fileUrl: string; downloadUrl: string }>}
|
|
|
+ */
|
|
|
+async function putFileToOssWithStsAndFinalize(file, fetchOptions = {}) {
|
|
|
+ const overlay = useSimpleUploadOverlayStore();
|
|
|
+ const trackOverlay = overlay.show;
|
|
|
+ const batch = fetchOptions.batchProgress;
|
|
|
+
|
|
|
+ const uploaded = await putFileToOssWithSts(file, fetchOptions);
|
|
|
+ if (trackOverlay) {
|
|
|
+ if (batch && typeof batch.index === "number" && typeof batch.total === "number") {
|
|
|
+ overlay.setMultiFileUploadProgress(batch.index, batch.total, 1);
|
|
|
+ overlay.beginAuditPhaseForBatch(batch.index, batch.total);
|
|
|
+ } else {
|
|
|
+ overlay.setUploadProgress(1);
|
|
|
+ overlay.beginAuditPhase();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const audit = await requestOssFinalizeAudit(file, uploaded.objectKey, fetchOptions);
|
|
|
+
|
|
|
+ if (trackOverlay) {
|
|
|
+ if (batch && typeof batch.index === "number" && typeof batch.total === "number") {
|
|
|
+ overlay.setMultiFileAuditProgress(batch.index, batch.total, 1);
|
|
|
+ if (batch.index === batch.total - 1) {
|
|
|
+ overlay.completeSuccess();
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ overlay.completeSuccess();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const downloadUrl = audit.downloadUrl?.trim() || uploaded.downloadUrl;
|
|
|
+ return { fileUrl: uploaded.fileUrl, downloadUrl };
|
|
|
+}
|
|
|
+
|
|
|
+/** 仅 STS 直传 OSS,不走 /upload/oss/finalize(证照等由页面单独 OCR 审核) */
|
|
|
+async function putFileToOssWithStsOnly(file, fetchOptions = {}) {
|
|
|
+ const overlay = useSimpleUploadOverlayStore();
|
|
|
+ const trackOverlay = overlay.show;
|
|
|
+ const uploaded = await putFileToOssWithSts(file, {
|
|
|
+ ...fetchOptions,
|
|
|
+ skipAuditProgress: true
|
|
|
+ });
|
|
|
+ if (trackOverlay) {
|
|
|
+ const batch = fetchOptions.batchProgress;
|
|
|
+ if (batch && typeof batch.index === "number" && typeof batch.total === "number") {
|
|
|
+ overlay.setMultiFileUploadProgress(batch.index, batch.total, 1);
|
|
|
+ if (batch.index === batch.total - 1) {
|
|
|
+ overlay.setUploadProgress(1, { skipAudit: true });
|
|
|
+ overlay.completeSuccess();
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ overlay.setUploadProgress(1, { skipAudit: true });
|
|
|
+ overlay.completeSuccess();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ fileUrl: uploaded.fileUrl,
|
|
|
+ downloadUrl: uploaded.downloadUrl,
|
|
|
+ objectKey: uploaded.objectKey
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 单文件 OSS + 审核,带可选全局上传弹层
|
|
|
+ * @param {File} file
|
|
|
+ * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean; skipFinalize?: boolean; uploadSuccessMessage?: string | null; uploadOverlayTitle?: string }} [options]
|
|
|
+ * @returns {Promise<{ fileUrl: string; downloadUrl: string; objectKey?: string }>}
|
|
|
+ */
|
|
|
+async function uploadSingleFileWithOssOverlay(file, options = {}) {
|
|
|
+ const {
|
|
|
+ showLoading = false,
|
|
|
+ skipSimpleUploadOverlay = false,
|
|
|
+ skipFinalize = false,
|
|
|
+ uploadSuccessMessage,
|
|
|
+ uploadOverlayTitle
|
|
|
+ } = options;
|
|
|
+ if (!(file instanceof File)) {
|
|
|
+ throw new Error("请选择要上传的文件");
|
|
|
+ }
|
|
|
+
|
|
|
+ let closeLoading = () => {};
|
|
|
+ if (showLoading && skipSimpleUploadOverlay) {
|
|
|
+ const loading = ElMessage({ message: "上传中...", type: "info", duration: 0, showClose: false });
|
|
|
+ closeLoading = () => loading.close();
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const runUpload = async signal => {
|
|
|
+ const fetchOpts = signal ? { signal } : {};
|
|
|
+ return skipFinalize ? putFileToOssWithStsOnly(file, fetchOpts) : putFileToOssWithStsAndFinalize(file, fetchOpts);
|
|
|
+ };
|
|
|
+
|
|
|
+ let result;
|
|
|
+ if (skipSimpleUploadOverlay) {
|
|
|
+ const overlaySignal = useSimpleUploadOverlayStore().getActiveAbortSignal?.();
|
|
|
+ result = await runUpload(overlaySignal ?? null);
|
|
|
+ } else {
|
|
|
+ const overlayOpts =
|
|
|
+ uploadSuccessMessage !== undefined || uploadOverlayTitle
|
|
|
+ ? {
|
|
|
+ title: uploadOverlayTitle,
|
|
|
+ successMessage: uploadSuccessMessage
|
|
|
+ }
|
|
|
+ : undefined;
|
|
|
+ result = await withSimpleUploadOverlay(signal => runUpload(signal), overlayOpts);
|
|
|
+ }
|
|
|
+
|
|
|
+ closeLoading();
|
|
|
+ return result;
|
|
|
+ } catch (e) {
|
|
|
+ closeLoading();
|
|
|
+ console.error("OSS 直传或审核失败", e);
|
|
|
+ if (isUploadUserCancelledError(e)) {
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ const msg = (isUploadTimeoutError(e) ? "上传超时,请检查网络后重试" : "") || e?.message || "上传失败";
|
|
|
+ ElMessage.error(msg);
|
|
|
+ try {
|
|
|
+ if (e && typeof e === "object") {
|
|
|
+ Object.defineProperty(e, "__uploadMessageShown", { value: true, enumerable: false, configurable: true });
|
|
|
+ }
|
|
|
+ } catch (_) {
|
|
|
+ /* ignore */
|
|
|
+ }
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET sts-token → OSS 直传 → POST finalize 审核
|
|
|
+ * @param {File} file
|
|
|
+ * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean; uploadSuccessMessage?: string | null; uploadOverlayTitle?: string }} [options]
|
|
|
+ * @returns {Promise<{ fileUrl: string; downloadUrl: string }>}
|
|
|
+ */
|
|
|
+export async function uploadFileToOssStsWithDownloadMeta(file, options = {}) {
|
|
|
+ return uploadSingleFileWithOssOverlay(file, options);
|
|
|
+}
|
|
|
+
|
|
|
+/** 与 uploadFileToOssStsWithDownloadMeta 相同 */
|
|
|
+export const uploadFileToOssWithDownloadMeta = uploadFileToOssStsWithDownloadMeta;
|
|
|
+
|
|
|
+/**
|
|
|
* POST multipart:字段 file;解析展示用地址与业务保存用的 download_url
|
|
|
* @param {File} file
|
|
|
* @param {{ signal?: AbortSignal }} [fetchOptions]
|
|
|
* @returns {Promise<{ url: string; downloadUrl: string; parsed: unknown }>}
|
|
|
*/
|
|
|
async function postFileToSimpleUploadDetailed(file, fetchOptions = {}) {
|
|
|
- const uploadUrl = String(SIMPLE_UPLOAD_URL || "").trim();
|
|
|
- if (!uploadUrl) {
|
|
|
- throw new Error("简单上传服务地址未配置");
|
|
|
+ const base = String(BASE_AI_URL || "").replace(/\/$/, "");
|
|
|
+ if (!base) {
|
|
|
+ throw new Error("未配置上传服务地址(VITE_AI_UPLOAD_BASE 或默认 /ai-upload)");
|
|
|
}
|
|
|
|
|
|
const formData = new FormData();
|
|
|
@@ -403,6 +1087,7 @@ async function postFileToSimpleUploadDetailed(file, fetchOptions = {}) {
|
|
|
|
|
|
const { signal } = fetchOptions;
|
|
|
|
|
|
+ const uploadUrl = `${base}${SIMPLE_UPLOAD_PATH}`;
|
|
|
let res;
|
|
|
try {
|
|
|
res = await fetch(uploadUrl, {
|
|
|
@@ -500,18 +1185,21 @@ async function postFileToSimpleUpload(file, fetchOptions = {}) {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 上传文件:图片与视频均走同一接口 POST /upload/simple,formData 键 file。
|
|
|
+ * 上传文件:GET sts-token → OSS 直传 → POST finalize 审核
|
|
|
* @param {File | File[] | FileList} files 浏览器文件对象;支持单个 File、数组或 FileList
|
|
|
* @param {string} [_fileType] 保留参数,兼容旧调用(当前不参与分支)
|
|
|
- * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean; uploadSuccessMessage?: string | null; uploadOverlayTitle?: string }} [options]
|
|
|
- * showLoading:在未使用全局上传弹层时,用 ElMessage 提示上传中
|
|
|
- * skipSimpleUploadOverlay:为 true 时不展示 PopupLoading(不弹「上传成功」)
|
|
|
- * uploadSuccessMessage:传给弹层,`null` 表示上传成功不 toast(默认「上传成功」)
|
|
|
- * uploadOverlayTitle:弹层标题
|
|
|
+ * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean; skipFinalize?: boolean; maxVideoMb?: number; uploadSuccessMessage?: string | null; uploadOverlayTitle?: string }} [options]
|
|
|
* @returns {Promise<string[]>} 上传成功后的文件 URL 列表
|
|
|
*/
|
|
|
export async function uploadFilesToOss(files, _fileType, options = {}) {
|
|
|
- const { showLoading = false, skipSimpleUploadOverlay = false, uploadSuccessMessage, uploadOverlayTitle } = options;
|
|
|
+ const {
|
|
|
+ showLoading = false,
|
|
|
+ skipSimpleUploadOverlay = false,
|
|
|
+ skipFinalize = false,
|
|
|
+ maxVideoMb,
|
|
|
+ uploadSuccessMessage,
|
|
|
+ uploadOverlayTitle
|
|
|
+ } = options;
|
|
|
const fileArr = normalizeFiles(files);
|
|
|
if (fileArr.length === 0) {
|
|
|
throw new Error("请选择要上传的文件");
|
|
|
@@ -524,11 +1212,24 @@ export async function uploadFilesToOss(files, _fileType, options = {}) {
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
+ assertWebUploadFilesWithinLimit(fileArr, _fileType, { maxVideoMb });
|
|
|
const runUpload = async signal => {
|
|
|
const uploadedUrls = [];
|
|
|
- for (const file of fileArr) {
|
|
|
- const url = await postFileToSimpleUpload(file, signal ? { signal } : {});
|
|
|
- uploadedUrls.push(url);
|
|
|
+ const total = fileArr.length;
|
|
|
+ for (let i = 0; i < total; i++) {
|
|
|
+ const file = fileArr[i];
|
|
|
+ const fetchOpts = {
|
|
|
+ ...(signal ? { signal } : {}),
|
|
|
+ batchProgress: total > 1 ? { index: i, total } : undefined
|
|
|
+ };
|
|
|
+ const { fileUrl, downloadUrl } = skipFinalize
|
|
|
+ ? await putFileToOssWithStsOnly(file, fetchOpts)
|
|
|
+ : await putFileToOssWithStsAndFinalize(file, fetchOpts);
|
|
|
+ uploadedUrls.push(downloadUrl || fileUrl);
|
|
|
+ }
|
|
|
+ const overlay = useSimpleUploadOverlayStore();
|
|
|
+ if (overlay.show && skipFinalize) {
|
|
|
+ overlay.completeSuccess();
|
|
|
}
|
|
|
return uploadedUrls;
|
|
|
};
|
|
|
@@ -556,67 +1257,7 @@ export async function uploadFilesToOss(files, _fileType, options = {}) {
|
|
|
if (isUploadUserCancelledError(e)) {
|
|
|
throw e;
|
|
|
}
|
|
|
- const msg = e?.message || "上传失败";
|
|
|
- ElMessage.error(msg);
|
|
|
- try {
|
|
|
- if (e && typeof e === "object") {
|
|
|
- Object.defineProperty(e, "__uploadMessageShown", { value: true, enumerable: false, configurable: true });
|
|
|
- }
|
|
|
- } catch (_) {
|
|
|
- /* ignore */
|
|
|
- }
|
|
|
- throw e;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * 单文件上传:返回列表展示用地址与业务保存用的 `download_url`(响应中无则为空字符串)
|
|
|
- * @param {File} file
|
|
|
- * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean; uploadSuccessMessage?: string | null; uploadOverlayTitle?: string }} [options]
|
|
|
- * @returns {Promise<{ fileUrl: string; downloadUrl: string }>}
|
|
|
- */
|
|
|
-export async function uploadFileToOssWithDownloadMeta(file, options = {}) {
|
|
|
- const { showLoading = false, skipSimpleUploadOverlay = false, uploadSuccessMessage, uploadOverlayTitle } = options;
|
|
|
- if (!(file instanceof File)) {
|
|
|
- throw new Error("请选择要上传的文件");
|
|
|
- }
|
|
|
-
|
|
|
- let closeLoading = () => {};
|
|
|
- if (showLoading && skipSimpleUploadOverlay) {
|
|
|
- const loading = ElMessage({ message: "上传中...", type: "info", duration: 0, showClose: false });
|
|
|
- closeLoading = () => loading.close();
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- const runUpload = async signal => {
|
|
|
- const { url, downloadUrl } = await postFileToSimpleUploadDetailed(file, signal ? { signal } : {});
|
|
|
- return { fileUrl: url, downloadUrl };
|
|
|
- };
|
|
|
-
|
|
|
- let result;
|
|
|
- if (skipSimpleUploadOverlay) {
|
|
|
- const overlaySignal = useSimpleUploadOverlayStore().getActiveAbortSignal?.();
|
|
|
- result = await runUpload(overlaySignal ?? null);
|
|
|
- } else {
|
|
|
- const overlayOpts =
|
|
|
- uploadSuccessMessage !== undefined || uploadOverlayTitle
|
|
|
- ? {
|
|
|
- title: uploadOverlayTitle,
|
|
|
- successMessage: uploadSuccessMessage
|
|
|
- }
|
|
|
- : undefined;
|
|
|
- result = await withSimpleUploadOverlay(signal => runUpload(signal), overlayOpts);
|
|
|
- }
|
|
|
-
|
|
|
- closeLoading();
|
|
|
- return result;
|
|
|
- } catch (e) {
|
|
|
- closeLoading();
|
|
|
- console.error("上传失败", e);
|
|
|
- if (isUploadUserCancelledError(e)) {
|
|
|
- throw e;
|
|
|
- }
|
|
|
- const msg = e?.message || "上传失败";
|
|
|
+ const msg = (isUploadTimeoutError(e) ? "上传超时,请检查网络后重试" : "") || e?.message || "上传失败";
|
|
|
ElMessage.error(msg);
|
|
|
try {
|
|
|
if (e && typeof e === "object") {
|
|
|
@@ -664,7 +1305,7 @@ export async function uploadFormDataToOss(formData, fileType, options = {}) {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * FormData 走 `/upload/simple` 后,包装成旧组件习惯的 `{ code, msg, data: { fileUrl }, fileUrl }`(并非请求 `/file/uploadMore`)
|
|
|
+ * FormData OSS 上传后,包装成旧组件习惯的 `{ code, msg, data: { fileUrl }, fileUrl }`
|
|
|
* @param {FormData} formData
|
|
|
* @param {{ showLoading?: boolean }} [options]
|
|
|
* @returns {Promise<{ code: number; msg: string; data: { fileUrl: string }; fileUrl: string }>}
|