瀏覽代碼

上传loading

zhuli 9 小時之前
父節點
當前提交
7a57f8b481

+ 7 - 3
src/api/modules/upload.ts

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

+ 102 - 53
src/components/Upload/Imgs.vue

@@ -68,11 +68,13 @@ import { ref, computed, inject, watch, nextTick } from "vue";
 import { Plus, Picture } from "@element-plus/icons-vue";
 import { uploadImg } from "@/api/modules/upload";
 import type { UploadProps, UploadFile, UploadUserFile, UploadRequestOptions } from "element-plus";
-import { ElNotification, formContextKey, formItemContextKey } from "element-plus";
+import { ElMessage, ElNotification, formContextKey, formItemContextKey } from "element-plus";
+import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
 
 interface UploadFileProps {
   fileList: UploadUserFile[];
-  api?: (params: any) => Promise<any>; // 上传图片的 api 方法,一般项目上传都是同一个 api 方法,在组件里直接引入即可 ==> 非必传
+  /** 上传 api;第二参为透传选项(如 skipSimpleUploadOverlay),与默认 uploadImg 一致 */
+  api?: (params: FormData, options?: Record<string, unknown>) => Promise<any>;
   drag?: boolean; // 是否支持拖拽上传 ==> 非必传(默认为 true)
   disabled?: boolean; // 是否禁用上传组件 ==> 非必传(默认为 false)
   limit?: number; // 最大图片上传数 ==> 非必传(默认为 5张)
@@ -151,6 +153,34 @@ watch(
 const uploadingFiles = new Set<string | number>();
 // 标记是否已经显示过成功提示,防止重复提示
 let hasShownSuccessNotification = false;
+
+/**
+ * 多选时 el-upload 会对每个文件并发触发 http-request,易导致 /upload/simple 并发报错。
+ * 通过 Promise 链串行化,同一时间只发起一次上传请求。
+ */
+let sequentialUploadTail = Promise.resolve();
+
+/** 当前这一批(一次多选)里待上传的文件数;归零时关闭全局上传弹层 */
+let pendingBatchUploadCount = 0;
+let batchHadUploadError = false;
+
+function sleep(ms: number) {
+  return new Promise<void>(resolve => setTimeout(resolve, ms));
+}
+
+async function finishBatchUploadOverlay() {
+  const overlay = useSimpleUploadOverlayStore();
+  if (batchHadUploadError) {
+    overlay.dismiss();
+  } else {
+    overlay.bumpToComplete();
+    await sleep(280);
+    overlay.dismiss();
+    if (props.showSuccessNotification) {
+      ElMessage.success("上传成功");
+    }
+  }
+}
 // 记录图片加载失败的 UID
 const imageLoadError = ref<Set<string | number>>(new Set());
 
@@ -196,70 +226,89 @@ const beforeUpload: UploadProps["beforeUpload"] = rawFile => {
  * @description 图片上传
  * @param options upload 所有配置项
  * */
-const handleHttpUpload = async (options: UploadRequestOptions) => {
-  // 开始上传,记录文件 UID
+const handleHttpUpload = (options: UploadRequestOptions): Promise<unknown> => {
   const fileUid = options.file.uid;
-  // 如果这是新的一批上传(Set 为空),重置成功提示标志
   if (uploadingFiles.size === 0) {
     hasShownSuccessNotification = false;
   }
   uploadingFiles.add(fileUid);
-  let formData = new FormData();
-  formData.append("file", options.file);
-  try {
-    const api = props.api ?? uploadImg;
-    const response = await api(formData);
-    // 从 response.fileUrl / response.data.fileUrl 取值
-    const fileUrl = response?.fileUrl || response?.data?.fileUrl || "";
-
-    if (fileUrl) {
-      const coverUrlRaw =
-        (response as any)?.coverUrl ?? (response as any)?.data?.coverUrl ?? (response as any)?.data?.cover ?? "";
-      const coverUrl = typeof coverUrlRaw === "string" && coverUrlRaw.trim() ? coverUrlRaw.trim() : "";
-
-      // 更新 options.file(Element Plus 传入的文件对象)
-      (options.file as unknown as UploadFile).url = fileUrl;
-      (options.file as unknown as UploadFile).status = "success";
-      (options.file as unknown as UploadFile).response = response;
-      if (coverUrl) {
-        (options.file as any).coverUrl = coverUrl;
-      }
 
-      // 同步更新 _fileList 中对应的文件
-      const fileIndex = _fileList.value.findIndex(item => item.uid === fileUid);
-      if (fileIndex !== -1) {
-        _fileList.value[fileIndex].url = fileUrl;
-        _fileList.value[fileIndex].status = "success";
-        (_fileList.value[fileIndex] as any).response = response;
+  if (pendingBatchUploadCount === 0) {
+    batchHadUploadError = false;
+  }
+  pendingBatchUploadCount++;
+  if (pendingBatchUploadCount === 1) {
+    useSimpleUploadOverlayStore().beginUpload();
+  }
+
+  const runOne = async (): Promise<void> => {
+    const formData = new FormData();
+    formData.append("file", options.file);
+    const simpleOpts = { skipSimpleUploadOverlay: true as const };
+    try {
+      const response = props.api ? await props.api(formData, simpleOpts) : await uploadImg(formData, simpleOpts);
+      const fileUrl = response?.fileUrl || response?.data?.fileUrl || "";
+
+      if (fileUrl) {
+        const coverUrlRaw =
+          (response as any)?.coverUrl ?? (response as any)?.data?.coverUrl ?? (response as any)?.data?.cover ?? "";
+        const coverUrl = typeof coverUrlRaw === "string" && coverUrlRaw.trim() ? coverUrlRaw.trim() : "";
+
+        (options.file as unknown as UploadFile).url = fileUrl;
+        (options.file as unknown as UploadFile).status = "success";
+        (options.file as unknown as UploadFile).response = response;
         if (coverUrl) {
-          (_fileList.value[fileIndex] as any).coverUrl = coverUrl;
+          (options.file as any).coverUrl = coverUrl;
+        }
+
+        const fileIndex = _fileList.value.findIndex(item => item.uid === fileUid);
+        if (fileIndex !== -1) {
+          _fileList.value[fileIndex].url = fileUrl;
+          _fileList.value[fileIndex].status = "success";
+          (_fileList.value[fileIndex] as any).response = response;
+          if (coverUrl) {
+            (_fileList.value[fileIndex] as any).coverUrl = coverUrl;
+          }
         }
       }
-    }
 
-    options.onSuccess(response);
-    // 传递更新后的 _fileList 给父组件
-    emit("update:fileList", _fileList.value);
-    if (props.onSuccess) {
-      try {
-        const result = props.onSuccess(fileUrl);
-        // 如果回调返回 Promise,等待它完成(但不影响上传成功状态)
-        if (result !== undefined && result !== null && typeof result === "object" && typeof (result as any).then === "function") {
-          (result as Promise<any>).catch((callbackError: any) => {
-            // 回调失败不影响上传成功状态,只记录错误
-            console.error("onSuccess callback error:", callbackError);
-          });
+      options.onSuccess(response);
+      emit("update:fileList", _fileList.value);
+      if (props.onSuccess) {
+        try {
+          const result = props.onSuccess(fileUrl);
+          if (
+            result !== undefined &&
+            result !== null &&
+            typeof result === "object" &&
+            typeof (result as any).then === "function"
+          ) {
+            (result as Promise<any>).catch((callbackError: any) => {
+              console.error("onSuccess callback error:", callbackError);
+            });
+          }
+        } catch (callbackError) {
+          console.error("onSuccess callback error:", callbackError);
         }
-      } catch (callbackError) {
-        // 回调失败不影响上传成功状态,只记录错误
-        console.error("onSuccess callback error:", callbackError);
+      }
+    } catch (error) {
+      batchHadUploadError = true;
+      uploadingFiles.delete(fileUid);
+      options.onError(error as any);
+      throw error;
+    } finally {
+      pendingBatchUploadCount--;
+      if (pendingBatchUploadCount === 0) {
+        await finishBatchUploadOverlay();
       }
     }
-  } catch (error) {
-    // 上传失败,移除文件 UID
-    uploadingFiles.delete(fileUid);
-    options.onError(error as any);
-  }
+  };
+
+  const p = sequentialUploadTail.then(runOne);
+  sequentialUploadTail = p.catch(() => {
+    /* 保持队列继续,不因单张失败阻断后续文件 */
+  });
+  return p;
 };
 
 /**

+ 41 - 1
src/views/dynamicManagement/publishDynamic.vue

@@ -134,6 +134,7 @@ import type { FormInstance, FormRules, UploadUserFile, UploadFile } from "elemen
 // import { addOrUpdateDynamic } from "@/api/modules/dynamicManagement";
 import { addOrUpdateDynamic } from "@/api/modules/newLoginApi";
 import { uploadFilesToOss } from "@/api/upload.js";
+import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
 import { useUserStore } from "@/stores/modules/user";
 import { getUserDraftDynamics, getInputPrompt } from "@/api/modules/newLoginApi";
 
@@ -141,6 +142,25 @@ const router = useRouter();
 const route = useRoute();
 const userStore = useUserStore();
 
+/** 多文件入队共用一个全局上传弹层;单次 OSS 请求带 skip,避免与 withSimpleUploadOverlay 重复 */
+let publishSimpleOverlayPending = 0;
+let publishSimpleOverlayHadError = false;
+
+function publishOverlaySleep(ms: number) {
+  return new Promise<void>(resolve => setTimeout(resolve, ms));
+}
+
+async function finishPublishSimpleOverlay() {
+  const overlay = useSimpleUploadOverlayStore();
+  if (publishSimpleOverlayHadError) {
+    overlay.dismiss();
+  } else {
+    overlay.bumpToComplete();
+    await publishOverlaySleep(280);
+    overlay.dismiss();
+  }
+}
+
 // 接口定义
 interface FormData {
   title: string;
@@ -346,6 +366,13 @@ const handleFileChange = (uploadFile: UploadFile, uploadFiles: UploadFile[]) =>
   if (readyFiles.length) {
     readyFiles.forEach(file => {
       if (!pendingUploadFiles.value.some(item => item.uid === file.uid)) {
+        if (publishSimpleOverlayPending === 0) {
+          publishSimpleOverlayHadError = false;
+        }
+        publishSimpleOverlayPending++;
+        if (publishSimpleOverlayPending === 1) {
+          useSimpleUploadOverlayStore().beginUpload();
+        }
         pendingUploadFiles.value.push(file as UploadFile);
       }
     });
@@ -368,6 +395,12 @@ const processUploadQueue = async () => {
 // 单个文件上传
 const uploadSingleFile = async (file: UploadFile) => {
   if (!file.raw) {
+    if (publishSimpleOverlayPending > 0) {
+      publishSimpleOverlayPending--;
+      if (publishSimpleOverlayPending === 0) {
+        await finishPublishSimpleOverlay();
+      }
+    }
     return;
   }
 
@@ -379,7 +412,7 @@ const uploadSingleFile = async (file: UploadFile) => {
 
   try {
     const isVideo = rawFile.type.startsWith("video/");
-    const urls = await uploadFilesToOss(rawFile, isVideo ? "video" : "image");
+    const urls = await uploadFilesToOss(rawFile, isVideo ? "video" : "image", { skipSimpleUploadOverlay: true });
     const fileUrl = urls[0];
     if (!fileUrl) {
       ElMessage.error("上传失败,未返回文件地址");
@@ -395,6 +428,7 @@ const uploadSingleFile = async (file: UploadFile) => {
       formData.images.push(fileUrl);
     }
   } catch (error: any) {
+    publishSimpleOverlayHadError = true;
     console.error("上传失败:", error);
     file.status = "fail";
     if (file.url && file.url.startsWith("blob:")) {
@@ -406,6 +440,12 @@ const uploadSingleFile = async (file: UploadFile) => {
     }
     // OSS 网络/签名错误已在 upload.js 中 ElMessage,避免重复弹窗
   } finally {
+    if (publishSimpleOverlayPending > 0) {
+      publishSimpleOverlayPending--;
+      if (publishSimpleOverlayPending === 0) {
+        await finishPublishSimpleOverlay();
+      }
+    }
     uploading.value = false;
     fileList.value = [...fileList.value];
   }

+ 3 - 3
src/views/priceList/edit.vue

@@ -550,15 +550,15 @@ function calcRecommendedPrice() {
   }
 }
 
-/** OSS 直传(UploadImgs 读取 response.fileUrl) */
-const handleCustomImageUpload = async (formData: FormData): Promise<any> => {
+/** OSS 直传(UploadImgs 读取 response.fileUrl);第二参由 UploadImgs 透传(如 skipSimpleUploadOverlay),多图时只走组件侧统一弹层 */
+const handleCustomImageUpload = 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 isVideo = typeof file.type === "string" && file.type.startsWith("video/");
-  const urls = await uploadFilesToOss(file, isVideo ? "video" : "image");
+  const urls = await uploadFilesToOss(file, isVideo ? "video" : "image", options ?? {});
   const fileUrl = urls[0];
   if (!fileUrl) {
     throw new Error("上传失败,未返回地址");

+ 78 - 34
src/views/storeDecoration/add.vue

@@ -562,6 +562,7 @@ import {
 import { Plus, ArrowDown } 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";
 
 const route = useRoute();
@@ -589,6 +590,27 @@ const formData = reactive({
 });
 
 const fileList = ref<UploadFile[]>([]);
+
+/** 多选时 el-upload 会并发触发 http-request;串行化 + 整批共用一个全局上传弹层 */
+let sequentialUploadTail = Promise.resolve();
+let pendingBatchUploadCount = 0;
+let batchHadUploadError = false;
+
+function overlaySleep(ms: number) {
+  return new Promise<void>(resolve => setTimeout(resolve, ms));
+}
+
+async function finishBatchUploadOverlay() {
+  const overlay = useSimpleUploadOverlayStore();
+  if (batchHadUploadError) {
+    overlay.dismiss();
+  } else {
+    overlay.bumpToComplete();
+    await overlaySleep(280);
+    overlay.dismiss();
+  }
+}
+
 const imageViewerVisible = ref(false);
 const imageViewerUrlList = ref<string[]>([]);
 const imageViewerInitialIndex = ref(0);
@@ -788,52 +810,74 @@ const beforeUpload: UploadProps["beforeUpload"] = (rawFile: File) => {
   return true;
 };
 
-// 图片上传:OSS 直传(@/api/upload.js)
-const handleImageUpload = async (options: UploadRequestOptions) => {
+// 图片上传:OSS 直传(@/api/upload.js);多图一次 loading、请求串行
+const handleImageUpload = (options: UploadRequestOptions): Promise<void> => {
   const uploadFileItem = options.file as UploadUserFile;
   const raw = uploadFileItem.raw || uploadFileItem;
   const file = raw instanceof File ? raw : null;
 
   if (!file) {
     ElMessage.error("文件对象不存在");
-    return;
+    return Promise.resolve();
   }
 
-  uploadFileItem.status = "uploading";
-  uploadFileItem.percentage = 0;
+  if (pendingBatchUploadCount === 0) {
+    batchHadUploadError = false;
+  }
+  pendingBatchUploadCount++;
+  if (pendingBatchUploadCount === 1) {
+    useSimpleUploadOverlayStore().beginUpload();
+  }
 
-  try {
-    const fileUrl = await uploadFileToOss(file, "image");
-    if (!fileUrl) {
-      throw new Error("上传失败,未返回文件地址");
-    }
+  const runOne = async (): Promise<void> => {
+    uploadFileItem.status = "uploading";
+    uploadFileItem.percentage = 0;
 
-    uploadFileItem.status = "success";
-    uploadFileItem.percentage = 100;
-    uploadFileItem.url = fileUrl;
-    uploadFileItem.response = { url: fileUrl };
+    try {
+      const fileUrl = await uploadFileToOss(file, "image", { skipSimpleUploadOverlay: true as const });
+      if (!fileUrl) {
+        throw new Error("上传失败,未返回文件地址");
+      }
 
-    if (!formData.attachmentUrls.includes(fileUrl)) {
-      formData.attachmentUrls.push(fileUrl);
-    }
-    options.onSuccess?.({ code: 200, data: fileUrl, url: fileUrl });
-    formRef.value?.validateField("attachmentUrls");
-  } catch (error: any) {
-    console.error("文件上传失败:", error);
-    uploadFileItem.status = "fail";
-    if (uploadFileItem.url && uploadFileItem.url.startsWith("blob:")) {
-      URL.revokeObjectURL(uploadFileItem.url);
-    }
-    const index = fileList.value.findIndex(f => f.uid === uploadFileItem.uid);
-    if (index > -1) {
-      fileList.value.splice(index, 1);
-    }
-    if (error?.message === "上传失败,未返回文件地址") {
-      ElMessage.error(error.message);
+      uploadFileItem.status = "success";
+      uploadFileItem.percentage = 100;
+      uploadFileItem.url = fileUrl;
+      uploadFileItem.response = { url: fileUrl };
+
+      if (!formData.attachmentUrls.includes(fileUrl)) {
+        formData.attachmentUrls.push(fileUrl);
+      }
+      options.onSuccess?.({ code: 200, data: fileUrl, url: fileUrl });
+      formRef.value?.validateField("attachmentUrls");
+    } catch (error: any) {
+      batchHadUploadError = true;
+      console.error("文件上传失败:", error);
+      uploadFileItem.status = "fail";
+      if (uploadFileItem.url && uploadFileItem.url.startsWith("blob:")) {
+        URL.revokeObjectURL(uploadFileItem.url);
+      }
+      const index = fileList.value.findIndex(f => f.uid === uploadFileItem.uid);
+      if (index > -1) {
+        fileList.value.splice(index, 1);
+      }
+      if (error?.message === "上传失败,未返回文件地址") {
+        ElMessage.error(error.message);
+      }
+      options.onError?.(error);
+      throw error;
+    } finally {
+      pendingBatchUploadCount--;
+      if (pendingBatchUploadCount === 0) {
+        await finishBatchUploadOverlay();
+      }
     }
-    // 其余错误(含 OSS 签名/网络)已在 upload.js 中提示
-    options.onError?.(error);
-  }
+  };
+
+  const p = sequentialUploadTail.then(runOne);
+  sequentialUploadTail = p.catch(() => {
+    /* 保持队列继续,不因单张失败阻断后续文件 */
+  });
+  return p;
 };
 
 // 图片预览

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

@@ -435,8 +435,8 @@ const validateImageDimensions = (file: File): Promise<boolean> => {
   });
 };
 
-// 自定义图片上传 API(OSS 直传)
-const customImageUploadApi = async (formData: FormData): Promise<any> => {
+// 自定义图片上传 API(OSS 直传);第二参由 UploadImgs 透传(如 skipSimpleUploadOverlay),多图只走组件侧统一弹层
+const customImageUploadApi = async (formData: FormData, options?: Record<string, unknown>): Promise<any> => {
   const raw = formData.get("file");
   const file = raw instanceof File ? raw : null;
   if (!file) {
@@ -449,7 +449,7 @@ const customImageUploadApi = async (formData: FormData): Promise<any> => {
     throw error;
   }
 
-  const urls = await uploadFilesToOss(file, "image");
+  const urls = await uploadFilesToOss(file, "image", options ?? {});
   const fileUrl = urls[0];
   if (!fileUrl) {
     throw new Error("上传失败,未返回地址");
@@ -557,7 +557,7 @@ async function resolveVideoCoverUrlForSave(targetVideo: AlbumVideo, videoUrl: st
     if (blob && blob.size > 0) {
       const coverFile = new File([blob], `video_cover_${Date.now()}.jpg`, { type: "image/jpeg" });
       try {
-        const urls = await uploadFilesToOss(coverFile, "image");
+        const urls = await uploadFilesToOss(coverFile, "image", {});
         const u = urls[0];
         if (u) {
           targetVideo.coverUrl = u;
@@ -622,8 +622,8 @@ const handleVideoUploadSuccess = async (url: string) => {
   }
 };
 
-// 自定义视频上传:与 @/api/upload.js 约定一致,走 Tus +(可选)视频审核,勿用 dev-simple 端点(易仅支持图或未配置导致失败)
-const customVideoUploadApi = async (formData: FormData): Promise<any> => {
+// 自定义视频上传:与 @/api/upload.js 约定一致;第二参由 UploadImgs 透传,避免与组件统一弹层重复
+const customVideoUploadApi = async (formData: FormData, options?: Record<string, unknown>): Promise<any> => {
   const raw = formData.get("file");
   const file = raw instanceof File ? raw : null;
   if (!file) {
@@ -636,7 +636,7 @@ const customVideoUploadApi = async (formData: FormData): Promise<any> => {
     throw new Error("视频大小超过限制");
   }
 
-  const { data } = await uploadFormDataToOss(formData, "video");
+  const { data } = await uploadFormDataToOss(formData, "video", options ?? {});
   const fileUrl = data?.fileUrl;
   if (!fileUrl) {
     throw new Error("上传失败,未返回地址");

+ 4 - 3
src/views/storeDecoration/storeHeadMap/index.vue

@@ -171,16 +171,17 @@ const carouselPreviewImages = computed(() => {
 });
 
 /**
- * OSS 直传:满足 UploadImgs(读 response.fileUrl)
+ * OSS 直传:满足 UploadImgs(读 response.fileUrl)。
+ * 第二参由 UploadImgs 透传(如 skipSimpleUploadOverlay),多图/连续选图只走组件侧统一弹层。
  */
-const uploadImageResult = async (payload: FormData): Promise<any> => {
+const uploadImageResult = async (payload: FormData, options?: Record<string, unknown>): Promise<any> => {
   const raw = payload.get("file");
   const file = raw instanceof File ? raw : null;
   if (!file) {
     throw new Error("请选择文件");
   }
   const isVideo = typeof file.type === "string" && file.type.startsWith("video/");
-  const urls = await uploadFilesToOss(file, isVideo ? "video" : "image");
+  const urls = await uploadFilesToOss(file, isVideo ? "video" : "image", options ?? {});
   const url = urls[0];
   if (!url) {
     throw new Error("上传失败,未返回地址");