Ver código fonte

Merge branch 'development' into uat

# Conflicts:
#	src/api/modules/aiImageUpload.ts
#	src/api/upload.js
#	src/typings/global.d.ts
#	src/utils/config.ts
dujian 2 semanas atrás
pai
commit
214859c7c0

+ 3 - 0
build/proxy.ts

@@ -22,6 +22,9 @@ export function createProxy(list: ProxyList = []) {
       changeOrigin: true,
       ws: true,
       rewrite: path => path.replace(new RegExp(`^${prefix}`), ""),
+      /** 大视频上传/审核经代理时避免过早断开(10 分钟) */
+      timeout: 600000,
+      proxyTimeout: 600000,
       // https is require secure=false
       ...(isHttps ? { secure: false } : {})
     };

Diferenças do arquivo suprimidas por serem muito extensas
+ 366 - 61
package-lock.json


+ 1 - 0
package.json

@@ -55,6 +55,7 @@
     "@vueuse/core": "^10.11.0",
     "@wangeditor/editor": "^5.1.23",
     "@wangeditor/editor-for-vue": "^5.1.12",
+    "ali-oss": "^6.23.0",
     "axios": "^1.7.2",
     "crypto-js": "^4.2.0",
     "dayjs": "^1.11.11",

Diferenças do arquivo suprimidas por serem muito extensas
+ 162 - 139
pnpm-lock.yaml


+ 1 - 1
src/api/helper/checkStatus.ts

@@ -11,7 +11,7 @@ export const checkStatus = (status: number) => {
       ElMessage.error("请求失败!请您稍后重试");
       break;
     case 401:
-      ElMessage.error("登录失效!请您重新登录");
+      ElMessage.error("账号已在别处登录,若非本人操作,请立即修改密码。");
       break;
     case 403:
       ElMessage.error("当前账号无权限访问!");

+ 16 - 55
src/api/modules/aiImageUpload.ts

@@ -1,8 +1,9 @@
 /**
- * Web 端:Tus 分片上传(与 Apifox / uni 版协议对齐);独立审核走上传服务 /upload/simple 等,不再调用 verify 审核域
+ * Web 端:Tus 分片上传(与 Apifox / uni 版协议对齐);简单直传走 @/api/upload.js OSS STS + finalize
  */
+import { uploadFileToOssWithDownloadMeta } from "@/api/upload.js";
 import { useUserStore } from "@/stores/modules/user";
-import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_URL, SIMPLE_UPLOAD_URL } from "@/utils/config";
+import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_URL, BASE_DEV_UPLOAD_SIMPLE } from "@/utils/config";
 import { withSimpleUploadOverlay } from "@/utils/withSimpleUploadOverlay";
 
 const TUS_VERSION = "1.0.0";
@@ -117,65 +118,25 @@ function normalizeSimpleUploadUrls(body: unknown, fileUrl: string): { fileUrl: s
   return { fileUrl: url, coverUrl: coverUrl || undefined };
 }
 
+const DEV_SIMPLE_UPLOAD_PATH = "/upload/simple";
+
 function buildDevSimpleUploadRequestUrl(): string {
-  return String(SIMPLE_UPLOAD_URL || "").trim();
+  const base = String(BASE_DEV_UPLOAD_SIMPLE || "").replace(/\/$/, "");
+  if (base) {
+    return `${base}${DEV_SIMPLE_UPLOAD_PATH}`;
+  }
+  return DEV_SIMPLE_UPLOAD_PATH;
 }
 
 /**
- * 官方相册等:multipart 直传(替代 Tus `POST .../upload`)
- * POST 同源 simple 上传(与全局 upload.js 一致,Nginx 反代至 uat),表单字段 `file`
+ * 官方相册等:GET sts-token → OSS 直传 → POST finalize(替代 Tus / dev-upload simple)
  */
 export async function uploadFileViaDevSimpleEndpoint(file: File): Promise<{ fileUrl: string; coverUrl?: string }> {
-  return withSimpleUploadOverlay(async signal => {
-    const reqUrl = buildDevSimpleUploadRequestUrl();
-    const fd = new FormData();
-    fd.append("file", file, file.name);
-
-    const res = await fetch(reqUrl, {
-      method: "POST",
-      headers: {
-        Authorization: authHeader()
-      },
-      body: fd,
-      credentials: "omit",
-      signal
-    });
-
-    let body: unknown = null;
-    const ct = res.headers.get("content-type") || "";
-    if (ct.includes("application/json")) {
-      try {
-        body = await res.json();
-      } catch {
-        body = null;
-      }
-    } else {
-      const t = await res.text();
-      try {
-        body = t ? JSON.parse(t) : null;
-      } catch {
-        body = t ? { raw: t } : null;
-      }
-    }
-
-    if (res.status < 200 || res.status >= 300) {
-      const msg =
-        body && typeof body === "object" && (body as any).msg != null ? String((body as any).msg) : `上传失败(${res.status})`;
-      throw new Error(msg);
-    }
-    if (body && typeof body === "object" && (body as any).code !== undefined) {
-      const c = (body as any).code;
-      if (c !== 200 && c !== 0) {
-        throw new Error((body as any).msg || (body as any).message || "上传失败");
-      }
-    }
-
-    const rawUrl = pickFileUrlFromBody(body);
-    if (!rawUrl) {
-      throw new Error("上传完成但未返回文件地址");
-    }
-    return normalizeSimpleUploadUrls(body, rawUrl);
-  });
+  const { fileUrl } = await uploadFileToOssWithDownloadMeta(file);
+  if (!fileUrl?.trim()) {
+    throw new Error("上传完成但未返回文件地址");
+  }
+  return { fileUrl: fileUrl.trim() };
 }
 
 /** 创建上传会话 POST /upload */

+ 25 - 3
src/api/modules/businessInfo.ts

@@ -4,13 +4,32 @@ import httpLogin from "@/api/indexApi";
  * @name 商家信息
  */
 
-// 营业执照上传
+/** @deprecated 进件页已改 OSS 直传,请使用 `@/utils/businessInfoImageUpload` 或 `@/api/upload.js` */
 export const getUpload = (params: any) => {
   return httpLogin.post(`alienStore/payment/wechatPartner/v3/merchant/media/upload`, params);
 };
 
+/** OCR 由进件页自行提示,避免与页面 fail 清理重复弹两次 */
 export const getOcrRequestByBase64 = (params: any) => {
-  return httpLogin.post(`alienStore/ali/ocrRequestByBase64`, params);
+  return httpLogin.post(`alienStore/ali/ocrRequestByBase64`, params, {
+    hideBusinessErrorMessage: true,
+    loading: false,
+    encrypt: false
+  });
+};
+
+/** 审核通过后按图片 URL 做 OCR(imageUrls + ocrType) */
+export const getOcrRequestUrl = (params: {
+  imageUrls: string;
+  ocrType: string;
+  storeId?: string | number;
+  storeUserId?: string | number;
+}) => {
+  return httpLogin.post(`alienStore/ali/ocrRequestUrl`, params, {
+    hideBusinessErrorMessage: true,
+    loading: false,
+    encrypt: false
+  });
 };
 
 /** POST,storeId 走 URL 查询参数,请求体为 JSON */
@@ -32,7 +51,10 @@ export const getPaymentApplyment = (applymentId: string | number) => {
   return httpLogin.get(`alienStore/payment/wechatPartner/v3/applyment4sub/applyment/applyment_id/${id}`);
 };
 
-/** 支付宝直付通图片上传:multipart,字段 image_type + image_content(二进制) */
+/**
+ * 支付宝直付通专用图片上传(multipart image_type + image_content)
+ * @deprecated 进件表单展示/OSS 审核请用 `uploadBusinessInfoImageToOss`;提交进件仍可能依赖本接口返回的 image_id
+ */
 export interface UploadAlipayImageParams {
   /** 图片格式扩展名,3~16 字符,如 jpg、png(支持 bmp、jpg、jpeg、png、gif) */
   imageType: string;

+ 1 - 1
src/api/modules/newLoginApi.ts

@@ -62,7 +62,7 @@ export const getInputPrompt = params => {
 export const getDistrict = params => {
   return httpLogin.get(`/alienStore/gaode/getDistrict`, params);
 };
-// 文件上传(统一走 @/api/upload.js → /upload/simple
+// 文件上传(统一走 @/api/upload.js → OSS STS + finalize 审核
 export const uploadImg = (params: FormData) => uploadFormDataSimpleCompat(params);
 // 发布/更新动态(新接口)
 export const addOrUpdateDynamic = (params: {

+ 1 - 1
src/api/modules/storeDecoration.ts

@@ -63,7 +63,7 @@ export const saveOrUpdateDecoration = (params: any) => {
   return httpApi.post(`/alienStore/renovation/requirement/saveOrUpdate`, params);
 };
 
-// 上传房屋图纸(统一走 @/api/upload.js → /upload/simple
+// 上传房屋图纸(统一走 @/api/upload.js → OSS STS + finalize 审核
 export const uploadDecorationImage = (params: FormData) => uploadFormDataSimpleCompat(params);
 
 // 聊天图片/视频上传

+ 1 - 1
src/api/modules/upload.ts

@@ -1,7 +1,7 @@
 import { uploadFormDataSimpleCompat } from "@/api/upload.js";
 
 /**
- * @name 文件上传模块(图片/视频统一走 @/api/upload.js → /upload/simple
+ * @name 文件上传模块(图片/视频统一走 @/api/upload.js → OSS STS + finalize 审核
  * @param options 透传 `uploadFormDataSimpleCompat`(如 `skipSimpleUploadOverlay`),多图串行上传时可避免重复全局弹层
  */
 export const uploadImg = (params: FormData, options?: Record<string, unknown>) =>

+ 720 - 79
src/api/upload.js

@@ -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 }>}

+ 27 - 25
src/assets/json/authMenuList.json

@@ -914,7 +914,7 @@
         "icon": "Operation",
         "title": "动态",
         "isLink": "",
-        "isHide": false,
+        "isHide": true,
         "isFull": false,
         "isAffix": false,
         "isKeepAlive": false
@@ -928,7 +928,7 @@
             "icon": "Setting",
             "title": "动态广场",
             "isLink": "",
-            "isHide": false,
+            "isHide": true,
             "isFull": false,
             "isAffix": false,
             "isKeepAlive": false
@@ -956,7 +956,7 @@
             "icon": "Setting",
             "title": "我的动态",
             "isLink": "",
-            "isHide": false,
+            "isHide": true,
             "isFull": false,
             "isAffix": false,
             "isKeepAlive": false
@@ -978,19 +978,36 @@
           }
         },
         {
-          "path": "/dynamicManagement/reviewAppeal",
-          "name": "reviewAppeal",
-          "component": "/dynamicManagement/reviewAppeal",
+          "path": "/dynamicManagement/draftDynamic",
+          "name": "draftDynamic",
+          "component": "/dynamicManagement/draftDynamic",
           "meta": {
-            "icon": "ChatDotSquare",
-            "title": "评论申诉",
+            "icon": "Document",
+            "title": "好友优惠券详情",
+            "activeMenu": "/dynamicManagement/draftDynamic",
             "isLink": "",
-            "isHide": false,
+            "isHide": true,
             "isFull": false,
             "isAffix": false,
             "isKeepAlive": false
           }
-        },
+        }
+      ]
+    },
+    {
+      "path": "/dynamicManagement/reviewAppeal",
+      "name": "reviewAppeal",
+      "component": "/dynamicManagement/reviewAppeal",
+      "meta": {
+        "icon": "ChatDotSquare",
+        "title": "评论申诉",
+        "isLink": "",
+        "isHide": false,
+        "isFull": false,
+        "isAffix": false,
+        "isKeepAlive": false
+      },
+      "children": [
         {
           "path": "/dynamicManagement/reviewAppealHistory",
           "name": "reviewAppealHistory",
@@ -1020,21 +1037,6 @@
             "isAffix": false,
             "isKeepAlive": false
           }
-        },
-        {
-          "path": "/dynamicManagement/draftDynamic",
-          "name": "draftDynamic",
-          "component": "/dynamicManagement/draftDynamic",
-          "meta": {
-            "icon": "Document",
-            "title": "好友优惠券详情",
-            "activeMenu": "/dynamicManagement/draftDynamic",
-            "isLink": "",
-            "isHide": true,
-            "isFull": false,
-            "isAffix": false,
-            "isKeepAlive": false
-          }
         }
       ]
     },

+ 6 - 4
src/components/Upload/Imgs.vue

