Ver código fonte

feat(group-package): 实现团购图片上传功能优化

- 新增图片上传队列管理,支持批量上传控制
- 添加文件类型校验,仅允许JPG/PNG格式图片
- 实现上传状态实时跟踪与UI反馈
- 增加未上传完成时操作按钮禁用逻辑
- 完善图片预览功能,区分上传状态显示不同内容
- 优化删除逻辑,防止删除正在上传或待上传的图片
- 更新上传API地址至新的STORE服务端点
- 添加上传数量限制提示与处理
- 改进图片上传组件交互体验及视觉反馈
congxuesong 1 mês atrás
pai
commit
2f429cf8c0
1 arquivos alterados com 279 adições e 28 exclusões
  1. 279 28
      src/views/groupPackageManagement/newGroup.vue

+ 279 - 28
src/views/groupPackageManagement/newGroup.vue

@@ -15,20 +15,31 @@
             <!-- 团购图片上传 prop="imageValueStr" 本地服务测不了上传图片 先去掉必填-->
             <el-form-item label="图片">
               <el-upload
+                ref="uploadRef"
                 v-model:file-list="storeInfoModel.imageValueStr"
-                :action="uploadUrl"
                 list-type="picture-card"
                 :accept="'.jpg,.png'"
-                :limit="9"
+                :limit="uploadMaxCount"
+                :auto-upload="false"
+                :disabled="hasUnuploadedImages"
+                multiple
+                :on-change="handleUploadChange"
+                :on-exceed="handleUploadExceed"
                 :on-preview="handlePictureCardPreview"
                 :before-remove="handleBeforeRemove"
                 :on-remove="handleRemove"
-                :on-success="handleSuccess"
                 :show-file-list="true"
               >
-                <el-icon>
-                  <Plus />
-                </el-icon>
+                <template #trigger>
+                  <div
+                    v-if="(storeInfoModel.imageId?.length || 0) < uploadMaxCount"
+                    class="upload-trigger-card el-upload--picture-card"
+                  >
+                    <el-icon>
+                      <Plus />
+                    </el-icon>
+                  </div>
+                </template>
               </el-upload>
             </el-form-item>
             <!-- 团购名称 -->
@@ -387,8 +398,8 @@
     </el-form>
     <!-- 底部按钮区域 -->
     <div class="button-container">
-      <el-button @click="handleSubmit('cg')"> 存草稿 </el-button>
-      <el-button type="primary" @click="handleSubmit()"> 确定 </el-button>
+      <el-button @click="handleSubmit('cg')" :disabled="hasUnuploadedImages"> 存草稿 </el-button>
+      <el-button type="primary" @click="handleSubmit()" :disabled="hasUnuploadedImages"> 确定 </el-button>
     </div>
     <!-- 图片预览 -->
     <el-image-viewer
@@ -462,7 +473,7 @@ import {
   saveThali
 } from "@/api/modules/groupPackageManagement";
 import { useRouter, useRoute } from "vue-router";
-import type { UploadProps, FormInstance } from "element-plus";
+import type { UploadProps, FormInstance, UploadInstance, UploadFile } from "element-plus";
 import { localGet, localSet } from "@/utils";
 
 // ==================== 响应式数据定义 ====================
@@ -481,7 +492,20 @@ const type = ref<string>(""); // 页面类型:add-新增, edit-编辑
 const id = ref<string>(""); // 页面ID参数
 
 // 文件上传地址
-const uploadUrl = ref(`${import.meta.env.VITE_API_URL_PLATFORM}/file/uploadImg`);
+const uploadUrl = ref(`${import.meta.env.VITE_API_URL_STORE}/file/uploadImg`);
+
+const imgType = ref(16);
+const uploadMaxCount = 9;
+const uploadRef = ref<UploadInstance>();
+const uploading = ref(false);
+const pendingUploadFiles = ref<UploadFile[]>([]);
+const generateImgSort = (() => {
+  let seed = Date.now();
+  return () => {
+    seed += 1;
+    return seed;
+  };
+})();
 
 // ==================== 表单验证规则 ====================
 const rules = reactive({
@@ -939,6 +963,21 @@ const visibleGroups = computed(() => {
   return lifeGroupBuyThalis.value.map((group, index) => ({ group, originalIndex: index }));
 });
 
+// 计算属性:检查是否有未上传完成的图片
+const hasUnuploadedImages = computed(() => {
+  // 检查是否有正在上传的文件
+  if (uploading.value || pendingUploadFiles.value.length > 0) {
+    return true;
+  }
+  // 检查文件列表中是否有状态为 "ready"(待上传)或 "uploading"(上传中)的图片
+  if (storeInfoModel.value.imageValueStr && storeInfoModel.value.imageValueStr.length > 0) {
+    return storeInfoModel.value.imageValueStr.some((file: any) => {
+      return file.status === "ready" || file.status === "uploading";
+    });
+  }
+  return false;
+});
+
 // ==================== 监听器 ====================
 
 /**
@@ -1129,6 +1168,26 @@ onMounted(async () => {
 const goBack = () => {
   router.go(-1);
 };
+const beforeAvatarUpload = (file: any) => {
+  console.log(file);
+  return false;
+};
+/**
+ * 检查文件是否在排队中(未上传)
+ * @param file 文件对象
+ * @returns 是否在排队中
+ */
+const isFilePending = (file: any): boolean => {
+  // 只检查 ready 状态(排队中),不包括 uploading(正在上传)
+  if (file.status === "ready") {
+    return true;
+  }
+  // 检查是否在待上传队列中
+  if (pendingUploadFiles.value.some(item => item.uid === file.uid)) {
+    return true;
+  }
+  return false;
+};
 
 /**
  * 图片上传 - 删除前确认
@@ -1137,6 +1196,11 @@ const goBack = () => {
  * @returns Promise<boolean>,true 允许删除,false 阻止删除
  */
 const handleBeforeRemove = async (uploadFile: any, uploadFiles: any[]): Promise<boolean> => {
+  // 如果文件在排队中(未上传),禁止删除
+  if (isFilePending(uploadFile)) {
+    ElMessage.warning("图片尚未上传,请等待上传完成后再删除");
+    return false;
+  }
   try {
     await ElMessageBox.confirm("确定要删除这张图片吗?", "提示", {
       confirmButtonText: "确定",
@@ -1167,28 +1231,163 @@ const handleRemove: UploadProps["onRemove"] = (uploadFile, uploadFiles) => {
       storeInfoModel.value.imageId.splice(index, 1);
     }
   }
+  if (file.url && file.url.startsWith("blob:")) {
+    URL.revokeObjectURL(file.url);
+  }
+  // 同步文件列表
+  storeInfoModel.value.imageValueStr = [...uploadFiles];
   // 删除成功后提示
   ElMessage.success("图片已删除");
 };
 
 /**
- * 图片上传 - 上传成功回调
- * @param response 上传响应数据
- * @param uploadFile 上传的文件对象
- * @param uploadFiles 当前文件列表
+ * 上传文件超出限制提示
+ */
+const handleUploadExceed: UploadProps["onExceed"] = () => {
+  ElMessage.warning(`最多只能上传${uploadMaxCount}张图片`);
+};
+
+/**
+ * el-upload 文件变更(选中或移除)
  */
-const handleSuccess = (response: any, uploadFile: any, uploadFiles: any[]) => {
-  const imageId = response?.data[0];
-  // 将 imageId 添加到 storeInfoModel 的 imageId 数组中
-  if (!storeInfoModel.value.imageId.includes(imageId)) {
-    storeInfoModel.value.imageId.push(imageId);
+const handleUploadChange: UploadProps["onChange"] = async (uploadFile, uploadFiles) => {
+  // 检查文件类型,只允许 jpg 和 png
+  if (uploadFile.raw) {
+    const fileType = uploadFile.raw.type.toLowerCase();
+    const fileName = uploadFile.name.toLowerCase();
+    const validTypes = ["image/jpeg", "image/jpg", "image/png"];
+    const validExtensions = [".jpg", ".jpeg", ".png"];
+
+    // 检查 MIME 类型或文件扩展名
+    const isValidType = validTypes.includes(fileType) || validExtensions.some(ext => fileName.endsWith(ext));
+
+    if (!isValidType) {
+      // 从文件列表中移除不符合类型的文件
+      const index = storeInfoModel.value.imageValueStr.findIndex((f: any) => f.uid === uploadFile.uid);
+      if (index > -1) {
+        storeInfoModel.value.imageValueStr.splice(index, 1);
+      }
+      // 从 uploadFiles 中移除
+      const uploadIndex = uploadFiles.findIndex((f: any) => f.uid === uploadFile.uid);
+      if (uploadIndex > -1) {
+        uploadFiles.splice(uploadIndex, 1);
+      }
+      // 如果文件有 blob URL,释放它
+      if (uploadFile.url && uploadFile.url.startsWith("blob:")) {
+        URL.revokeObjectURL(uploadFile.url);
+      }
+      ElMessage.warning("只支持上传 JPG 和 PNG 格式的图片");
+      return;
+    }
   }
-  // 将 imageId 保存到文件对象中,以便删除时使用
-  if (uploadFile) {
-    (uploadFile as any).imageId = imageId;
+
+  // 同步文件列表到表单数据(只添加通过验证的文件)
+  const existingIndex = storeInfoModel.value.imageValueStr.findIndex((f: any) => f.uid === uploadFile.uid);
+  if (existingIndex === -1) {
+    storeInfoModel.value.imageValueStr.push(uploadFile);
+  }
+
+  const readyFiles = storeInfoModel.value.imageValueStr.filter(file => file.status === "ready");
+  if (readyFiles.length) {
+    readyFiles.forEach(file => {
+      if (!pendingUploadFiles.value.some(item => item.uid === file.uid)) {
+        pendingUploadFiles.value.push(file);
+      }
+    });
+  }
+  processUploadQueue();
+};
+
+/**
+ * 处理上传队列 - 逐个上传文件
+ */
+const processUploadQueue = async () => {
+  if (uploading.value || pendingUploadFiles.value.length === 0) {
+    return;
+  }
+  // 每次只取一个文件进行上传
+  const file = pendingUploadFiles.value.shift();
+  if (file) {
+    await uploadSingleFile(file);
+    // 继续处理队列中的下一个文件
+    processUploadQueue();
+  }
+};
+
+/**
+ * 单文件上传图片
+ * @param file 待上传的文件
+ */
+const uploadSingleFile = async (file: UploadFile) => {
+  if (!file.raw) {
+    return;
+  }
+
+  const formData = new FormData();
+  const storeId = Number(localGet("createdId") || 104);
+  const rawFile = file.raw as File;
+  const sortValue = generateImgSort();
+
+  formData.append("file", rawFile);
+  formData.append(
+    "list",
+    JSON.stringify([
+      {
+        storeId,
+        imgType: imgType.value,
+        imgSort: sortValue
+      }
+    ])
+  );
+
+  file.status = "uploading";
+  file.percentage = 0;
+  uploading.value = true;
+
+  try {
+    const response = await fetch(uploadUrl.value, {
+      method: "POST",
+      body: formData,
+      credentials: "include"
+    });
+    if (!response.ok) {
+      throw new Error("上传失败");
+    }
+    const result = await response.json();
+
+    if (result?.code === 200 && result.data) {
+      // 处理单个文件的上传结果
+      const imageId = String(Array.isArray(result.data) ? result.data[0] : result.data);
+      file.status = "success";
+      file.percentage = 100;
+      (file as any).imageId = imageId;
+      file.response = { data: imageId };
+
+      if (!Array.isArray(storeInfoModel.value.imageId)) {
+        storeInfoModel.value.imageId = [];
+      }
+      if (!storeInfoModel.value.imageId.includes(imageId)) {
+        storeInfoModel.value.imageId.push(imageId);
+      }
+    } else {
+      throw new Error(result?.msg || "图片上传失败");
+    }
+  } catch (error: any) {
+    file.status = "fail";
+    if (file.url && file.url.startsWith("blob:")) {
+      URL.revokeObjectURL(file.url);
+    }
+    // 从文件列表中移除失败的文件
+    const index = storeInfoModel.value.imageValueStr.findIndex((f: any) => f.uid === file.uid);
+    if (index > -1) {
+      storeInfoModel.value.imageValueStr.splice(index, 1);
+    }
+    ElMessage.error(error?.message || "图片上传失败");
+  } finally {
+    uploading.value = false;
+    // 触发视图更新
+    storeInfoModel.value.imageValueStr = [...storeInfoModel.value.imageValueStr];
   }
-  ElMessage.success("图片上传成功");
-  // 上传成功后,imageValueStr 会自动更新,response.data 包含图片URL
 };
 
 /**
@@ -1196,12 +1395,30 @@ const handleSuccess = (response: any, uploadFile: any, uploadFiles: any[]) => {
  * @param file 上传文件对象
  */
 const handlePictureCardPreview = (file: any) => {
-  // 获取所有图片的 URL 列表
-  const urlList = storeInfoModel.value.imageValueStr.map((item: any) => item.url || item.response?.data || item);
+  // 如果文件在排队中(未上传),禁止预览
+  if (isFilePending(file)) {
+    ElMessage.warning("图片尚未上传,请等待上传完成后再预览");
+    return;
+  }
+  // 如果文件正在上传中,允许预览(使用本地预览)
+  if (file.status === "uploading" && file.url) {
+    imageViewerUrlList.value = [file.url];
+    imageViewerInitialIndex.value = 0;
+    imageViewerVisible.value = true;
+    return;
+  }
+  // 获取所有图片的 URL 列表(只包含已上传成功的图片)
+  const urlList = storeInfoModel.value.imageValueStr
+    .filter((item: any) => item.status === "success" && (item.url || item.response?.data))
+    .map((item: any) => item.url || item.response?.data);
   // 找到当前点击的图片索引
-  const currentIndex = urlList.findIndex((url: string) => url === file.url);
+  const currentIndex = urlList.findIndex((url: string) => url === (file.url || file.response?.data));
+  if (currentIndex < 0) {
+    ElMessage.warning("图片尚未上传完成,无法预览");
+    return;
+  }
   imageViewerUrlList.value = urlList;
-  imageViewerInitialIndex.value = currentIndex >= 0 ? currentIndex : 0;
+  imageViewerInitialIndex.value = currentIndex;
   imageViewerVisible.value = true;
 };
 
@@ -1752,6 +1969,11 @@ const packageFormRules = computed(() => {
  * 验证表单,通过后调用相应的API接口
  */
 const handleSubmit = async (type?) => {
+  // 检查是否有未上传完成的图片
+  if (hasUnuploadedImages.value) {
+    ElMessage.warning("请等待图片上传完成后再提交");
+    return;
+  }
   let params: any = { ...storeInfoModel.value };
   // 确保 imageId 是数组,然后转换为逗号分隔的字符串
   if (Array.isArray(params.imageId)) {
@@ -1972,6 +2194,35 @@ const handleImageParam = (list: any[], result: any[]) => {
       object-fit: fill;
     }
   }
+
+  /* 排队中(未上传)的图片禁用样式 */
+  .el-upload-list__item[data-status="ready"],
+  .el-upload-list__item.is-ready {
+    position: relative;
+    pointer-events: none;
+    cursor: not-allowed;
+    opacity: 0.6;
+    &::after {
+      position: absolute;
+      inset: 0;
+      z-index: 1;
+      content: "";
+      background-color: rgb(0 0 0 / 30%);
+    }
+    .el-upload-list__item-actions {
+      pointer-events: none;
+      opacity: 0.5;
+    }
+  }
+}
+.upload-trigger-card {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  font-size: 28px;
+  color: #8c939d;
 }
 
 /* 表单容器 */