Переглянути джерело

feat(licenseManagement): 新增合同图片上传功能并优化上传交互

- 实现基于 XMLHttpRequest 的文件上传方法,支持上传进度监听
- 添加合同图片上传接口 uploadContractImage
- 优化上传弹窗交互,增加上传完成前禁用取消按钮逻辑
- 移除旧的取消确认弹窗,改用 MessageBox 确认
- 调整上传区域样式,支持上传满额后隐藏上传入口
- 完善上传失败处理,保持进度条状态一致
- 更新提交审核逻辑,支持按顺序提交图片数据
- 隐藏无权限用户的证照管理菜单项
- 移除冗余的空合同占位符模板代码
congxuesong 3 тижнів тому
батько
коміт
2d83afc650

+ 95 - 0
src/api/index.ts

@@ -7,6 +7,7 @@ import { ResultEnum } from "@/enums/httpEnum";
 import { checkStatus } from "./helper/checkStatus";
 import { AxiosCanceler } from "./helper/axiosCancel";
 import { useUserStore } from "@/stores/modules/user";
+import { localGet } from "@/utils";
 import router from "@/routers";
 
 export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
@@ -118,6 +119,100 @@ class RequestHttp {
   download(url: string, params?: object, _object = {}): Promise<BlobPart> {
     return this.service.post(url, params, { ..._object, responseType: "blob" });
   }
+
+  /**
+   * @description 文件上传方法(基于 XMLHttpRequest)
+   * @param url 上传地址
+   * @param formData FormData 对象
+   * @param onProgress 上传进度回调函数 (progress: number) => void
+   * @param baseURL 可选的基础URL,不传则使用默认baseURL
+   * @returns Promise<ResultData<T>>
+   */
+  upload<T = any>(
+    url: string,
+    formData: FormData,
+    onProgress?: (progress: number) => void,
+    baseURL?: string
+  ): Promise<ResultData<T>> {
+    return new Promise((resolve, reject) => {
+      const xhr = new XMLHttpRequest();
+      const userStore = useUserStore();
+      // 如果传入了 baseURL,使用传入的 baseURL;如果 URL 是完整 URL(以 http 开头),直接使用;否则使用默认 baseURL
+      const fullUrl = baseURL ? `${baseURL}${url}` : url.startsWith("http") ? url : `${config.baseURL}${url}`;
+
+      // 监听上传进度
+      if (onProgress) {
+        xhr.upload.addEventListener("progress", e => {
+          if (e.lengthComputable) {
+            const percentComplete = Math.round((e.loaded / e.total) * 100);
+            onProgress(percentComplete);
+          }
+        });
+      }
+
+      // 监听请求完成
+      xhr.addEventListener("load", () => {
+        if (xhr.status >= 200 && xhr.status < 300) {
+          try {
+            const response = JSON.parse(xhr.responseText);
+            // 统一处理响应,与 axios 拦截器保持一致
+            if (response.code == ResultEnum.OVERDUE) {
+              userStore.setToken("");
+              router.replace(LOGIN_URL);
+              ElMessage.error(response.msg);
+              reject(response);
+              return;
+            }
+            if (response.code && response.code !== ResultEnum.SUCCESS) {
+              ElMessage.error(response.msg);
+              reject(response);
+              return;
+            }
+            resolve(response);
+          } catch (error) {
+            reject(new Error("响应解析失败"));
+          }
+        } else {
+          const errorMsg = `上传失败: ${xhr.status} ${xhr.statusText}`;
+          ElMessage.error(errorMsg);
+          reject(new Error(errorMsg));
+        }
+      });
+
+      // 监听请求错误
+      xhr.addEventListener("error", () => {
+        const errorMsg = "网络错误!请您稍后重试";
+        ElMessage.error(errorMsg);
+        reject(new Error(errorMsg));
+      });
+
+      // 监听请求中止
+      xhr.addEventListener("abort", () => {
+        reject(new Error("上传已取消"));
+      });
+
+      // 打开请求
+      xhr.open("POST", fullUrl, true);
+
+      // 设置请求头
+      const token = userStore.token || localGet("geeker-user")?.token;
+      if (token) {
+        xhr.setRequestHeader("Authorization", token);
+      }
+
+      // 设置超时
+      xhr.timeout = config.timeout;
+      xhr.addEventListener("timeout", () => {
+        const errorMsg = "请求超时!请您稍后重试";
+        ElMessage.error(errorMsg);
+        reject(new Error(errorMsg));
+      });
+
+      // 发送请求
+      xhr.withCredentials = config.withCredentials;
+      xhr.send(formData);
+    });
+  }
 }
 
 export default new RequestHttp(config);

+ 5 - 0
src/api/modules/licenseManagement.ts

@@ -31,3 +31,8 @@ export const submitFoodLicenseReview = params => {
 export const submitContractReview = params => {
   return http.post(PORT_NONE + `/license/submitContractReview`, params);
 };
+
+// 上传合同图片
+export const uploadContractImage = (formData: FormData, onProgress?: (progress: number) => void) => {
+  return http.upload("/file/uploadMore", formData, onProgress, import.meta.env.VITE_API_URL as string);
+};

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

@@ -37,8 +37,8 @@ export const useAuthStore = defineStore({
 
       const hasPermission = await usePermission();
       if (!hasPermission) {
-        // 根据权限隐藏"门店装修"和"财务管理"菜单
-        const hideMenuNames = ["storeDecoration", "financialManagement"];
+        // 根据权限隐藏"门店装修"、"财务管理"和"证照管理"菜单
+        const hideMenuNames = ["storeDecoration", "financialManagement", "licenseManagement"];
         // 递归查找并隐藏指定菜单
         const hideMenus = (menuList: any[]) => {
           menuList.forEach(menu => {

+ 80 - 122
src/views/licenseManagement/contractManagement.vue

@@ -28,24 +28,14 @@
             </el-image>
           </div>
         </el-col>
-        <!--        <template v-for="n in Math.max(0, 8 - contractList.length)" :key="`empty-${n}`">-->
-        <!--          <el-col :xs="12" :sm="8" :md="6" :lg="4" :xl="4">-->
-        <!--            <div class="contract-item empty-item">-->
-        <!--              <el-icon class="empty-icon">-->
-        <!--                <Picture />-->
-        <!--              </el-icon>-->
-        <!--            </div>-->
-        <!--          </el-col>-->
-        <!--        </template>-->
       </el-row>
     </div>
 
     <!-- 更换合同弹窗 -->
-    <el-dialog v-model="replaceDialogVisible" title="更换合同" width="800px" @close="handleReplaceDialogClose">
+    <el-dialog v-model="replaceDialogVisible" title="更换合同" width="860px" :before-close="handleReplaceDialogClose">
       <el-scrollbar height="400px" class="replace-upload-scrollbar">
-        <div class="replace-upload-area">
+        <div class="replace-upload-area" :class="{ 'upload-full': uploadedImageCount >= uploadMaxCount }">
           <el-upload
-            ref="uploadRef"
             v-model:file-list="fileList"
             list-type="picture-card"
             :accept="'.jpg,.png'"
@@ -73,7 +63,7 @@
       </el-scrollbar>
       <template #footer>
         <div class="dialog-footer">
-          <el-button @click="handleCancelReplace"> 取消 </el-button>
+          <el-button @click="handleCancelReplace" :disabled="hasUnuploadedImages"> 取消 </el-button>
           <el-button type="primary" @click="handleSubmitReplace" :disabled="hasUnuploadedImages"> 去审核 </el-button>
         </div>
       </template>
@@ -87,17 +77,6 @@
       @close="imageViewerVisible = false"
     />
 
-    <!-- 取消确认弹窗 -->
-    <el-dialog v-model="cancelConfirmVisible" title="提示" width="400px" :close-on-click-modal="false">
-      <div class="confirm-text">确定要取消本次图片上传吗?已上传的图片将不保存</div>
-      <template #footer>
-        <div class="dialog-footer">
-          <el-button @click="cancelConfirmVisible = false"> 取消 </el-button>
-          <el-button type="primary" @click="handleConfirmCancel"> 确定 </el-button>
-        </div>
-      </template>
-    </el-dialog>
-
     <!-- 变更记录弹窗 -->
     <el-dialog v-model="changeRecordDialogVisible" title="变更记录" width="900px" :close-on-click-modal="false">
       <div class="change-record-content">
@@ -136,18 +115,12 @@
 </template>
 
 <script setup lang="ts" name="contractManagement">
-import { ref, onMounted, computed, nextTick } from "vue";
+import { ref, onMounted, computed } from "vue";
 import { ElMessage, ElMessageBox } from "element-plus";
 import { Plus, Picture } from "@element-plus/icons-vue";
-import type { UploadProps, UploadFile, UploadInstance } from "element-plus";
+import type { UploadProps, UploadFile } from "element-plus";
 import { localGet } from "@/utils";
-import { getContractImages } from "@/api/modules/licenseManagement";
-
-interface ContractItem {
-  id: string;
-  url: string;
-  name: string;
-}
+import { getContractImages, uploadContractImage, submitContractReview } from "@/api/modules/licenseManagement";
 
 interface ChangeRecordItem {
   id: string;
@@ -165,10 +138,8 @@ const statusMap: Record<number, { name: string; class: string }> = {
 
 const contractList = ref<any>([]);
 const replaceDialogVisible = ref(false);
-const cancelConfirmVisible = ref(false);
 const changeRecordDialogVisible = ref(false);
 const fileList = ref<UploadFile[]>([]);
-const uploadRef = ref<UploadInstance>();
 const currentRecordDate = ref("2025.08.01 10:29");
 const changeRecordList = ref<ChangeRecordItem[]>([]);
 const rejectionReason = ref("");
@@ -176,20 +147,10 @@ const rejectionReason = ref("");
 const id = localGet("createdId");
 
 // ==================== 图片上传相关变量 ====================
-// 文件上传地址
-const uploadUrl = ref(`${import.meta.env.VITE_API_URL_STORE}/file/uploadImg`);
-const imgType = ref(16);
 const uploadMaxCount = 20;
 const uploading = ref(false);
 const pendingUploadFiles = ref<UploadFile[]>([]);
 const imageUrlList = ref<string[]>([]); // 存储图片URL列表
-const generateImgSort = (() => {
-  let seed = Date.now();
-  return () => {
-    seed += 1;
-    return seed;
-  };
-})();
 
 // 图片预览相关
 const imageViewerVisible = ref(false);
@@ -414,61 +375,20 @@ const uploadSingleFile = async (file: UploadFile) => {
   if (!file.raw) {
     return;
   }
-
-  const formData = new FormData();
-  const storeId = Number(localGet("createdId"));
   const rawFile = file.raw as File;
-  const sortValue = generateImgSort();
-
+  const formData = new FormData();
   formData.append("file", rawFile);
-  formData.append(
-    "list",
-    JSON.stringify([
-      {
-        storeId,
-        imgType: imgType.value,
-        imgSort: sortValue
-      }
-    ])
-  );
-
+  formData.append("user", "text");
   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();
-
+    // 上传过程中先保持进度为 0,避免接口异常时进度条误显示 100%
+    const result: any = await uploadContractImage(formData);
     if (result?.code === 200 && result.data) {
       // 处理单个文件的上传结果
-      // 尝试从返回结果中获取图片URL
-      // 可能的结构:result.data 是对象包含 url,或者 result.url,或者 result.data 是 URL 字符串
-      let imageUrl = "";
-      if (typeof result.data === "object" && !Array.isArray(result.data)) {
-        // 如果返回的是对象,尝试获取 url 字段
-        imageUrl = result.data.url || result.data.fileUrl || "";
-      } else if (typeof result.data === "string") {
-        // 如果返回的是字符串,可能是 URL
-        imageUrl = result.data;
-      } else if (Array.isArray(result.data) && result.data.length > 0) {
-        // 如果返回的是数组,取第一个元素
-        imageUrl = typeof result.data[0] === "string" ? result.data[0] : result.data[0]?.url || result.data[0]?.fileUrl || "";
-      } else if (result.url) {
-        // 如果返回结果中有 url 字段
-        imageUrl = result.url;
-      } else if (result.fileUrl) {
-        // 如果返回结果中有 fileUrl 字段
-        imageUrl = result.fileUrl;
-      }
-
+      let imageUrl = result.data[0];
       if (!imageUrl) {
         throw new Error("上传成功但未获取到图片URL");
       }
@@ -490,6 +410,8 @@ const uploadSingleFile = async (file: UploadFile) => {
       throw new Error(result?.msg || "图片上传失败");
     }
   } catch (error: any) {
+    // 上传失败时保持进度条为 0
+    file.percentage = 0;
     file.status = "fail";
     if (file.url && file.url.startsWith("blob:")) {
       URL.revokeObjectURL(file.url);
@@ -499,7 +421,6 @@ const uploadSingleFile = async (file: UploadFile) => {
     if (index > -1) {
       fileList.value.splice(index, 1);
     }
-    ElMessage.error(error?.message || "图片上传失败");
   } finally {
     uploading.value = false;
     // 触发视图更新
@@ -539,26 +460,58 @@ const handlePictureCardPreview = (file: any) => {
   imageViewerVisible.value = true;
 };
 
-const handleCancelReplace = () => {
+const handleCancelReplace = async () => {
+  // 如果有图片正在上传,阻止关闭
+  if (hasUnuploadedImages.value) {
+    ElMessage.warning("请等待图片上传完成后再关闭");
+    return;
+  }
   if (fileList.value.length > 0) {
-    cancelConfirmVisible.value = true;
+    try {
+      await ElMessageBox.confirm("确定要取消本次图片上传吗?已上传的图片将不保存", "提示", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      });
+      // 用户确认取消
+      fileList.value = [];
+      imageUrlList.value = [];
+      pendingUploadFiles.value = [];
+      uploading.value = false;
+      replaceDialogVisible.value = false;
+    } catch {
+      // 用户取消操作,不做任何处理
+    }
   } else {
     replaceDialogVisible.value = false;
   }
 };
 
-const handleConfirmCancel = () => {
-  fileList.value = [];
-  imageUrlList.value = [];
-  pendingUploadFiles.value = [];
-  uploading.value = false;
-  cancelConfirmVisible.value = false;
-  replaceDialogVisible.value = false;
-};
-
-const handleReplaceDialogClose = () => {
+const handleReplaceDialogClose = async (done: () => void) => {
+  // 如果有图片正在上传,阻止关闭
+  if (hasUnuploadedImages.value) {
+    ElMessage.warning("请等待图片上传完成后再关闭");
+    return; // 不调用 done(),阻止关闭弹窗
+  }
   if (fileList.value.length > 0) {
-    cancelConfirmVisible.value = true;
+    try {
+      await ElMessageBox.confirm("确定要取消本次图片上传吗?已上传的图片将不保存", "提示", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      });
+      // 用户确认取消,清空数据并关闭弹窗
+      fileList.value = [];
+      imageUrlList.value = [];
+      pendingUploadFiles.value = [];
+      uploading.value = false;
+      done(); // 调用 done() 允许关闭弹窗
+    } catch {
+      // 用户取消操作,不调用 done(),阻止关闭弹窗
+    }
+  } else {
+    // 没有文件,直接关闭
+    done();
   }
 };
 
@@ -578,9 +531,12 @@ const handleSubmitReplace = async () => {
     return;
   }
   try {
-    // TODO: 调用API提交审核
-    // const imageUrls = imageUrlList.value;
-    // await submitContractReview(imageUrls);
+    // 根据文件列表顺序,生成带排序的图片数据(排序从0开始)
+    const imageDataWithSort = uploadedFiles.map((file, index) => ({
+      url: file.url,
+      sort: index
+    }));
+    await submitContractReview({ images: imageDataWithSort });
     ElMessage.success("提交审核成功");
     replaceDialogVisible.value = false;
     fileList.value = [];
@@ -663,14 +619,6 @@ const getStatusName = (status: number) => {
     color: var(--el-text-color-placeholder);
     background: var(--el-fill-color-light);
   }
-  &.empty-item {
-    background-color: var(--el-fill-color-lighter);
-    border: 1px dashed var(--el-border-color);
-    .empty-icon {
-      font-size: 48px;
-      color: var(--el-text-color-placeholder);
-    }
-  }
 }
 .replace-upload-scrollbar {
   :deep(.el-scrollbar__wrap) {
@@ -680,6 +628,22 @@ const getStatusName = (status: number) => {
 .replace-upload-area {
   min-height: 300px;
   padding: 20px;
+  :deep(.el-upload-list--picture-card .el-upload-list__item:hover .el-upload-list__item-status-label) {
+    display: inline-flex !important;
+    opacity: 1 !important;
+  }
+  :deep(.el-upload-list__item.is-success:focus .el-upload-list__item-status-label) {
+    display: inline-flex !important;
+    opacity: 1 !important;
+  }
+  :deep(.el-upload-list--picture-card .el-icon--close-tip) {
+    display: none !important;
+  }
+  &.upload-full {
+    :deep(.el-upload--picture-card) {
+      display: none !important;
+    }
+  }
 }
 .dialog-footer {
   display: flex;
@@ -733,12 +697,6 @@ const getStatusName = (status: number) => {
     color: #8c939d;
   }
 }
-.confirm-text {
-  padding: 10px 0;
-  font-size: 14px;
-  line-height: 1.6;
-  color: var(--el-text-color-primary);
-}
 .change-record-content {
   padding: 20px 0;
   .record-date {