@@ -79,7 +79,7 @@ interface UploadFileProps {
   disabled?: boolean; // 是否禁用上传组件 ==> 非必传(默认为 false)
   limit?: number; // 最大图片上传数 ==> 非必传(默认为 5张)
   fileSize?: number; // 图片大小限制(MB)==> 非必传(默认 20M)
-  /** 视频大小限制(MB);含 video/* 的 fileType 时生效;默认 200;仅发布动态/门店封面页在业务里单独放宽 */
+  /** 视频大小限制(MB);含 video/* 的 fileType 时生效;默认 200 */
   videoFileSize?: number;
   fileType?: string[]; // 接受的 MIME(图片 + 可选视频)==> 非必传
   height?: string; // 组件高度 ==> 非必传(默认为 150px)
@@ -158,7 +158,7 @@ const uploadingFiles = new Set<string | number>();
 let hasShownSuccessNotification = false;
 
 /**
- * 多选时 el-upload 会对每个文件并发触发 http-request,易导致 /upload/simple 并发报错
+ * 多选时 el-upload 会对每个文件并发触发 http-request,通过 Promise 链串行化 OSS 上传
  * 通过 Promise 链串行化,同一时间只发起一次上传请求。
  */
 let sequentialUploadTail = Promise.resolve();
@@ -176,8 +176,10 @@ async function finishBatchUploadOverlay() {
   if (batchHadUploadError) {
     overlay.dismiss();
   } else {
-    overlay.bumpToComplete();
-    await sleep(280);
+    if (overlay.show && overlay.percent < 100) {
+      overlay.completeSuccess();
+    }
+    await sleep(420);
     overlay.dismiss();
     if (props.showSuccessNotification) {
       ElMessage.success("上传成功");

+ 9 - 4
src/components/popupLoading/index.vue

@@ -34,10 +34,12 @@
           {{ title }}
         </p>
       </div>
-      <div class="pl-divider" />
-      <button type="button" class="pl-cancel" @click="onCancel">
-        <span class="pl-cancel__text">{{ cancelText }}</span>
-      </button>
+      <template v-if="showCancel">
+        <div class="pl-divider" />
+        <button type="button" class="pl-cancel" @click="onCancel">
+          <span class="pl-cancel__text">{{ cancelText }}</span>
+        </button>
+      </template>
     </div>
   </div>
 </template>
@@ -75,6 +77,9 @@ const displayPercent = computed(() => {
   return Math.round(Math.min(100, Math.max(0, n)));
 });
 
+/** 进度到 100% 后不再允许取消(上传/审核已结束) */
+const showCancel = computed(() => displayPercent.value < 100);
+
 /** SVG 环形:与 Element 主题色对齐 */
 const vb = 100;
 const cx = vb / 2;

+ 2 - 1
src/enums/httpEnum.ts

@@ -7,7 +7,8 @@ export enum ResultEnum {
   OVERDUE = 401,
   /** 账号在别处登录等业务踢下线 */
   KICK_OUT = 666,
-  TIMEOUT = 300000,
+  /** 大文件/视频上传场景需更久,与 OSS 直传超时口径对齐 */
+  TIMEOUT = 600000,
   TYPE = "success"
 }
 

+ 1 - 1
src/stores/modules/auth.ts

@@ -36,7 +36,7 @@ export const useAuthStore = defineStore({
       const { data } = (await getAuthMenuListApi()) as any;
 
       const hasPermission = await usePermission();
-      const hideMenuNames = ["storeDecoration", "financialManagement", "licenseManagement", "dynamicManagement"];
+      const hideMenuNames = ["storeDecoration", "financialManagement", "licenseManagement"];
 
       // 获取用户信息和 businessSection(经营板块)、mealsFlag(是否提供餐食)、storeId、storeTickets
       const userInfo = localGet("geeker-user")?.userInfo || {};

+ 149 - 15
src/stores/modules/simpleUploadOverlay.ts

@@ -2,16 +2,54 @@ import { defineStore } from "pinia";
 import { ref } from "vue";
 import { ElMessage } from "element-plus";
 
-let progressTimer: ReturnType<typeof setInterval> | null = null;
+/** 上传占 90%,内容审核占 10% */
+const UPLOAD_PERCENT_MAX = 90;
+const AUDIT_PERCENT_MAX = 100;
+
+const AUDIT_ROTATE_MESSAGES = [
+  "检测违规内容中...",
+  "审核内容合规性...",
+  "筛查敏感信息中...",
+  "识别不良信息中...",
+  "智能风控核验中..."
+];
+
+const AUDIT_TITLE_DONE = "上传完毕,处理中...";
+const AUDIT_TITLE_SUCCESS = "上传成功";
+
+let uploadCreepTimer: ReturnType<typeof setInterval> | null = null;
+let auditMessageTimer: ReturnType<typeof setInterval> | null = null;
+let auditProgressTimer: ReturnType<typeof setInterval> | null = null;
+let auditMessageIndex = 0;
 let activeController: AbortController | null = null;
 
-function clearProgressTimer() {
-  if (progressTimer) {
-    clearInterval(progressTimer);
-    progressTimer = null;
+function clearUploadCreepTimer() {
+  if (uploadCreepTimer) {
+    clearInterval(uploadCreepTimer);
+    uploadCreepTimer = null;
+  }
+}
+
+function clearAuditTimers() {
+  if (auditMessageTimer) {
+    clearInterval(auditMessageTimer);
+    auditMessageTimer = null;
+  }
+  if (auditProgressTimer) {
+    clearInterval(auditProgressTimer);
+    auditProgressTimer = null;
   }
 }
 
+function clearAllTimers() {
+  clearUploadCreepTimer();
+  clearAuditTimers();
+}
+
+function clampPercent(n: number) {
+  return Math.min(AUDIT_PERCENT_MAX, Math.max(0, Math.round(n)));
+}
+
 export const useSimpleUploadOverlayStore = defineStore("simple-upload-overlay", () => {
   const show = ref(false);
   const percent = ref(0);
@@ -20,28 +58,119 @@ export const useSimpleUploadOverlayStore = defineStore("simple-upload-overlay",
 
   function beginUpload(opts?: { title?: string }) {
     activeController?.abort(new DOMException("已开始新的上传", "AbortError"));
-    clearProgressTimer();
+    clearAllTimers();
     activeController = new AbortController();
     title.value = opts?.title ?? "上传中";
-    percent.value = 3;
+    percent.value = 0;
     show.value = true;
-    progressTimer = setInterval(() => {
-      if (percent.value < 88) {
-        percent.value = Math.min(88, percent.value + 2 + Math.random() * 6);
+    auditMessageIndex = 0;
+    /** OSS 未回调 progress 时缓慢推进,上限留到 85% 等待真实进度 */
+    uploadCreepTimer = setInterval(() => {
+      if (percent.value < 85) {
+        percent.value = Math.min(85, percent.value + 1);
       }
-    }, 260);
+    }, 220);
     return activeController.signal;
   }
 
+  /** 单文件 OSS 上传进度 ratio 0~1 → 0~90% */
+  function setUploadProgress(ratio: number, opts?: { skipAudit?: boolean }) {
+    const r = Math.min(1, Math.max(0, Number(ratio) || 0));
+    const max = opts?.skipAudit ? AUDIT_PERCENT_MAX : UPLOAD_PERCENT_MAX;
+    const next = clampPercent(r * max);
+    if (next > percent.value) {
+      percent.value = next;
+    }
+    if (next >= UPLOAD_PERCENT_MAX - 1) {
+      clearUploadCreepTimer();
+    }
+  }
+
+  /** 多文件:第 index 个文件、共 total 个,单文件内 ratio 0~1 */
+  function setMultiFileUploadProgress(fileIndex: number, totalFiles: number, ratio: number) {
+    const total = Math.max(1, totalFiles);
+    const idx = Math.min(Math.max(0, fileIndex), total - 1);
+    const r = Math.min(1, Math.max(0, Number(ratio) || 0));
+    const base = (idx / total) * UPLOAD_PERCENT_MAX;
+    const span = UPLOAD_PERCENT_MAX / total;
+    const next = clampPercent(base + r * span);
+    if (next > percent.value) {
+      percent.value = next;
+    }
+    if (r >= 1 && idx === total - 1) {
+      clearUploadCreepTimer();
+      percent.value = UPLOAD_PERCENT_MAX;
+    }
+  }
+
+  /** 多文件审核阶段进度 */
+  function setMultiFileAuditProgress(fileIndex: number, totalFiles: number, ratio: number) {
+    const total = Math.max(1, totalFiles);
+    const idx = Math.min(Math.max(0, fileIndex), total - 1);
+    const r = Math.min(1, Math.max(0, Number(ratio) || 0));
+    const base = UPLOAD_PERCENT_MAX + (idx / total) * (AUDIT_PERCENT_MAX - UPLOAD_PERCENT_MAX);
+    const span = (AUDIT_PERCENT_MAX - UPLOAD_PERCENT_MAX) / total;
+    percent.value = clampPercent(base + r * span);
+  }
+
+  function startAuditMessageRotation() {
+    clearAuditTimers();
+    auditMessageIndex = 0;
+    title.value = AUDIT_TITLE_DONE;
+    auditMessageTimer = setInterval(() => {
+      title.value = AUDIT_ROTATE_MESSAGES[auditMessageIndex % AUDIT_ROTATE_MESSAGES.length];
+      auditMessageIndex += 1;
+    }, 1200);
+    let auditP = percent.value < UPLOAD_PERCENT_MAX ? UPLOAD_PERCENT_MAX : percent.value;
+    percent.value = UPLOAD_PERCENT_MAX;
+    auditProgressTimer = setInterval(() => {
+      if (auditP < 99) {
+        auditP = Math.min(99, auditP + 0.35);
+        percent.value = clampPercent(auditP);
+      }
+    }, 280);
+  }
+
+  /** 进入审核阶段:固定 90%,先显示「上传完毕,处理中...」再轮播审核文案 */
+  function beginAuditPhase() {
+    clearUploadCreepTimer();
+    percent.value = UPLOAD_PERCENT_MAX;
+    title.value = AUDIT_TITLE_DONE;
+    setTimeout(() => {
+      if (!show.value) return;
+      startAuditMessageRotation();
+    }, 600);
+  }
+
+  function beginAuditPhaseForBatch(fileIndex: number, totalFiles: number) {
+    clearUploadCreepTimer();
+    const total = Math.max(1, totalFiles);
+    const idx = Math.min(Math.max(0, fileIndex), total - 1);
+    const auditBase = UPLOAD_PERCENT_MAX + (idx / total) * (AUDIT_PERCENT_MAX - UPLOAD_PERCENT_MAX);
+    percent.value = clampPercent(Math.max(UPLOAD_PERCENT_MAX, auditBase));
+    title.value = AUDIT_TITLE_DONE;
+    setTimeout(() => {
+      if (!show.value) return;
+      startAuditMessageRotation();
+    }, 600);
+  }
+
+  function completeSuccess() {
+    clearAllTimers();
+    percent.value = AUDIT_PERCENT_MAX;
+    title.value = AUDIT_TITLE_SUCCESS;
+  }
+
+  /** @deprecated 请用 completeSuccess */
   function bumpToComplete() {
-    clearProgressTimer();
-    percent.value = 100;
+    completeSuccess();
   }
 
   function dismiss() {
-    clearProgressTimer();
+    clearAllTimers();
     show.value = false;
     percent.value = 0;
+    title.value = "上传中";
     activeController = null;
   }
 
@@ -51,7 +180,6 @@ export const useSimpleUploadOverlayStore = defineStore("simple-upload-overlay",
     ElMessage.info("取消上传");
   }
 
-  /** 供 skipSimpleUploadOverlay 的上传与 beginUpload 配套,把 fetch 绑到同一 AbortSignal */
   function getActiveAbortSignal(): AbortSignal | undefined {
     return activeController?.signal;
   }
@@ -62,6 +190,12 @@ export const useSimpleUploadOverlayStore = defineStore("simple-upload-overlay",
     title,
     cancelText,
     beginUpload,
+    setUploadProgress,
+    setMultiFileUploadProgress,
+    setMultiFileAuditProgress,
+    beginAuditPhase,
+    beginAuditPhaseForBatch,
+    completeSuccess,
     bumpToComplete,
     dismiss,
     userCancel,

+ 4 - 0
src/typings/global.d.ts

@@ -68,6 +68,10 @@ declare interface ViteEnv {
   VITE_AI_UPLOAD_BASE?: string;
   /** 上传完成后对外访问 URL 前缀 */
   VITE_AI_FILES_PUBLIC_BASE?: string;
+  /** 可选;官方相册视频 simple 上传服务根,不配则开发走相对路径 /dev-upload-ailien/...、生产默认 upload.ailien.shop:8443 */
+  VITE_DEV_UPLOAD_SIMPLE_BASE?: string;
+  /** 可选;OSS 直传 endpoint host,内网可设 oss-cn-beijing-internal.aliyuncs.com,不配则用 sts-token 返回值 */
+  VITE_OSS_UPLOAD_ENDPOINT?: string;
 }
 
 interface ImportMetaEnv extends ViteEnv {

+ 308 - 0
src/utils/businessInfoImageUpload.ts

@@ -0,0 +1,308 @@
+import type { UploadRequestOptions, UploadUserFile } from "element-plus";
+
+import { ElLoading, ElMessage } from "element-plus";
+
+import {
+  uploadFileToOss,
+  uploadFileToOssStsOnlyWithMeta,
+  requestOssFinalizeWithOcr,
+  isUploadUserCancelledError,
+  isUploadApiErrorAlreadyMessaged
+} from "@/api/upload.js";
+
+export { isUploadUserCancelledError };
+
+export interface BusinessInfoImageUploadResult {
+  fileUrl: string;
+
+  /** 与历史 media_id / image_id 字段兼容;OSS 场景下默认与 fileUrl 一致 */
+
+  mediaId: string;
+}
+
+let blockingLoadingDepth = 0;
+
+let blockingLoadingInst: ReturnType<typeof ElLoading.service> | null = null;
+
+/** 页面单独 OCR 审核期间:全屏 loading + 禁止操作 */
+
+export async function withBusinessInfoBlockingLoading<T>(
+  task: () => Promise<T>,
+
+  text = "识别中,请稍候..."
+): Promise<T> {
+  if (blockingLoadingDepth === 0) {
+    blockingLoadingInst = ElLoading.service({
+      fullscreen: true,
+
+      lock: true,
+
+      text,
+
+      background: "rgba(0, 0, 0, 0.55)"
+    });
+  }
+
+  blockingLoadingDepth += 1;
+
+  try {
+    return await task();
+  } finally {
+    blockingLoadingDepth -= 1;
+
+    if (blockingLoadingDepth <= 0 && blockingLoadingInst) {
+      blockingLoadingInst.close();
+
+      blockingLoadingInst = null;
+
+      blockingLoadingDepth = 0;
+    }
+  }
+}
+
+/**
+
+ * 商家进件等:OSS STS 直传;默认走 finalize 内容审核
+
+ * @param options.skipFinalize 仅直传 OSS,不走 finalize(证照 OCR 请用 uploadBusinessInfoImageWithFinalizeOcr)
+
+ */
+
+export async function uploadBusinessInfoImageToOss(
+  file: File,
+
+  options: {
+    showUploadOverlay?: boolean;
+
+    skipFinalize?: boolean;
+
+    uploadOverlayTitle?: string;
+
+    uploadSuccessMessage?: string | null;
+  } = {}
+): Promise<BusinessInfoImageUploadResult> {
+  const showOverlay = options.showUploadOverlay !== false;
+
+  const skipFinalize = options.skipFinalize === true;
+
+  const fileUrl = String(
+    (await uploadFileToOss(file, "image", {
+      skipSimpleUploadOverlay: !showOverlay,
+
+      skipFinalize,
+
+      uploadOverlayTitle: options.uploadOverlayTitle ?? (skipFinalize ? "上传中..." : undefined),
+
+      uploadSuccessMessage:
+        options.uploadSuccessMessage !== undefined ? options.uploadSuccessMessage : skipFinalize ? null : undefined
+    })) || ""
+  ).trim();
+
+  if (!fileUrl) {
+    throw new Error("上传失败,未返回地址");
+  }
+
+  return { fileUrl, mediaId: fileUrl };
+}
+
+/**
+ * 营业执照 / 身份证等:OSS 直传 → POST /upload/oss/finalize(内容审核)→ ocrRequestUrl(OCR)
+ */
+export async function uploadBusinessInfoImageWithFinalizeOcr(
+  file: File,
+  _ocrType: string,
+  applyOcrByUrl: (imageUrl: string) => void | Promise<void>,
+  options: { showUploadOverlay?: boolean; ocrLoadingText?: string } = {}
+): Promise<BusinessInfoImageUploadResult> {
+  const showOverlay = options.showUploadOverlay !== false;
+  let fileUrl = "";
+
+  const runAuditAndOcr = async (objectKey: string, stsFileUrl: string) => {
+    const audit = await requestOssFinalizeWithOcr(file, objectKey, "");
+    const imageUrl = String(audit.downloadUrl || stsFileUrl || "").trim();
+    if (!imageUrl) {
+      throw new Error("上传失败,未返回地址");
+    }
+    await applyOcrByUrl(imageUrl);
+    return imageUrl;
+  };
+
+  if (showOverlay) {
+    const sts = await uploadFileToOssStsOnlyWithMeta(file, {
+      skipSimpleUploadOverlay: false,
+      uploadOverlayTitle: "上传中...",
+      uploadSuccessMessage: null
+    });
+    const stsUrl = String(sts.fileUrl || sts.downloadUrl || "").trim();
+    fileUrl = await withBusinessInfoBlockingLoading(
+      () => runAuditAndOcr(sts.objectKey, stsUrl),
+      options.ocrLoadingText ?? "识别中,请稍候..."
+    );
+  } else {
+    fileUrl = await withBusinessInfoBlockingLoading(async () => {
+      const sts = await uploadFileToOssStsOnlyWithMeta(file, {
+        skipSimpleUploadOverlay: true,
+        uploadSuccessMessage: null
+      });
+      const stsUrl = String(sts.fileUrl || sts.downloadUrl || "").trim();
+      return runAuditAndOcr(sts.objectKey, stsUrl);
+    }, options.ocrLoadingText ?? "识别中,请稍候...");
+  }
+
+  if (!fileUrl) {
+    throw new Error("上传失败,未返回地址");
+  }
+  ElMessage.success("上传成功");
+  return { fileUrl, mediaId: fileUrl };
+}
+
+/**
+ * 营业执照 / 身份证等:OSS 直传(不走 finalize)→ 页面 OCR 审核(全屏 loading)
+ * @deprecated 进件 subjectInfo 等请用 uploadBusinessInfoImageWithFinalizeOcr
+ */
+export async function uploadBusinessInfoImageWithPageOcr(
+  file: File,
+
+  runOcr: (file: File) => Promise<void>,
+
+  options: { showUploadOverlay?: boolean; ocrLoadingText?: string } = {}
+): Promise<BusinessInfoImageUploadResult> {
+  const result = await uploadBusinessInfoImageToOss(file, {
+    showUploadOverlay: options.showUploadOverlay !== false,
+
+    skipFinalize: true
+  });
+
+  await withBusinessInfoBlockingLoading(
+    () => runOcr(file),
+
+    options.ocrLoadingText ?? "识别中,请稍候..."
+  );
+
+  ElMessage.success("上传成功");
+
+  return result;
+}
+
+export function filterOutUploadUserFileByUid(
+  list: UploadUserFile[],
+
+  uid?: number
+): UploadUserFile[] {
+  if (uid == null) return list;
+
+  return list.filter(f => f.uid !== uid);
+}
+
+/** 从 file-list 收集已成功上传的服务器地址(排除 blob 本地预览) */
+
+/** 审核失败 / 上传失败:移除列表项并清空业务字段(营业执照、证件照等) */
+
+export function failBusinessInfoUploadCleanup(handlers: {
+  fileList: UploadUserFile[];
+
+  setFileList: (list: UploadUserFile[]) => void;
+
+  uid?: number;
+
+  onClear?: () => void;
+
+  error?: unknown;
+}) {
+  handlers.setFileList(filterOutUploadUserFileByUid(handlers.fileList, handlers.uid));
+
+  handlers.onClear?.();
+
+  const err = handlers.error;
+
+  if (err && !isUploadUserCancelledError(err) && !isUploadApiErrorAlreadyMessaged(err)) {
+    const o = err && typeof err === "object" ? (err as Record<string, unknown>) : null;
+
+    const msg = String(
+      (err instanceof Error ? err.message : "") || o?.msg || o?.message || (typeof err === "string" ? err : "") || "上传失败"
+    ).trim();
+
+    if (msg) ElMessage.error(msg);
+  }
+}
+
+export function collectSuccessUploadUrls(list: UploadUserFile[]): string[] {
+  return list
+
+    .filter(f => f.status === "success")
+
+    .map(f => {
+      const fu = f as UploadUserFile & { url?: string; response?: { url?: string } };
+
+      return String(fu.url ?? fu.response?.url ?? "").trim();
+    })
+
+    .filter(u => u && !/^blob:/i.test(u) && !/^data:/i.test(u));
+}
+
+type UploadFailHandler = UploadRequestOptions["onError"];
+
+/**
+
+ * el-upload http-request 通用:OSS 上传,失败/取消时移除列表项
+
+ */
+
+export async function runBusinessInfoOssUpload(
+  options: UploadRequestOptions,
+  handlers: {
+    fileList: UploadUserFile[];
+
+    setFileList: (list: UploadUserFile[]) => void;
+
+    onUploaded: (result: BusinessInfoImageUploadResult, file: File) => void | Promise<void>;
+
+    onFail?: () => void;
+  }
+): Promise<void> {
+  const uploadFileItem = options.file as UploadUserFile;
+
+  const uid = uploadFileItem.uid;
+
+  const raw = uploadFileItem.raw || uploadFileItem;
+
+  const file = raw instanceof File ? raw : null;
+
+  if (!file) {
+    handlers.setFileList(filterOutUploadUserFileByUid(handlers.fileList, uid));
+
+    (options.onError as UploadFailHandler)?.(new Error("无效文件") as any);
+
+    return;
+  }
+
+  uploadFileItem.status = "uploading";
+
+  try {
+    const result = await uploadBusinessInfoImageToOss(file);
+
+    uploadFileItem.status = "success";
+
+    uploadFileItem.url = result.fileUrl;
+
+    uploadFileItem.response = { media_id: result.mediaId, url: result.fileUrl };
+
+    await handlers.onUploaded(result, file);
+
+    options.onSuccess(result as any);
+  } catch (err) {
+    failBusinessInfoUploadCleanup({
+      fileList: handlers.fileList,
+
+      setFileList: handlers.setFileList,
+
+      uid,
+
+      onClear: handlers.onFail,
+
+      error: err
+    });
+
+    (options.onError as UploadFailHandler)?.(err as any);
+  }
+}

+ 9 - 8
src/utils/config.ts

@@ -1,20 +1,21 @@
 /**
  * Tus / simple 上传服务「请求」基址(与 uni 端对齐)
- * - 未配置 VITE_AI_UPLOAD_BASE 时默认 **同源相对路径 `/ai-upload`**,由 Vite 开发代理或 Nginx 反代到 https://uat.ailien.shop,避免浏览器直连上传域产生跨域。
+ * - 未配置 VITE_AI_UPLOAD_BASE 时默认 **同源相对路径 `/ai-upload`**,由 Vite 开发代理或 Nginx 反代到实际上传域,避免浏览器直连 upload 域产生跨域。
  * - 若必须浏览器直连上传 HTTPS 域名,请在 .env 设置 VITE_AI_UPLOAD_BASE,并确保该域已正确配置 CORS(含 Authorization、OPTIONS)。
  */
 const trimSlash = (s: string) => s.replace(/\/$/, "");
 
 export const BASE_AI_URL = trimSlash(String(import.meta.env.VITE_AI_UPLOAD_BASE || "").trim() || "/ai-upload");
 
-/**
- * multipart 简单上传:浏览器走同源 `${BASE_AI_URL}/upload/simple`(默认 `/ai-upload/upload/simple`),
- * 经 Vite/Nginx 反代后实际上传至 https://uat.ailien.shop/upload/simple。
- * 勿在 prod/test 等环境打包为直链 uat 域名,否则会 CORS。
- */
-export const SIMPLE_UPLOAD_URL = `${BASE_AI_URL}/upload/simple`;
-
 /** 上传完成后对外可访问的文件 URL:`${AI_UPLOAD_FILES_PUBLIC_BASE}/${uploadId}` */
 export const AI_UPLOAD_FILES_PUBLIC_BASE = trimSlash(
   String(import.meta.env.VITE_AI_FILES_PUBLIC_BASE || "").trim() || "https://uat.ailien.shop/files"
 );
+
+/**
+ * 上传:GET /upload/oss/sts-token → OSS 直传 → POST /upload/oss/finalize 审核
+ * 不配时:开发环境请求同源相对路径(需在 VITE_PROXY 中把 `/dev-upload-ailien` 指到上传服务);生产默认 upload.ailien.shop:8443
+ */
+export const BASE_DEV_UPLOAD_SIMPLE = trimSlash(
+  String(import.meta.env.VITE_DEV_UPLOAD_SIMPLE_BASE || "").trim() || (import.meta.env.DEV ? "" : "https://uat.ailien.shop")
+);

+ 7 - 5
src/utils/withSimpleUploadOverlay.ts

@@ -6,9 +6,9 @@ function sleep(ms: number) {
 }
 
 /**
- * 使用全局 PopupLoading 包裹「/upload/simple」类上传;成功后提示「上传成功」
- * 取消(AbortError)不弹成功提示;失败时关闭弹层,由调用方决定是否 ElMessage.error
- * `successMessage === null` 时不弹成功提示(用于上传后还要继续审核等场景)。
+ * 使用全局 PopupLoading 包裹 OSS 上传 + 审核
+ * 进度:上传 0~90%,审核 90~100%;100% 且文案为「上传成功」后关闭
+ * `successMessage === null` 时不额外 ElMessage(弹层内已展示成功文案)。
  */
 export async function withSimpleUploadOverlay<T>(
   task: (signal: AbortSignal) => Promise<T>,
@@ -18,8 +18,10 @@ export async function withSimpleUploadOverlay<T>(
   const signal = overlay.beginUpload({ title: options?.title });
   try {
     const result = await task(signal);
-    overlay.bumpToComplete();
-    await sleep(280);
+    if (overlay.show && overlay.percent < 100) {
+      overlay.completeSuccess();
+    }
+    await sleep(420);
     overlay.dismiss();
     if (options?.successMessage !== null) {
       ElMessage.success(options?.successMessage ?? "上传成功");

+ 38 - 9
src/views/appoinmentManagement/classifyManagement.vue

@@ -121,6 +121,7 @@ import {
   scheduleSort
 } from "@/api/modules/scheduledService";
 import { uploadFileToOss } from "@/api/upload.js";
+import { failBusinessInfoUploadCleanup } from "@/utils/businessInfoImageUpload";
 import { localGet } from "@/utils";
 
 /** 平面图单张上限(与商户端 / 动态发布一致) */
@@ -252,34 +253,62 @@ const rules: FormRules = {
   ]
 };
 
+function syncPlaneUrlsFromFileList() {
+  form.planeImageUrls = form.planeImageFileList
+    .map(f => String((f as UploadUserFile).url || "").trim())
+    .filter(u => u && !/^blob:/i.test(u) && !/^data:/i.test(u));
+}
+
+/** 取消/失败时从列表移除本地预览,避免仍显示未上传成功的图 */
+function removePlaneUploadByUid(uid?: number) {
+  if (uid == null) return;
+  form.planeImageFileList = form.planeImageFileList.filter(f => f.uid !== uid);
+  syncPlaneUrlsFromFileList();
+}
+
 async function handlePlaneImageUpload(options: UploadRequestOptions) {
   const uploadFileItem = options.file as UploadUserFile;
+  const uid = uploadFileItem.uid;
   const raw = uploadFileItem.raw || uploadFileItem;
   const file = raw instanceof File ? raw : null;
-  if (!file) return;
+  if (!file) {
+    removePlaneUploadByUid(uid);
+    options.onError(new Error("无效文件") as any);
+    return;
+  }
 
   uploadFileItem.status = "uploading";
   try {
-    const fileUrl = await uploadFileToOss(file, "image");
+    const fileUrl = await uploadFileToOss(file, "image", { skipSimpleUploadOverlay: false });
     if (fileUrl) {
       uploadFileItem.status = "success";
       uploadFileItem.url = fileUrl;
       uploadFileItem.response = { url: fileUrl };
-      if (!form.planeImageUrls.includes(fileUrl)) form.planeImageUrls.push(fileUrl);
+      syncPlaneUrlsFromFileList();
+      options.onSuccess({ url: fileUrl } as any);
     } else {
-      uploadFileItem.status = "fail";
+      removePlaneUploadByUid(uid);
       ElMessage.error("上传失败,未返回地址");
+      options.onError(new Error("上传失败,未返回地址") as any);
     }
-  } catch {
-    uploadFileItem.status = "fail";
-    // OSS 签名/网络错误已在 upload.js 中提示
+  } catch (err) {
+    failBusinessInfoUploadCleanup({
+      fileList: form.planeImageFileList,
+      setFileList: list => {
+        form.planeImageFileList = list;
+      },
+      uid,
+      onClear: syncPlaneUrlsFromFileList,
+      error: err
+    });
+    options.onError(err as any);
   }
   formRef.value?.validateField("planeImageUrls").catch(() => {});
 }
 
 function onPlaneImageRemove(_file: UploadUserFile, fileList: UploadUserFile[]) {
-  const urls = fileList.map(f => (f as any).url).filter(Boolean);
-  form.planeImageUrls = urls;
+  form.planeImageFileList = fileList;
+  syncPlaneUrlsFromFileList();
   formRef.value?.validateField("planeImageUrls").catch(() => {});
 }
 

+ 49 - 38
src/views/businessInfo/manageInfo.vue

@@ -300,7 +300,12 @@ import { ElMessage } from "element-plus";
 import type { FormInstance, FormRules } from "element-plus";
 import type { UploadRequestOptions, UploadUserFile } from "element-plus";
 import { Plus, InfoFilled } from "@element-plus/icons-vue";
-import { getUpload } from "@/api/modules/businessInfo";
+import {
+  uploadBusinessInfoImageToOss,
+  filterOutUploadUserFileByUid,
+  failBusinessInfoUploadCleanup,
+  collectSuccessUploadUrls
+} from "@/utils/businessInfoImageUpload";
 import { localGet, localSet } from "@/utils/index";
 import cityJson from "@/assets/json/city.json";
 
@@ -362,6 +367,7 @@ function buildSalesScenesType(): string[] {
 const wechatMediaIdByFileUid = new Map<number, string>();
 
 function getMediaIdFromUploadFile(f: UploadUserFile): string {
+  const url = String((f as UploadUserFile & { url?: string }).url ?? "").trim();
   const tagged = String((f as UploadUserFile & { __wxMediaId?: string }).__wxMediaId ?? "").trim();
   if (tagged) return tagged;
   const uid = (f as UploadUserFile & { uid?: number }).uid;
@@ -371,15 +377,15 @@ function getMediaIdFromUploadFile(f: UploadUserFile): string {
   const r = (f as UploadUserFile & { response?: unknown }).response;
   if (r && typeof r === "object" && !Array.isArray(r)) {
     const o = r as Record<string, unknown>;
-    const direct = String(o.media_id ?? o.mediaId ?? "").trim();
+    const direct = String(o.media_id ?? o.mediaId ?? o.url ?? "").trim();
     if (direct) return direct;
     const nested = extractMediaUploadMeta(r).mediaId;
     if (nested) return nested;
   }
-  return "";
+  return url;
 }
 
-/** 写入进件缓存的四处图片字段均为微信 media/upload 返回的 media_id */
+/** 写入进件缓存的四处图片字段(OSS 上传后的可访问地址) */
 function collectMediaIdsForMerge(list: UploadUserFile[]): string[] {
   return list
     .filter(f => f.status === "success")
@@ -784,7 +790,7 @@ function urlListKey(kind: UploadKind): "offlineStorefrontUrls" | "offlineInterio
   return map[kind];
 }
 
-/** 解析 getUpload 响应:兼容 data 为字符串、双层 data、嵌套对象等 */
+/** 解析历史微信 media/upload 缓存(兼容旧数据) */
 function extractMediaUploadMeta(res: any): { fileUrl: string; mediaId: string; errMsg: string } {
   const errMsg = String(res?.msg ?? res?.message ?? res?.data?.msg ?? res?.data?.message ?? "").trim();
 
@@ -853,31 +859,49 @@ function extractMediaUploadMeta(res: any): { fileUrl: string; mediaId: string; e
   return { fileUrl, mediaId, errMsg };
 }
 
+function fileListForKind(kind: UploadKind): UploadUserFile[] {
+  if (kind === "storefront") return form.offlineStorefrontFileList;
+  if (kind === "interior") return form.offlineInteriorFileList;
+  if (kind === "miniShot") return form.miniProgramShotFileList;
+  return form.appShotFileList;
+}
+
+function setFileListForKind(kind: UploadKind, list: UploadUserFile[]) {
+  if (kind === "storefront") form.offlineStorefrontFileList = list;
+  else if (kind === "interior") form.offlineInteriorFileList = list;
+  else if (kind === "miniShot") form.miniProgramShotFileList = list;
+  else form.appShotFileList = list;
+}
+
 async function handleMultiUpload(options: UploadRequestOptions, kind: UploadKind) {
   const uploadFileItem = options.file as UploadUserFile;
+  const uid = uploadFileItem.uid;
   const raw = uploadFileItem.raw || uploadFileItem;
   const file = raw instanceof File ? raw : null;
-  if (!file) return;
+  if (!file) {
+    setFileListForKind(kind, filterOutUploadUserFileByUid(fileListForKind(kind), uid));
+    options.onError(new Error("无效文件") as any);
+    return;
+  }
 
   uploadFileItem.status = "uploading";
   try {
-    const fd = new FormData();
-    fd.append("file", file);
-    const res: any = await getUpload(fd);
-    const { fileUrl, mediaId, errMsg } = extractMediaUploadMeta(res);
-    if (mediaId) {
-      uploadFileItem.status = "success";
-      if (fileUrl) uploadFileItem.url = fileUrl;
-      uploadFileItem.response = { media_id: mediaId, url: fileUrl };
-      const uid = (uploadFileItem as UploadUserFile & { uid?: number }).uid;
-      if (uid != null) wechatMediaIdByFileUid.set(uid, mediaId);
-      (uploadFileItem as UploadUserFile & { __wxMediaId?: string }).__wxMediaId = mediaId;
-    } else {
-      uploadFileItem.status = "fail";
-      ElMessage.error(errMsg || "上传失败,未返回 media_id");
-    }
-  } catch {
-    uploadFileItem.status = "fail";
+    const { fileUrl, mediaId } = await uploadBusinessInfoImageToOss(file, { showUploadOverlay: false });
+    uploadFileItem.status = "success";
+    uploadFileItem.url = fileUrl;
+    uploadFileItem.response = { media_id: mediaId, url: fileUrl };
+    const uidNum = (uploadFileItem as UploadUserFile & { uid?: number }).uid;
+    if (uidNum != null) wechatMediaIdByFileUid.set(uidNum, fileUrl);
+    (uploadFileItem as UploadUserFile & { __wxMediaId?: string }).__wxMediaId = fileUrl;
+    options.onSuccess({ url: fileUrl } as any);
+  } catch (err) {
+    failBusinessInfoUploadCleanup({
+      fileList: fileListForKind(kind),
+      setFileList: list => setFileListForKind(kind, list),
+      uid,
+      error: err
+    });
+    options.onError(err as any);
   }
   syncUrlsFromFileList(kind);
   validateKind(kind);
@@ -885,20 +909,7 @@ async function handleMultiUpload(options: UploadRequestOptions, kind: UploadKind
 
 function syncUrlsFromFileList(kind: UploadKind) {
   const key = urlListKey(kind);
-  let list: UploadUserFile[] = [];
-  if (kind === "storefront") list = form.offlineStorefrontFileList;
-  else if (kind === "interior") list = form.offlineInteriorFileList;
-  else if (kind === "miniShot") list = form.miniProgramShotFileList;
-  else list = form.appShotFileList;
-
-  form[key] = list
-    .map(f => {
-      const fu = f as UploadUserFile & { url?: string; response?: { url?: string } };
-      const mid = getMediaIdFromUploadFile(f);
-      const url = String(fu.url ?? fu.response?.url ?? "").trim();
-      return mid || url;
-    })
-    .filter(Boolean) as string[];
+  form[key] = collectSuccessUploadUrls(fileListForKind(kind));
 }
 
 function onMultiRemove(file: UploadUserFile, _fileList: UploadUserFile[], kind: UploadKind) {
@@ -932,7 +943,7 @@ function assertActiveScenarioPicsHaveMediaId(): boolean {
     for (const f of g.list) {
       if (f.status !== "success") continue;
       if (!getMediaIdFromUploadFile(f)) {
-        ElMessage.warning(`「${g.label}」须使用微信素材上传接口返回的 media_id,请删除后重新上传`);
+        ElMessage.warning(`「${g.label}」请完成图片上传`);
         return false;
       }
     }

+ 136 - 108
src/views/businessInfo/subjectInfo.vue

@@ -221,9 +221,38 @@ import { ElMessage } from "element-plus";
 import type { FormInstance, FormRules } from "element-plus";
 import type { UploadRequestOptions, UploadUserFile } from "element-plus";
 import { Plus, InfoFilled } from "@element-plus/icons-vue";
-import { getOcrRequestByBase64, getUpload } from "@/api/modules/businessInfo";
+import { getOcrRequestUrl } from "@/api/modules/businessInfo";
+import {
+  uploadBusinessInfoImageWithFinalizeOcr,
+  filterOutUploadUserFileByUid,
+  failBusinessInfoUploadCleanup
+} from "@/utils/businessInfoImageUpload";
 import { localGet, localSet } from "@/utils/index";
 
+const GEEKER_USER_KEY = "geeker-user";
+
+function buildOcrRequestUrlParams(imageUrls: string, ocrType: string) {
+  const params: {
+    imageUrls: string;
+    ocrType: string;
+    storeId?: string | number;
+    storeUserId?: string | number;
+  } = { imageUrls, ocrType };
+  const geeker = localGet(GEEKER_USER_KEY) as
+    | { userInfo?: { storeId?: string | number | null; id?: string | number | null } }
+    | null
+    | undefined;
+  const storeId = geeker?.userInfo?.storeId ?? localGet("createdId");
+  const storeUserId = geeker?.userInfo?.id;
+  if (storeId !== undefined && storeId !== null && String(storeId).trim() !== "") {
+    params.storeId = storeId;
+  }
+  if (storeUserId !== undefined && storeUserId !== null && String(storeUserId).trim() !== "") {
+    params.storeUserId = storeUserId;
+  }
+  return params;
+}
+
 const BUSINESS_DATA_CACHE_KEY = "businessData";
 
 const formRef = ref<FormInstance>();
@@ -247,7 +276,7 @@ function clearBusinessLicenseOcr() {
   businessLicenseOcr.legalPerson = "";
 }
 
-/** 从 ocrRequestByBase64 成功响应中取出 creditCode / companyName / legalPerson */
+/** 从 ocrRequestUrl 响应中取出 creditCode / companyName / legalPerson */
 function pickBusinessLicenseOcrFields(res: any) {
   const raw = res?.data ?? res;
   let node: any = raw;
@@ -286,7 +315,7 @@ function splitIdCardValidPeriod(raw: string): { begin: string; end: string } {
   return { begin: "", end: "" };
 }
 
-/** 人像面 getOcrRequestByBase64(ID_CARD) 成功响应 */
+/** 人像面 ocrRequestUrl(ID_CARD) 成功响应 */
 function pickIdCardPortraitOcrFields(res: any): {
   name: string;
   idNumber: string;
@@ -640,25 +669,6 @@ function beforeLicenseUpload(file: File) {
   return true;
 }
 
-/** 与 getUpload(微信 media/upload)返回结构对齐 */
-function parseMediaUploadResult(res: any): { fileUrl: string; mediaId: string; errMsg: string } {
-  const envelope = res?.data ?? res;
-  const body = envelope?.data !== undefined ? envelope.data : envelope;
-  const fileUrl =
-    body?.url ??
-    body?.fileUrl ??
-    envelope?.url ??
-    envelope?.data?.url ??
-    envelope?.data?.fileUrl ??
-    body?.mediaUrl ??
-    body?.data?.mediaUrl ??
-    "";
-  const mediaId =
-    body?.media_id ?? body?.mediaId ?? envelope?.media_id ?? envelope?.data?.media_id ?? envelope?.data?.mediaId ?? "";
-  const errMsg = envelope?.msg ?? body?.msg ?? res?.msg ?? "";
-  return { fileUrl, mediaId, errMsg };
-}
-
 function mergeIdCardOcrFromFields(fields: ReturnType<typeof pickIdCardPortraitOcrFields>, mode: "portrait" | "emblem") {
   if (mode === "portrait") {
     if (fields.name) idPortraitOcr.name = fields.name;
@@ -673,99 +683,100 @@ function mergeIdCardOcrFromFields(fields: ReturnType<typeof pickIdCardPortraitOc
   }
 }
 
-async function requestIdCardPortraitOcr(file: File) {
-  const formData = new FormData();
-  formData.append("imageFile", file, file.name || "id-portrait.jpg");
-  formData.append("ocrType", "ID_CARD");
+async function requestIdCardPortraitOcrByUrl(imageUrl: string) {
   isIdPortraitOcrProcessing.value = true;
   try {
-    const res: any = await getOcrRequestByBase64(formData);
+    const res: any = await getOcrRequestUrl(buildOcrRequestUrlParams(imageUrl, "ID_CARD"));
     if (res?.code === 200 || res?.code === "200") {
       const fields = pickIdCardPortraitOcrFields(res);
       mergeIdCardOcrFromFields(fields, "portrait");
-      ElMessage.success("身份证人像面识别成功");
-    } else {
-      clearIdPortraitOcr();
-      ElMessage.warning(res?.msg || "身份证人像面识别未通过,请核对照片清晰度");
+      if (!fields.name && !fields.idNumber) {
+        clearIdPortraitOcr();
+        throw new Error("身份证人像面识别未通过,请核对照片清晰度");
+      }
+      return;
     }
+    clearIdPortraitOcr();
+    throw new Error(String(res?.msg || "身份证人像面识别未通过,请核对照片清晰度"));
   } finally {
     isIdPortraitOcrProcessing.value = false;
   }
 }
 
-/** 国徽面单独 OCR,合并有效期限(及背面地址等),不清空人像面已识别字段 */
-async function requestIdCardEmblemOcr(file: File) {
-  const formData = new FormData();
-  formData.append("imageFile", file, file.name || "id-emblem.jpg");
-  formData.append("ocrType", "ID_CARD");
+/** 国徽面 ocrRequestUrl,合并有效期限(及背面地址等),不清空人像面已识别字段 */
+async function requestIdCardEmblemOcrByUrl(imageUrl: string) {
   isIdPortraitOcrProcessing.value = true;
   try {
-    const res: any = await getOcrRequestByBase64(formData);
+    const res: any = await getOcrRequestUrl(buildOcrRequestUrlParams(imageUrl, "ID_CARD"));
     if (res?.code === 200 || res?.code === "200") {
       const fields = pickIdCardPortraitOcrFields(res);
       mergeIdCardOcrFromFields(fields, "emblem");
-      if (fields.cardPeriodBegin || fields.cardPeriodEnd) {
-        ElMessage.success("身份证国徽面识别成功");
-      } else {
-        ElMessage.warning("未识别到有效期限,请确认国徽面照片清晰完整");
+      if (!fields.cardPeriodBegin && !fields.cardPeriodEnd) {
+        throw new Error("未识别到有效期限,请确认国徽面照片清晰完整");
       }
-    } else {
-      ElMessage.warning(res?.msg || "身份证国徽面识别未通过,请核对照片清晰度");
+      return;
     }
+    throw new Error(String(res?.msg || "身份证国徽面识别未通过,请核对照片清晰度"));
   } finally {
     isIdPortraitOcrProcessing.value = false;
   }
 }
 
-async function requestBusinessLicenseOcr(file: File) {
-  const formData = new FormData();
-  formData.append("imageFile", file, file.name || "license.jpg");
-  formData.append("ocrType", "BUSINESS_LICENSE");
-  const res: any = await getOcrRequestByBase64(formData);
+async function requestBusinessLicenseOcrByUrl(imageUrl: string) {
+  const res: any = await getOcrRequestUrl(buildOcrRequestUrlParams(imageUrl, "BUSINESS_LICENSE"));
   if (res?.code === 200 || res?.code === "200") {
     const fields = pickBusinessLicenseOcrFields(res);
     businessLicenseOcr.creditCode = fields.creditCode;
     businessLicenseOcr.companyName = fields.companyName;
     businessLicenseOcr.legalPerson = fields.legalPerson;
-    ElMessage.success("营业执照识别成功");
-  } else {
-    clearBusinessLicenseOcr();
-    ElMessage.warning(res?.msg || "营业执照识别未通过,请核对照片清晰度");
+    if (!fields.creditCode && !fields.companyName && !fields.legalPerson) {
+      clearBusinessLicenseOcr();
+      throw new Error("营业执照识别未通过,请核对照片清晰度");
+    }
+    return;
   }
+  clearBusinessLicenseOcr();
+  throw new Error(String(res?.msg || "营业执照识别未通过,请核对照片清晰度"));
 }
 
 async function handleLicenseUpload(options: UploadRequestOptions) {
   const uploadFileItem = options.file as UploadUserFile;
+  const uid = uploadFileItem.uid;
   const raw = uploadFileItem.raw || uploadFileItem;
   const file = raw instanceof File ? raw : null;
-  if (!file) return;
+  if (!file) {
+    form.businessLicenseFileList = filterOutUploadUserFileByUid(form.businessLicenseFileList, uid);
+    options.onError(new Error("无效文件") as any);
+    return;
+  }
 
   uploadFileItem.status = "uploading";
   try {
-    const formData = new FormData();
-    formData.append("file", file);
-    const res: any = await getUpload(formData);
-    const { fileUrl, mediaId, errMsg } = parseMediaUploadResult(res);
-
-    if (fileUrl || mediaId) {
-      uploadFileItem.status = "success";
-      if (fileUrl) {
-        uploadFileItem.url = fileUrl;
-      }
-      uploadFileItem.response = { media_id: mediaId, url: fileUrl };
-      form.businessLicenseUrl = fileUrl || mediaId;
-      try {
-        await requestBusinessLicenseOcr(file);
-      } catch {
+    const { fileUrl, mediaId } = await uploadBusinessInfoImageWithFinalizeOcr(
+      file,
+      "BUSINESS_LICENSE",
+      requestBusinessLicenseOcrByUrl,
+      { showUploadOverlay: true }
+    );
+    uploadFileItem.status = "success";
+    uploadFileItem.url = fileUrl;
+    uploadFileItem.response = { media_id: mediaId, url: fileUrl };
+    form.businessLicenseUrl = fileUrl;
+    options.onSuccess({ url: fileUrl } as any);
+  } catch (err) {
+    failBusinessInfoUploadCleanup({
+      fileList: form.businessLicenseFileList,
+      setFileList: list => {
+        form.businessLicenseFileList = list;
+      },
+      uid,
+      onClear: () => {
+        form.businessLicenseUrl = "";
         clearBusinessLicenseOcr();
-        ElMessage.warning("营业执照识别服务暂时不可用,请稍后重试");
-      }
-    } else {
-      uploadFileItem.status = "fail";
-      ElMessage.error(errMsg || "上传失败,未返回可用结果");
-    }
-  } catch {
-    uploadFileItem.status = "fail";
+      },
+      error: err
+    });
+    options.onError(err as any);
   }
   formRef.value?.validateField("businessLicenseUrl").catch(() => {});
 }
@@ -785,46 +796,63 @@ type IdCardSide = "portrait" | "emblem";
 
 async function handleIdCardUpload(options: UploadRequestOptions, side: IdCardSide) {
   const uploadFileItem = options.file as UploadUserFile;
+  const uid = uploadFileItem.uid;
   const raw = uploadFileItem.raw || uploadFileItem;
   const file = raw instanceof File ? raw : null;
-  if (!file) return;
+  const fileList = side === "portrait" ? form.idPortraitFileList : form.idEmblemFileList;
+  if (!file) {
+    if (side === "portrait") {
+      form.idPortraitFileList = filterOutUploadUserFileByUid(fileList, uid);
+    } else {
+      form.idEmblemFileList = filterOutUploadUserFileByUid(fileList, uid);
+    }
+    options.onError(new Error("无效文件") as any);
+    return;
+  }
 
   uploadFileItem.status = "uploading";
   try {
-    const formData = new FormData();
-    formData.append("file", file);
-    const res: any = await getUpload(formData);
-    const { fileUrl, mediaId, errMsg } = parseMediaUploadResult(res);
-
-    if (fileUrl || mediaId) {
-      uploadFileItem.status = "success";
-      if (fileUrl) {
-        uploadFileItem.url = fileUrl;
-      }
-      uploadFileItem.response = { media_id: mediaId, url: fileUrl };
-      const stored = fileUrl || mediaId;
-      if (side === "portrait") {
-        form.idPortraitUrl = stored;
-        try {
-          await requestIdCardPortraitOcr(file);
-        } catch {
+    const runOcrByUrl = side === "portrait" ? requestIdCardPortraitOcrByUrl : requestIdCardEmblemOcrByUrl;
+    const { fileUrl, mediaId } = await uploadBusinessInfoImageWithFinalizeOcr(file, "ID_CARD", runOcrByUrl, {
+      showUploadOverlay: true
+    });
+    if (side === "portrait") {
+      form.idPortraitUrl = fileUrl;
+    } else {
+      form.idEmblemUrl = fileUrl;
+    }
+    uploadFileItem.status = "success";
+    uploadFileItem.url = fileUrl;
+    uploadFileItem.response = { media_id: mediaId, url: fileUrl };
+    options.onSuccess({ url: fileUrl } as any);
+  } catch (err) {
+    if (side === "portrait") {
+      failBusinessInfoUploadCleanup({
+        fileList: form.idPortraitFileList,
+        setFileList: list => {
+          form.idPortraitFileList = list;
+        },
+        uid,
+        onClear: () => {
+          form.idPortraitUrl = "";
           clearIdPortraitOcr();
-          ElMessage.warning("身份证识别服务暂时不可用,请稍后重试");
-        }
-      } else {
-        form.idEmblemUrl = stored;
-        try {
-          await requestIdCardEmblemOcr(file);
-        } catch {
-          ElMessage.warning("身份证国徽面识别服务暂时不可用,请稍后重试");
-        }
-      }
+        },
+        error: err
+      });
     } else {
-      uploadFileItem.status = "fail";
-      ElMessage.error(errMsg || "上传失败,未返回可用结果");
+      failBusinessInfoUploadCleanup({
+        fileList: form.idEmblemFileList,
+        setFileList: list => {
+          form.idEmblemFileList = list;
+        },
+        uid,
+        onClear: () => {
+          form.idEmblemUrl = "";
+        },
+        error: err
+      });
     }
-  } catch {
-    uploadFileItem.status = "fail";
+    options.onError(err as any);
   }
   const field = side === "portrait" ? "idPortraitUrl" : "idEmblemUrl";
   formRef.value?.validateField(field).catch(() => {});

+ 141 - 122
src/views/businessInfo/zfbIndex.vue

@@ -727,7 +727,13 @@ import { ElMessage } from "element-plus";
 import type { FormInstance, FormRules } from "element-plus";
 import type { UploadRequestOptions, UploadUserFile } from "element-plus";
 import { Plus, InfoFilled } from "@element-plus/icons-vue";
-import { getOcrRequestByBase64, uploadAlipayImage } from "@/api/modules/businessInfo";
+import { getOcrRequestByBase64 } from "@/api/modules/businessInfo";
+import {
+  uploadBusinessInfoImageToOss,
+  uploadBusinessInfoImageWithPageOcr,
+  filterOutUploadUserFileByUid,
+  failBusinessInfoUploadCleanup
+} from "@/utils/businessInfoImageUpload";
 import { localGet, localSet } from "@/utils/index";
 import cityJson from "@/assets/json/city.json";
 import categoryJson from "@/views/businessInfo/category.json";
@@ -1456,21 +1462,19 @@ async function runOcrAfterZfbUpload(kind: UploadKind, file: File) {
     formData.append("imageFile", file, file.name || "license.jpg");
     formData.append("ocrType", "BUSINESS_LICENSE");
     const res: any = await getOcrRequestByBase64(formData);
-    if (isOcrSuccess(res)) {
-      const fields = pickBusinessLicenseOcrFields(res);
-      if (fields.creditCode) form.creditCode = fields.creditCode;
-      if (form.subjectType === "07") {
-        if (fields.companyName) {
-          form.householdLicenseName = fields.companyName;
-          form.merchantShortName = fields.companyName;
-        }
-      } else {
-        if (fields.companyName) form.merchantShortName = fields.companyName;
-        if (fields.legalPerson) form.legalName = fields.legalPerson;
+    if (!isOcrSuccess(res)) {
+      throw new Error(String(res?.msg || "营业执照识别未通过,请核对照片清晰度"));
+    }
+    const fields = pickBusinessLicenseOcrFields(res);
+    if (fields.creditCode) form.creditCode = fields.creditCode;
+    if (form.subjectType === "07") {
+      if (fields.companyName) {
+        form.householdLicenseName = fields.companyName;
+        form.merchantShortName = fields.companyName;
       }
-      ElMessage.success("营业执照识别成功");
     } else {
-      ElMessage.warning(res?.msg || "营业执照识别未通过,请核对照片清晰度");
+      if (fields.companyName) form.merchantShortName = fields.companyName;
+      if (fields.legalPerson) form.legalName = fields.legalPerson;
     }
     return;
   }
@@ -1479,14 +1483,12 @@ async function runOcrAfterZfbUpload(kind: UploadKind, file: File) {
     formData.append("imageFile", file, file.name || "institution-cert.jpg");
     formData.append("ocrType", "BUSINESS_LICENSE");
     const res: any = await getOcrRequestByBase64(formData);
-    if (isOcrSuccess(res)) {
-      const f = pickInstitutionCertOcrFields(res);
-      if (f.certNumber) form.institutionCertNumber = f.certNumber;
-      if (f.orgName) form.merchantShortName = f.orgName;
-      ElMessage.success("事业单位法人证书识别成功");
-    } else {
-      ElMessage.warning(res?.msg || "事业单位法人证书识别未通过,请核对照片清晰度");
+    if (!isOcrSuccess(res)) {
+      throw new Error(String(res?.msg || "事业单位法人证书识别未通过,请核对照片清晰度"));
     }
+    const f = pickInstitutionCertOcrFields(res);
+    if (f.certNumber) form.institutionCertNumber = f.certNumber;
+    if (f.orgName) form.merchantShortName = f.orgName;
     return;
   }
   if (kind === "idFront") {
@@ -1494,16 +1496,14 @@ async function runOcrAfterZfbUpload(kind: UploadKind, file: File) {
     formData.append("imageFile", file, file.name || "id-portrait.jpg");
     formData.append("ocrType", "ID_CARD");
     const res: any = await getOcrRequestByBase64(formData);
-    if (isOcrSuccess(res)) {
-      const f = pickIdCardPortraitOcrFields(res);
-      if (f.name) form.legalName = f.name;
-      if (f.idNumber) form.idNumber = f.idNumber;
-      if (f.cardPeriodBegin && f.cardPeriodEnd) {
-        form.businessTermRange = [f.cardPeriodBegin, f.cardPeriodEnd];
-      }
-      ElMessage.success("身份证人像面识别成功");
-    } else {
-      ElMessage.warning(res?.msg || "身份证人像面识别未通过,请核对照片清晰度");
+    if (!isOcrSuccess(res)) {
+      throw new Error(String(res?.msg || "身份证人像面识别未通过,请核对照片清晰度"));
+    }
+    const f = pickIdCardPortraitOcrFields(res);
+    if (f.name) form.legalName = f.name;
+    if (f.idNumber) form.idNumber = f.idNumber;
+    if (f.cardPeriodBegin && f.cardPeriodEnd) {
+      form.businessTermRange = [f.cardPeriodBegin, f.cardPeriodEnd];
     }
     return;
   }
@@ -1512,18 +1512,14 @@ async function runOcrAfterZfbUpload(kind: UploadKind, file: File) {
     formData.append("imageFile", file, file.name || "id-emblem.jpg");
     formData.append("ocrType", "ID_CARD");
     const res: any = await getOcrRequestByBase64(formData);
-    if (isOcrSuccess(res)) {
-      const f = pickIdCardPortraitOcrFields(res);
-      if (f.cardPeriodBegin && f.cardPeriodEnd) {
-        form.businessTermRange = [f.cardPeriodBegin, f.cardPeriodEnd];
-      }
-      if (f.cardPeriodBegin || f.cardPeriodEnd) {
-        ElMessage.success("身份证国徽面识别成功");
-      } else {
-        ElMessage.warning("未识别到有效期限,请确认国徽面照片清晰完整");
-      }
-    } else {
-      ElMessage.warning(res?.msg || "身份证国徽面识别未通过,请核对照片清晰度");
+    if (!isOcrSuccess(res)) {
+      throw new Error(String(res?.msg || "身份证国徽面识别未通过,请核对照片清晰度"));
+    }
+    const f = pickIdCardPortraitOcrFields(res);
+    if (f.cardPeriodBegin && f.cardPeriodEnd) {
+      form.businessTermRange = [f.cardPeriodBegin, f.cardPeriodEnd];
+    } else if (!f.cardPeriodBegin && !f.cardPeriodEnd) {
+      throw new Error("未识别到有效期限,请确认国徽面照片清晰完整");
     }
   }
 }
@@ -1543,102 +1539,122 @@ function urlFieldFor(kind: UploadKind): keyof typeof form {
   return map[kind];
 }
 
+function fileListForUploadKind(kind: UploadKind): UploadUserFile[] {
+  const map: Record<UploadKind, UploadUserFile[]> = {
+    license: form.licenseFileList,
+    idFront: form.idFrontFileList,
+    idBack: form.idBackFileList,
+    storefront: form.storefrontFileList,
+    interior: form.interiorFileList,
+    institutionCert: form.institutionCertFileList,
+    otherOrgCert: form.otherOrgCertFileList,
+    socialCert: form.socialCertFileList,
+    govCert: form.govCertFileList
+  };
+  return map[kind] ?? [];
+}
+
+function setFileListForUploadKind(kind: UploadKind, list: UploadUserFile[]) {
+  if (kind === "license") form.licenseFileList = list;
+  else if (kind === "idFront") form.idFrontFileList = list;
+  else if (kind === "idBack") form.idBackFileList = list;
+  else if (kind === "storefront") form.storefrontFileList = list;
+  else if (kind === "interior") form.interiorFileList = list;
+  else if (kind === "institutionCert") form.institutionCertFileList = list;
+  else if (kind === "otherOrgCert") form.otherOrgCertFileList = list;
+  else if (kind === "socialCert") form.socialCertFileList = list;
+  else if (kind === "govCert") form.govCertFileList = list;
+}
+
 async function handleSingleUpload(options: UploadRequestOptions, kind: UploadKind) {
   const uploadFileItem = options.file as UploadUserFile;
+  const uid = uploadFileItem.uid;
   const raw = uploadFileItem.raw || uploadFileItem;
   const file = raw instanceof File ? raw : null;
-  if (!file) return;
-
   const field = urlFieldFor(kind);
+
+  if (!file) {
+    setFileListForUploadKind(kind, filterOutUploadUserFileByUid(fileListForUploadKind(kind), uid));
+    options.onError(new Error("无效文件") as any);
+    return;
+  }
+
   uploadFileItem.status = "uploading";
   try {
     if (file.size > MAX_ALIPAY_IMAGE_BYTES) {
-      uploadFileItem.status = "fail";
+      setFileListForUploadKind(kind, filterOutUploadUserFileByUid(fileListForUploadKind(kind), uid));
       ElMessage.error("文件大小不能超过 20M");
+      options.onError(new Error("文件大小不能超过 20M") as any);
       return;
     }
-    const imageType = resolveAlipayImageType(file);
-    if (!imageType) {
-      uploadFileItem.status = "fail";
-      ElMessage.error("无法识别图片格式,请使用带扩展名的文件(如 .jpg、.png)");
-      return;
+    const needOcr = kind === "license" || kind === "idFront" || kind === "idBack" || kind === "institutionCert";
+    const { fileUrl } = needOcr
+      ? await uploadBusinessInfoImageWithPageOcr(file, f => runOcrAfterZfbUpload(kind, f), {
+          showUploadOverlay: true
+        })
+      : await uploadBusinessInfoImageToOss(file, { showUploadOverlay: true });
+    const resolvedImageId = extractAlipayFilenameFromImageUrl(fileUrl) || fileUrl;
+
+    if (needOcr) {
+      nextTick(() => {
+        const f = formRef.value;
+        if (!f) return;
+        if (kind === "license") {
+          f.validateField("creditCode").catch(() => {});
+          f.validateField("merchantShortName").catch(() => {});
+          if (form.subjectType === "07") {
+            f.validateField("householdLicenseName").catch(() => {});
+          } else {
+            f.validateField("legalName").catch(() => {});
+          }
+        } else if (kind === "institutionCert") {
+          f.validateField("institutionCertNumber").catch(() => {});
+          f.validateField("merchantShortName").catch(() => {});
+        } else if (kind === "idFront") {
+          f.validateField("legalName").catch(() => {});
+          f.validateField("idNumber").catch(() => {});
+          f.validateField("businessTermRange").catch(() => {});
+        } else if (kind === "idBack") {
+          f.validateField("businessTermRange").catch(() => {});
+        }
+      });
     }
-    const storeId = resolveAlipayUploadStoreId();
-    // if (storeId === null) {
-    //   uploadFileItem.status = "fail";
-    //   ElMessage.error("未获取到门店 storeId,请重新登录或通过链接传入 storeId");
-    //   return;
-    // }
-    const res: any = await uploadAlipayImage({
-      storeId,
-      imageType,
-      imageContent: file
-    });
-    const { fileUrl, mediaId, errMsg } = parseMediaUploadResult(res);
-    const httpOk = isAlipayUploadHttpSuccess(res);
-    const hasMediaRef = !!(fileUrl || mediaId);
-    const resolvedImageId = resolveAlipayUploadImageId(res, fileUrl, mediaId);
-
-    if (hasMediaRef || httpOk) {
-      uploadFileItem.status = "success";
-      if (fileUrl) {
-        uploadFileItem.url = fileUrl;
-      } else {
-        uploadFileItem.url = URL.createObjectURL(file);
-      }
-      uploadFileItem.response = { media_id: resolvedImageId, url: fileUrl };
-      (form as any)[field] = fileUrl || mediaId || (httpOk ? uploadFileItem.url : "");
-      if (kind === "storefront") {
-        form.storefrontImageId = resolvedImageId;
-      }
-      if (kind === "interior") {
-        form.interiorImageId = resolvedImageId;
-      }
-      if (kind === "license") {
-        form.licenseImageId = resolvedImageId;
-      }
 
-      const needOcr = kind === "license" || kind === "idFront" || kind === "idBack" || kind === "institutionCert";
-      if (needOcr) {
-        try {
-          await runOcrAfterZfbUpload(kind, file);
-          nextTick(() => {
-            const f = formRef.value;
-            if (!f) return;
-            if (kind === "license") {
-              f.validateField("creditCode").catch(() => {});
-              f.validateField("merchantShortName").catch(() => {});
-              if (form.subjectType === "07") {
-                f.validateField("householdLicenseName").catch(() => {});
-              } else {
-                f.validateField("legalName").catch(() => {});
-              }
-            } else if (kind === "institutionCert") {
-              f.validateField("institutionCertNumber").catch(() => {});
-              f.validateField("merchantShortName").catch(() => {});
-            } else if (kind === "idFront") {
-              f.validateField("legalName").catch(() => {});
-              f.validateField("idNumber").catch(() => {});
-              f.validateField("businessTermRange").catch(() => {});
-            } else if (kind === "idBack") {
-              f.validateField("businessTermRange").catch(() => {});
-            }
-          });
-        } catch {
-          ElMessage.warning("识别服务暂时不可用,请稍后重试或手动填写");
-        }
-      }
-    } else {
-      uploadFileItem.status = "fail";
-      ElMessage.error(errMsg || res?.msg || "上传失败,未返回可用结果");
+    uploadFileItem.status = "success";
+    uploadFileItem.url = fileUrl;
+    uploadFileItem.response = { media_id: resolvedImageId, url: fileUrl };
+    (form as any)[field] = fileUrl;
+    if (kind === "storefront") {
+      form.storefrontImageId = resolvedImageId;
     }
-  } catch {
-    uploadFileItem.status = "fail";
-    ElMessage.error("上传失败,请稍后重试");
+    if (kind === "interior") {
+      form.interiorImageId = resolvedImageId;
+    }
+    if (kind === "license") {
+      form.licenseImageId = resolvedImageId;
+    }
+    options.onSuccess({ url: fileUrl } as any);
+  } catch (err) {
+    failBusinessInfoUploadCleanup({
+      fileList: fileListForUploadKind(kind),
+      setFileList: list => setFileListForUploadKind(kind, list),
+      uid,
+      onClear: () => clearZfbUploadFieldsForKind(kind),
+      error: err
+    });
+    options.onError(err as any);
   }
   formRef.value?.validateField(field as string).catch(() => {});
 }
 
+function clearZfbUploadFieldsForKind(kind: UploadKind) {
+  const field = urlFieldFor(kind);
+  (form as any)[field] = "";
+  if (kind === "storefront") form.storefrontImageId = "";
+  if (kind === "interior") form.interiorImageId = "";
+  if (kind === "license") form.licenseImageId = "";
+}
+
 function onSingleRemove(fileList: UploadUserFile[], kind: UploadKind) {
   const field = urlFieldFor(kind);
   if (!fileList.length) {
@@ -1712,10 +1728,13 @@ function buildBusinessAddressForAlipayJson():
   return { address, districtCode, cityCode, provinceCode };
 }
 
-/** 仅使用上传成功接口里保存的 image_id;且只认 .jpg / .jpeg / .png(不从 URL 等其它字段截取) */
+/** 进件用图片标识:支付宝 image_id 或 OSS 地址中的文件名(*.jpg / *.png) */
 function alipayImageIdFromUploadOnly(imageIdField: string): string {
   const s = String(imageIdField || "").trim();
   if (!s) return "";
+  if (/^https?:\/\//i.test(s)) {
+    return extractAlipayFilenameFromImageUrl(s) || "";
+  }
   const lower = s.toLowerCase();
   if (lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png")) return s;
   return "";

+ 8 - 3
src/views/dynamicManagement/publishDynamic.vue

@@ -162,8 +162,10 @@ async function finishPublishSimpleOverlay() {
   if (publishSimpleOverlayHadError) {
     overlay.dismiss();
   } else {
-    overlay.bumpToComplete();
-    await publishOverlaySleep(280);
+    if (overlay.show && overlay.percent < 100) {
+      overlay.completeSuccess();
+    }
+    await publishOverlaySleep(420);
     overlay.dismiss();
   }
 }
@@ -423,7 +425,10 @@ const uploadSingleFile = async (file: UploadFile) => {
 
   try {
     const isVideo = rawFile.type.startsWith("video/");
-    const urls = await uploadFilesToOss(rawFile, isVideo ? "video" : "image", { skipSimpleUploadOverlay: true });
+    const urls = await uploadFilesToOss(rawFile, isVideo ? "video" : "image", {
+      skipSimpleUploadOverlay: true,
+      maxVideoMb: DYNAMIC_VIDEO_MAX_MB
+    });
     const fileUrl = urls[0];
     if (!fileUrl) {
       ElMessage.error("上传失败,未返回文件地址");

+ 3 - 2
src/views/home/components/go-flow.vue

@@ -123,8 +123,9 @@
                   <el-radio label="20-50平米" value="2"> 20-50平米 </el-radio>
                   <el-radio label="50-100平米" value="3"> 50-100平米 </el-radio>
                   <el-radio label="100-300平米" value="4"> 100-300平米 </el-radio>
-                  <el-radio label="500-1000平米" value="5"> 500-1000平米 </el-radio>
-                  <el-radio label="大于1000平米" value="6"> 大于1000平米 </el-radio>
+                  <el-radio label="300-500平米" value="5"> 300-500平米 </el-radio>
+                  <el-radio label="500-1000平米" value="6"> 500-1000平米 </el-radio>
+                  <el-radio label="1000平米以上" value="7"> 1000平米以上 </el-radio>
                 </el-radio-group>
               </el-form-item>
 

+ 33 - 3
src/views/performance/edit.vue

@@ -44,7 +44,8 @@
               <div class="upload-wrap">
                 <UploadImgs
                   v-model:file-list="posterFileList"
-                  :api="uploadImgStore"
+                  :api="handlePerformanceImageUpload"
+                  :file-type="PERFORMANCE_IMAGE_FILE_TYPES"
                   :limit="1"
                   :file-size="20"
                   :disabled="viewMode"
@@ -194,7 +195,8 @@
               <div class="upload-wrap">
                 <UploadImgs
                   v-model:file-list="detailImageFileList"
-                  :api="uploadImgStore"
+                  :api="handlePerformanceImageUpload"
+                  :file-type="PERFORMANCE_IMAGE_FILE_TYPES"
                   :limit="9"
                   :file-size="20"
                   :disabled="viewMode"
@@ -294,7 +296,10 @@ import { ElMessage } from "element-plus";
 import { User, Close, ArrowLeft } from "@element-plus/icons-vue";
 import type { FormInstance, FormRules, UploadUserFile } from "element-plus";
 import UploadImgs from "@/components/Upload/Imgs.vue";
-import { uploadImgStore } from "@/api/modules/upload";
+import { uploadFilesToOss } from "@/api/upload.js";
+
+/** 演出编辑仅图片(UploadImgs 默认 fileType 含 video/*) */
+const PERFORMANCE_IMAGE_FILE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif"];
 import {
   getPerformanceDetail,
   saveOrUpdatePerformance,
@@ -477,6 +482,31 @@ async function loadDetail() {
   } catch (e) {}
 }
 
+/** OSS 直传,仅图片(UploadImgs 读取 response.fileUrl) */
+const handlePerformanceImageUpload = async (formData: FormData, options?: Record<string, unknown>): Promise<any> => {
+  const raw = formData.get("file");
+  const file = raw instanceof File ? raw : null;
+  if (!file) {
+    throw new Error("请选择文件");
+  }
+  const mime = typeof file.type === "string" ? file.type : "";
+  if (mime.startsWith("video/") || /\.(mp4|m4v|webm|ogg|mov|avi)(\?.*)?$/i.test(file.name || "")) {
+    throw new Error("仅支持上传图片");
+  }
+  const urls = await uploadFilesToOss(file, "image", options ?? {});
+  const fileUrl = urls[0];
+  if (!fileUrl) {
+    throw new Error("上传失败,未返回地址");
+  }
+  return {
+    fileUrl,
+    data: { fileUrl },
+    code: 200,
+    success: true,
+    msg: "操作成功"
+  };
+};
+
 function syncPosterAndDetailFiles() {
   const poster = form.value.posterUrls?.[0] ?? form.value.imgUrl;
   posterFileList.value = poster ? ([{ name: "poster", url: poster }] as UploadUserFile[]) : [];

+ 10 - 2
src/views/priceList/edit.vue

@@ -44,6 +44,7 @@
               <UploadImgs
                 v-model:file-list="imageFileList"
                 :api="handleCustomImageUpload"
+                :file-type="PRICE_LIST_IMAGE_FILE_TYPES"
                 :limit="9"
                 :file-size="20"
                 :width="'100px'"
@@ -241,6 +242,7 @@
               <UploadImgs
                 v-model:file-list="detailImageFileList"
                 :api="handleCustomImageUpload"
+                :file-type="PRICE_LIST_IMAGE_FILE_TYPES"
                 :limit="9"
                 :file-size="20"
                 :width="'100px'"
@@ -377,6 +379,9 @@ import { localGet } from "@/utils";
 import UploadImgs from "@/components/Upload/Imgs.vue";
 import { uploadFilesToOss } from "@/api/upload.js";
 
+/** 价目表仅图片,不含视频(UploadImgs 默认 fileType 含 video/*) */
+const PRICE_LIST_IMAGE_FILE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif"];
+
 const router = useRouter();
 const route = useRoute();
 const ruleFormRef = ref<FormInstance>();
@@ -557,8 +562,11 @@ const handleCustomImageUpload = async (formData: FormData, options?: Record<stri
   if (!file) {
     throw new Error("请选择文件");
   }
-  const isVideo = typeof file.type === "string" && file.type.startsWith("video/");
-  const urls = await uploadFilesToOss(file, isVideo ? "video" : "image", options ?? {});
+  const mime = typeof file.type === "string" ? file.type : "";
+  if (mime.startsWith("video/") || /\.(mp4|m4v|webm|ogg|mov|avi)(\?.*)?$/i.test(file.name || "")) {
+    throw new Error("仅支持上传图片");
+  }
+  const urls = await uploadFilesToOss(file, "image", options ?? {});
   const fileUrl = urls[0];
   if (!fileUrl) {
     throw new Error("上传失败,未返回地址");

+ 191 - 27
src/views/storeDecoration/add.vue

@@ -76,13 +76,40 @@
             :on-remove="handleRemove"
             :on-exceed="handleExceed"
             :before-upload="beforeUpload"
-            :http-request="handleImageUpload"
-            accept="image/*"
+            :http-request="handleAttachmentUpload"
+            accept="image/jpeg,image/png,image/gif,image/webp,image/bmp,video/mp4,video/quicktime,video/webm,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.webm,.m4v"
             multiple
           >
+            <template #file="{ file }">
+              <div class="attachment-upload-item">
+                <video
+                  v-if="isVideoUploadFile(file) && file.url"
+                  :src="file.url"
+                  :poster="getAttachmentVideoPoster(file)"
+                  class="attachment-upload-thumb"
+                  muted
+                  preload="metadata"
+                  playsinline
+                />
+                <img v-else-if="file.url" class="attachment-upload-thumb" :src="file.url" alt="" />
+                <div v-else class="attachment-upload-placeholder">
+                  <el-icon><VideoPlay v-if="isVideoUploadFile(file)" /><Picture v-else /></el-icon>
+                </div>
+                <div class="attachment-upload-handle" @click.stop>
+                  <div class="attachment-upload-handle-icon" @click="handlePictureCardPreview(file)">
+                    <el-icon><ZoomIn /></el-icon>
+                    <span>查看</span>
+                  </div>
+                  <div class="attachment-upload-handle-icon" @click="handleAttachmentRemove(file)">
+                    <el-icon><Delete /></el-icon>
+                    <span>删除</span>
+                  </div>
+                </div>
+              </div>
+            </template>
             <el-icon><Plus /></el-icon>
           </el-upload>
-          <div class="upload-tip">({{ fileList.length }}/9)</div>
+          <div class="upload-tip">支持图片或视频 ({{ fileList.length }}/9),图片单文件不超过 20MB,视频单文件不超过 200MB</div>
         </el-form-item>
 
         <!-- 联系人 -->
@@ -194,6 +221,8 @@
       :initial-index="imageViewerInitialIndex"
     />
 
+    <PcVideoPreviewDialog v-model="videoPreviewVisible" :src="videoPreviewUrl" title="查看视频" />
+
     <!-- 用户服务协议弹窗 -->
     <el-dialog v-model="agreementDialogVisible" title="用户服务协议" width="800px" :close-on-click-modal="false">
       <div class="agreement-content">
@@ -558,12 +587,13 @@ import {
   type UploadRequestOptions,
   type UploadUserFile
 } from "element-plus";
-import { Plus, ArrowDown } from "@element-plus/icons-vue";
+import { Plus, ArrowDown, Delete, ZoomIn, VideoPlay, Picture } from "@element-plus/icons-vue";
 import { saveOrUpdateDecoration, getDistrict } from "@/api/modules/storeDecoration";
 import { uploadFileToOss } from "@/api/upload.js";
 import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
 import { localGet } from "@/utils";
 import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
+import PcVideoPreviewDialog from "@/components/pcMediaPreview/PcVideoPreviewDialog.vue";
 
 const route = useRoute();
 const router = useRouter();
@@ -605,8 +635,10 @@ async function finishBatchUploadOverlay() {
   if (batchHadUploadError) {
     overlay.dismiss();
   } else {
-    overlay.bumpToComplete();
-    await overlaySleep(280);
+    if (overlay.show && overlay.percent < 100) {
+      overlay.completeSuccess();
+    }
+    await overlaySleep(420);
     overlay.dismiss();
   }
 }
@@ -614,6 +646,49 @@ async function finishBatchUploadOverlay() {
 const imageViewerVisible = ref(false);
 const imageViewerUrlList = ref<string[]>([]);
 const imageViewerInitialIndex = ref(0);
+const videoPreviewVisible = ref(false);
+const videoPreviewUrl = ref("");
+
+const ATTACHMENT_IMAGE_MAX_MB = 20;
+const ATTACHMENT_VIDEO_MAX_MB = 200;
+
+function isVideoUploadFile(file: UploadFile) {
+  const resp = file.response as { isVideo?: boolean } | undefined;
+  if (resp?.isVideo) return true;
+  const raw = file.raw as File | undefined;
+  if (raw && String(raw.type || "").startsWith("video/")) return true;
+  const url = String(file.url || "");
+  return /\.(mp4|mov|m4v|webm|3gp|ogg|avi)(\?|#|$)/i.test(url);
+}
+
+function isImageUploadFile(file: File) {
+  const mime = String(file.type || "").toLowerCase();
+  if (mime.startsWith("image/")) return true;
+  return /\.(jpe?g|png|gif|webp|bmp)(\?.*)?$/i.test(file.name || "");
+}
+
+function isVideoUploadRawFile(file: File) {
+  const mime = String(file.type || "").toLowerCase();
+  if (mime.startsWith("video/")) return true;
+  return /\.(mp4|m4v|webm|ogg|mov|avi|3gp)(\?.*)?$/i.test(file.name || "");
+}
+
+/** OSS 视频首帧封面(列表缩略图) */
+function ossVideoSnapshotPosterUrl(videoUrl: string): string {
+  const u = String(videoUrl || "").trim();
+  if (!u || /^blob:/i.test(u) || /^data:/i.test(u)) return "";
+  if (!/\.(mp4|mov|m4v|webm|3gp)(\?|#|$)/i.test(u)) return "";
+  const sep = u.includes("?") ? "&" : "?";
+  return `${u}${sep}x-oss-process=video/snapshot,t_0,f_jpg,w_400,h_400,m_fast`;
+}
+
+function getAttachmentVideoPoster(file: UploadFile): string | undefined {
+  const cover = (file as UploadFile & { coverUrl?: string }).coverUrl;
+  if (cover) return cover;
+  const url = String(file.url || "");
+  const poster = ossVideoSnapshotPosterUrl(url);
+  return poster || undefined;
+}
 
 // 协议和隐私政策弹窗
 const agreementDialogVisible = ref(false);
@@ -794,24 +869,32 @@ const handleCityClear = () => {
   districtOptions.value = [];
 };
 
-// 图片上传前验证
+// 房屋图纸上传前验证(图片 / 视频)
 const beforeUpload: UploadProps["beforeUpload"] = (rawFile: File) => {
-  const isImage = rawFile.type.startsWith("image/");
-  const isLt20M = rawFile.size / 1024 / 1024 < 20;
+  const isVideo = isVideoUploadRawFile(rawFile);
+  const isImage = isImageUploadFile(rawFile);
 
-  if (!isImage) {
-    ElMessage.error("只能上传图片文件!");
+  if (!isImage && !isVideo) {
+    ElMessage.error("仅支持上传图片或视频文件");
     return false;
   }
-  if (!isLt20M) {
-    ElMessage.error("图片大小不能超过 20MB!");
+  const sizeMb = rawFile.size / 1024 / 1024;
+  if (isVideo) {
+    if (sizeMb > ATTACHMENT_VIDEO_MAX_MB) {
+      ElMessage.error(`视频大小不能超过 ${ATTACHMENT_VIDEO_MAX_MB}MB`);
+      return false;
+    }
+    return true;
+  }
+  if (sizeMb > ATTACHMENT_IMAGE_MAX_MB) {
+    ElMessage.error(`图片大小不能超过 ${ATTACHMENT_IMAGE_MAX_MB}MB`);
     return false;
   }
   return true;
 };
 
-// 图片上传:OSS 直传(@/api/upload.js);多图一次 loading、请求串行
-const handleImageUpload = (options: UploadRequestOptions): Promise<void> => {
+// 附件上传:OSS 直传(@/api/upload.js);多文件一次 loading、请求串行
+const handleAttachmentUpload = (options: UploadRequestOptions): Promise<void> => {
   const uploadFileItem = options.file as UploadUserFile;
   const raw = uploadFileItem.raw || uploadFileItem;
   const file = raw instanceof File ? raw : null;
@@ -832,17 +915,29 @@ const handleImageUpload = (options: UploadRequestOptions): Promise<void> => {
   const runOne = async (): Promise<void> => {
     uploadFileItem.status = "uploading";
     uploadFileItem.percentage = 0;
+    if (isVideoUploadRawFile(file) && !uploadFileItem.url) {
+      uploadFileItem.url = URL.createObjectURL(file);
+    }
 
     try {
-      const fileUrl = await uploadFileToOss(file, "image", { skipSimpleUploadOverlay: true as const });
+      const isVideo = isVideoUploadRawFile(file);
+      const fileUrl = await uploadFileToOss(file, isVideo ? "video" : "image", {
+        skipSimpleUploadOverlay: true as const
+      });
       if (!fileUrl) {
         throw new Error("上传失败,未返回文件地址");
       }
 
       uploadFileItem.status = "success";
       uploadFileItem.percentage = 100;
+      if (uploadFileItem.url && uploadFileItem.url.startsWith("blob:")) {
+        URL.revokeObjectURL(uploadFileItem.url);
+      }
       uploadFileItem.url = fileUrl;
-      uploadFileItem.response = { url: fileUrl };
+      uploadFileItem.response = { url: fileUrl, isVideo };
+      if (isVideo) {
+        (uploadFileItem as UploadUserFile & { coverUrl?: string }).coverUrl = ossVideoSnapshotPosterUrl(fileUrl);
+      }
 
       if (!formData.attachmentUrls.includes(fileUrl)) {
         formData.attachmentUrls.push(fileUrl);
@@ -880,32 +975,43 @@ const handleImageUpload = (options: UploadRequestOptions): Promise<void> => {
   return p;
 };
 
-// 图片预览
+// 附件预览(图片 / 视频)
 const handlePictureCardPreview = (file: UploadFile) => {
-  if (file.url) {
-    imageViewerUrlList.value = fileList.value.map((item: UploadFile) => item.url || "").filter(Boolean);
-    imageViewerInitialIndex.value = fileList.value.findIndex((item: UploadFile) => item.uid === file.uid);
-    imageViewerVisible.value = true;
+  if (!file.url) return;
+  if (isVideoUploadFile(file)) {
+    videoPreviewUrl.value = file.url;
+    videoPreviewVisible.value = true;
+    return;
+  }
+  const imageFiles = fileList.value.filter(item => item.url && !isVideoUploadFile(item));
+  imageViewerUrlList.value = imageFiles.map(item => item.url || "");
+  imageViewerInitialIndex.value = imageFiles.findIndex(item => item.uid === file.uid);
+  if (imageViewerInitialIndex.value < 0) imageViewerInitialIndex.value = 0;
+  imageViewerVisible.value = true;
+};
+
+const handleAttachmentRemove = (file: UploadFile) => {
+  const index = fileList.value.findIndex(f => f.uid === file.uid);
+  if (index > -1) {
+    fileList.value.splice(index, 1);
   }
+  handleRemove(file);
 };
 
-// 删除图片
+// 删除附件
 const handleRemove = (file: UploadFile) => {
   if (file.url) {
     const index = formData.attachmentUrls.indexOf(file.url);
     if (index > -1) {
       formData.attachmentUrls.splice(index, 1);
-      console.log("删除图片,路径已移除:", file.url);
-      console.log("当前附件列表:", formData.attachmentUrls);
     }
   }
-  // 触发表单验证
   formRef.value?.validateField("attachmentUrls");
 };
 
 // 超出限制
 const handleExceed = () => {
-  ElMessage.warning("最多只能上传9张图片");
+  ElMessage.warning("最多只能上传 9 个文件");
 };
 
 // 显示服务协议
@@ -1032,6 +1138,64 @@ onMounted(() => {
   :deep(.el-upload-list--picture-card .el-upload-list__item) {
     width: 100px;
     height: 100px;
+    overflow: hidden;
+  }
+  .attachment-upload-item {
+    position: relative;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+  }
+  .attachment-upload-thumb {
+    display: block;
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    background: #f0f2f5;
+  }
+  .attachment-upload-placeholder {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+    color: #909399;
+    background: #f0f2f5;
+    .el-icon {
+      font-size: 28px;
+    }
+  }
+  .attachment-upload-handle {
+    position: absolute;
+    inset: 0;
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    justify-content: center;
+    cursor: default;
+    background: rgb(0 0 0 / 55%);
+    opacity: 0;
+    transition: opacity 0.2s;
+  }
+  .attachment-upload-item:hover .attachment-upload-handle {
+    opacity: 1;
+  }
+  .attachment-upload-handle-icon {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 0 6px;
+    color: #ffffff;
+    cursor: pointer;
+    .el-icon {
+      font-size: 18px;
+    }
+    span {
+      margin-top: 4px;
+      font-size: 12px;
+      line-height: 1;
+    }
   }
   .city-selector {
     padding: 10px 0;

+ 3 - 3
src/views/storeDecoration/basicStoreInformation/index.vue

@@ -28,7 +28,7 @@
               <el-radio :value="4"> 100-300平米 </el-radio>
               <el-radio :value="5"> 300-500平米 </el-radio>
               <el-radio :value="6"> 500-1000平米 </el-radio>
-              <el-radio :value="7"> 大于1000平米 </el-radio>
+              <el-radio :value="7"> 1000平米以上 </el-radio>
             </el-radio-group>
           </el-form-item>
 
@@ -1062,7 +1062,7 @@ const storeAreaMap: Record<string, number> = {
   "100-300平米": 4,
   "300-500平米": 5,
   "500-1000平米": 6,
-  大于1000平米: 7
+  "1000平米以上": 7
 };
 
 // 门店面积反向映射:数字 -> 字符串
@@ -1073,7 +1073,7 @@ const storeAreaReverseMap: Record<number, string> = {
   4: "100-300平米",
   5: "300-500平米",
   6: "500-1000平米",
-  7: "大于1000平米"
+  7: "1000平米以上"
 };
 
 // 营业状态映射:字符串 -> 数字

+ 1 - 1
src/views/storeDecoration/officialPhotoAlbum/index.vue

@@ -262,7 +262,7 @@ const validateImageDimensions = (file: File): Promise<boolean> => {
   });
 };
 
-// 自定义图片上传:`upload/simple` 成功且响应含 download_url 后再交给 onSuccess 里保存相册
+// 自定义图片上传:GET /upload/oss/sts-token 后直传 OSS,再用返回 URL 保存相册
 const customImageUploadApi = async (formData: FormData, options?: Record<string, unknown>): Promise<any> => {
   const raw = formData.get("file");
   const file = raw instanceof File ? raw : null;

+ 2 - 1
src/views/storeDecoration/storeCoverMap/index.vue

@@ -389,7 +389,8 @@ async function uploadOneToDraft(file: File, isVideo: boolean): Promise<DraftCove
   uploading.value = true;
   try {
     const urls = await uploadFilesToOss([file], isVideo ? "video" : "image", {
-      uploadSuccessMessage: null
+      uploadSuccessMessage: null,
+      maxVideoMb: VIDEO_MAX_MB
     });
     const url = urls[0];
     if (!url) throw new Error("上传成功但未返回地址");

Diferenças do arquivo suprimidas por serem muito extensas
+ 656 - 8
yarn.lock


Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff