Jelajahi Sumber

fix: 设置接口的时长,避免上传视频超时报错,设置收款账号也改成oss上传,门店装修可以上传视频

sgc 3 minggu lalu
induk
melakukan
da0f9da188

+ 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 } : {})
     };

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

@@ -4,13 +4,18 @@ 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
+  });
 };
 
 /** POST,storeId 走 URL 查询参数,请求体为 JSON */
@@ -32,7 +37,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;

+ 169 - 20
src/api/upload.js

@@ -17,11 +17,99 @@ 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 */
+/** 商户 Web 端上传统一上限:图 20MB、视频 500MB(与门店封面、发布动态一致) */
 const WEB_UPLOAD_IMAGE_MAX_BYTES = 20 * 1024 * 1024;
-const WEB_UPLOAD_VIDEO_MAX_BYTES = 200 * 1024 * 1024;
+const WEB_UPLOAD_VIDEO_MAX_BYTES = 500 * 1024 * 1024;
 const WEB_UPLOAD_TIP_IMAGE = "图片建议不超过 20MB";
-const WEB_UPLOAD_TIP_VIDEO = "视频建议不超过 200MB";
+const WEB_UPLOAD_TIP_VIDEO = "视频建议不超过 500MB";
+
+/** 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 {File[]} fileArr
@@ -340,13 +428,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";
@@ -510,12 +616,15 @@ async function fetchOssStsToken(fetchOptions = {}) {
       method: "GET",
       headers,
       credentials: "omit",
-      signal: fetchOptions.signal ?? undefined
+      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 签名失败");
   }
@@ -580,6 +689,7 @@ 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,
@@ -587,11 +697,24 @@ async function putFileToOssWithSts(file, fetchOptions = {}) {
     stsToken: sts.credentials.securityToken,
     bucket: sts.bucket,
     endpoint,
-    secure: true
-  });
-  const result = await client.put(objectKey, file, {
-    mime: file.type || undefined
+    secure: true,
+    timeout: putTimeoutMs
   });
+  let result;
+  try {
+    result = await client.put(objectKey, file, {
+      mime: file.type || undefined,
+      timeout: putTimeoutMs
+    });
+  } 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 };
@@ -645,12 +768,15 @@ async function requestOssFinalizeAudit(file, objectKey, fetchOptions = {}) {
         content_type: resolveFileContentType(file),
         size: Number(file?.size) || 0
       }),
-      signal: fetchOptions.signal ?? undefined
+      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("内容审核请求失败");
   }
@@ -708,14 +834,26 @@ async function putFileToOssWithStsAndFinalize(file, fetchOptions = {}) {
   return { fileUrl: uploaded.fileUrl, downloadUrl };
 }
 
+/** 仅 STS 直传 OSS,不走 /upload/oss/finalize(证照等由页面单独 OCR 审核) */
+async function putFileToOssWithStsOnly(file, fetchOptions = {}) {
+  const uploaded = await putFileToOssWithSts(file, fetchOptions);
+  return { fileUrl: uploaded.fileUrl, downloadUrl: uploaded.downloadUrl };
+}
+
 /**
  * 单文件 OSS + 审核,带可选全局上传弹层
  * @param {File} file
- * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean; uploadSuccessMessage?: string | null; uploadOverlayTitle?: string }} [options]
+ * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean; skipFinalize?: boolean; uploadSuccessMessage?: string | null; uploadOverlayTitle?: string }} [options]
  * @returns {Promise<{ fileUrl: string; downloadUrl: string }>}
  */
 async function uploadSingleFileWithOssOverlay(file, options = {}) {
-  const { showLoading = false, skipSimpleUploadOverlay = false, uploadSuccessMessage, uploadOverlayTitle } = options;
+  const {
+    showLoading = false,
+    skipSimpleUploadOverlay = false,
+    skipFinalize = false,
+    uploadSuccessMessage,
+    uploadOverlayTitle
+  } = options;
   if (!(file instanceof File)) {
     throw new Error("请选择要上传的文件");
   }
@@ -727,7 +865,10 @@ async function uploadSingleFileWithOssOverlay(file, options = {}) {
   }
 
   try {
-    const runUpload = async signal => putFileToOssWithStsAndFinalize(file, signal ? { signal } : {});
+    const runUpload = async signal => {
+      const fetchOpts = signal ? { signal } : {};
+      return skipFinalize ? putFileToOssWithStsOnly(file, fetchOpts) : putFileToOssWithStsAndFinalize(file, fetchOpts);
+    };
 
     let result;
     if (skipSimpleUploadOverlay) {
@@ -752,7 +893,7 @@ async function uploadSingleFileWithOssOverlay(file, options = {}) {
     if (isUploadUserCancelledError(e)) {
       throw e;
     }
-    const msg = e?.message || "上传失败";
+    const msg = (isUploadTimeoutError(e) ? "上传超时,请检查网络后重试" : "") || e?.message || "上传失败";
     ElMessage.error(msg);
     try {
       if (e && typeof e === "object") {
@@ -903,11 +1044,17 @@ async function postFileToSimpleUpload(file, fetchOptions = {}) {
  * 上传文件: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]
+ * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean; skipFinalize?: boolean; 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,
+    uploadSuccessMessage,
+    uploadOverlayTitle
+  } = options;
   const fileArr = normalizeFiles(files);
   if (fileArr.length === 0) {
     throw new Error("请选择要上传的文件");
@@ -925,7 +1072,9 @@ export async function uploadFilesToOss(files, _fileType, options = {}) {
       const uploadedUrls = [];
       const fetchOpts = signal ? { signal } : {};
       for (const file of fileArr) {
-        const { fileUrl, downloadUrl } = await putFileToOssWithStsAndFinalize(file, fetchOpts);
+        const { fileUrl, downloadUrl } = skipFinalize
+          ? await putFileToOssWithStsOnly(file, fetchOpts)
+          : await putFileToOssWithStsAndFinalize(file, fetchOpts);
         uploadedUrls.push(downloadUrl || fileUrl);
       }
       return uploadedUrls;
@@ -954,7 +1103,7 @@ export async function uploadFilesToOss(files, _fileType, options = {}) {
     if (isUploadUserCancelledError(e)) {
       throw e;
     }
-    const msg = e?.message || "上传失败";
+    const msg = (isUploadTimeoutError(e) ? "上传超时,请检查网络后重试" : "") || e?.message || "上传失败";
     ElMessage.error(msg);
     try {
       if (e && typeof e === "object") {

+ 2 - 2
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 时生效;默认 500(与 upload.js 一致) */
   videoFileSize?: number;
   fileType?: string[]; // 接受的 MIME(图片 + 可选视频)==> 非必传
   height?: string; // 组件高度 ==> 非必传(默认为 150px)
@@ -97,7 +97,7 @@ const props = withDefaults(defineProps<UploadFileProps>(), {
   disabled: false,
   limit: 5,
   fileSize: 20,
-  videoFileSize: 200,
+  videoFileSize: 500,
   fileType: () => ["image/jpeg", "image/png", "image/gif", "video/mp4", "video/webm", "video/quicktime", "video/ogg"],
   height: "150px",
   width: "150px",

+ 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"
 }
 

File diff ditekan karena terlalu besar
+ 0 - 0
src/utils/businessInfoImageUpload.ts


+ 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;
       }
     }

+ 92 - 90
src/views/businessInfo/subjectInfo.vue

@@ -221,7 +221,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 { getOcrRequestByBase64, getUpload } from "@/api/modules/businessInfo";
+import { getOcrRequestByBase64 } from "@/api/modules/businessInfo";
+import {
+  uploadBusinessInfoImageWithPageOcr,
+  filterOutUploadUserFileByUid,
+  failBusinessInfoUploadCleanup
+} from "@/utils/businessInfoImageUpload";
 import { localGet, localSet } from "@/utils/index";
 
 const BUSINESS_DATA_CACHE_KEY = "businessData";
@@ -640,25 +645,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;
@@ -683,11 +669,10 @@ async function requestIdCardPortraitOcr(file: File) {
     if (res?.code === 200 || res?.code === "200") {
       const fields = pickIdCardPortraitOcrFields(res);
       mergeIdCardOcrFromFields(fields, "portrait");
-      ElMessage.success("身份证人像面识别成功");
-    } else {
-      clearIdPortraitOcr();
-      ElMessage.warning(res?.msg || "身份证人像面识别未通过,请核对照片清晰度");
+      return;
     }
+    clearIdPortraitOcr();
+    throw new Error(String(res?.msg || "身份证人像面识别未通过,请核对照片清晰度"));
   } finally {
     isIdPortraitOcrProcessing.value = false;
   }
@@ -704,14 +689,12 @@ async function requestIdCardEmblemOcr(file: File) {
     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;
   }
@@ -727,45 +710,47 @@ async function requestBusinessLicenseOcr(file: File) {
     businessLicenseOcr.creditCode = fields.creditCode;
     businessLicenseOcr.companyName = fields.companyName;
     businessLicenseOcr.legalPerson = fields.legalPerson;
-    ElMessage.success("营业执照识别成功");
-  } else {
-    clearBusinessLicenseOcr();
-    ElMessage.warning(res?.msg || "营业执照识别未通过,请核对照片清晰度");
+    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 uploadBusinessInfoImageWithPageOcr(file, requestBusinessLicenseOcr, {
+      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 +770,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 runOcr = side === "portrait" ? requestIdCardPortraitOcr : requestIdCardEmblemOcr;
+    const { fileUrl, mediaId } = await uploadBusinessInfoImageWithPageOcr(file, runOcr, {
+      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 "";

+ 187 - 25
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,视频单文件不超过 500MB</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();
@@ -614,6 +644,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 = 500;
+
+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 +867,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 +913,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 +973,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 +1136,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;

+ 2 - 2
src/views/storeDecoration/personnelConfig/index.vue

@@ -278,7 +278,7 @@
               v-model:file-list="formData.backgroundImages"
               :limit="9"
               :file-size="20"
-              :video-file-size="200"
+              :video-file-size="500"
               :file-type="['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4']"
               :width="'150px'"
               :height="'150px'"
@@ -291,7 +291,7 @@
             >
               <template #tip>
                 <div class="upload-tip">
-                  上传图片或视频 ({{ formData.backgroundImages.length }}/9),图片单文件不超过 20M,视频单文件不超过 200M
+                  上传图片或视频 ({{ formData.backgroundImages.length }}/9),图片单文件不超过 20M,视频单文件不超过 500M
                 </div>
               </template>
             </UploadImgs>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini