yanghao 3 дней назад
Родитель
Сommit
6637addbe1

+ 2 - 0
components.d.ts

@@ -27,6 +27,7 @@ declare module 'vue' {
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElIcon: typeof import('element-plus/es')['ElIcon']
     ElInput: typeof import('element-plus/es')['ElInput']
+    ElLink: typeof import('element-plus/es')['ElLink']
     ElPagination: typeof import('element-plus/es')['ElPagination']
     ElPopover: typeof import('element-plus/es')['ElPopover']
     ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
@@ -41,6 +42,7 @@ declare module 'vue' {
     Header: typeof import('./src/components/home/header.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
+    UploadImgs: typeof import('./src/components/UploadFile/UploadImgs.vue')['default']
   }
   export interface GlobalDirectives {
     vLoading: typeof import('element-plus/es')['ElLoadingDirective']

+ 3 - 0
package.json

@@ -22,6 +22,7 @@
     "@iconify/vue": "^5.0.0",
     "@types/qs": "^6.14.0",
     "axios": "^1.13.1",
+    "crypto-js": "^4.2.0",
     "dingtalk-jsapi": "^3.2.5",
     "echarts": "^6.0.0",
     "element-plus": "^2.11.7",
@@ -32,11 +33,13 @@
     "qs": "^6.14.0",
     "vue": "^3.5.22",
     "vue-router": "^4.6.3",
+    "vue-types": "^6.0.0",
     "web-storage-cache": "^1.1.1"
   },
   "devDependencies": {
     "@tailwindcss/vite": "^4.1.16",
     "@tsconfig/node22": "^22.0.2",
+    "@types/crypto-js": "^4.2.2",
     "@types/node": "^22.18.11",
     "@vitejs/plugin-vue": "^6.0.1",
     "@vue/tsconfig": "^0.8.1",

+ 32 - 0
pnpm-lock.yaml

@@ -20,6 +20,9 @@ importers:
       axios:
         specifier: ^1.13.1
         version: 1.13.1
+      crypto-js:
+        specifier: ^4.2.0
+        version: 4.2.0
       dingtalk-jsapi:
         specifier: ^3.2.5
         version: 3.2.5
@@ -50,6 +53,9 @@ importers:
       vue-router:
         specifier: ^4.6.3
         version: 4.6.3(vue@3.5.22(typescript@5.9.3))
+      vue-types:
+        specifier: ^6.0.0
+        version: 6.0.0(vue@3.5.22(typescript@5.9.3))
       web-storage-cache:
         specifier: ^1.1.1
         version: 1.1.1
@@ -60,6 +66,9 @@ importers:
       '@tsconfig/node22':
         specifier: ^22.0.2
         version: 22.0.2
+      '@types/crypto-js':
+        specifier: ^4.2.2
+        version: 4.2.2
       '@types/node':
         specifier: ^22.18.11
         version: 22.19.0
@@ -656,6 +665,9 @@ packages:
   '@tsconfig/node22@22.0.2':
     resolution: {integrity: sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA==}
 
+  '@types/crypto-js@4.2.2':
+    resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
+
   '@types/estree@1.0.8':
     resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
 
@@ -882,6 +894,9 @@ packages:
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
     engines: {node: '>= 8'}
 
+  crypto-js@4.2.0:
+    resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+
   csstype@3.1.3:
     resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
 
@@ -1628,6 +1643,15 @@ packages:
     peerDependencies:
       typescript: '>=5.0.0'
 
+  vue-types@6.0.0:
+    resolution: {integrity: sha512-fBgCA4nrBrB8SCU/AN40tFq8HUxLGBvU2ds7a5+SEDse6dYc+TJyvy8mWiwwL8oWIC/aGS/8nTqmhwxApgU5eA==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      vue: ^3.0.0
+    peerDependenciesMeta:
+      vue:
+        optional: true
+
   vue@3.5.22:
     resolution: {integrity: sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==}
     peerDependencies:
@@ -2122,6 +2146,8 @@ snapshots:
 
   '@tsconfig/node22@22.0.2': {}
 
+  '@types/crypto-js@4.2.2': {}
+
   '@types/estree@1.0.8': {}
 
   '@types/lodash-es@4.17.12':
@@ -2424,6 +2450,8 @@ snapshots:
       shebang-command: 2.0.0
       which: 2.0.2
 
+  crypto-js@4.2.0: {}
+
   csstype@3.1.3: {}
 
   dayjs@1.11.19: {}
@@ -3122,6 +3150,10 @@ snapshots:
       '@vue/language-core': 3.1.3(typescript@5.9.3)
       typescript: 5.9.3
 
+  vue-types@6.0.0(vue@3.5.22(typescript@5.9.3)):
+    optionalDependencies:
+      vue: 3.5.22(typescript@5.9.3)
+
   vue@3.5.22(typescript@5.9.3):
     dependencies:
       '@vue/compiler-dom': 3.5.22

+ 323 - 0
src/components/UploadFile/UploadImgs.vue

@@ -0,0 +1,323 @@
+<template>
+  <div class="upload-box">
+    <el-upload
+      v-model:file-list="fileList"
+      :accept="fileType.join(',')"
+      :action="uploadUrl"
+      :before-upload="beforeUpload"
+      :class="['upload', drag ? 'no-border' : '']"
+      :disabled="disabled"
+      :drag="drag"
+      :http-request="httpRequestOnlyPath"
+      :limit="limit"
+      :multiple="true"
+      :on-error="uploadError"
+      :on-exceed="handleExceed"
+      :on-success="uploadSuccess"
+      list-type="picture-card"
+    >
+      <div class="upload-empty">
+        <slot name="empty">
+          <Icon icon="ep:plus" />
+          <!-- <span>请上传图片</span> -->
+        </slot>
+      </div>
+      <template #file="{ file }">
+        <img :src="file.url" class="upload-image" />
+        <div class="upload-handle" @click.stop>
+          <!-- <div class="handle-icon" @click="imagePreview(file.url!)">
+            <Icon icon="ep:zoom-in" />
+            <span>查看</span>
+          </div> -->
+          <div v-if="!disabled" class="handle-icon" @click="handleRemove(file)">
+            <Icon icon="ep:delete" />
+            <span>删除</span>
+          </div>
+        </div>
+      </template>
+    </el-upload>
+    <div class="el-upload__tip">
+      <slot name="tip"></slot>
+    </div>
+  </div>
+</template>
+<script lang="ts" setup>
+import type { UploadFile, UploadProps, UploadUserFile } from "element-plus";
+import { ElMessage, ElNotification } from "element-plus";
+import { ref, watch } from "vue";
+
+import { propTypes } from "./propTypes";
+import { useUpload } from "@/components/UploadFile/src/useUpload";
+
+defineOptions({ name: "UploadImgs" });
+
+// 查看图片
+// const imagePreview = (imgUrl: string) => {
+//   createImageViewer({
+//     zIndex: 9999999,
+//     urlList: [imgUrl],
+//   });
+// };
+
+type FileTypes =
+  | "image/apng"
+  | "image/bmp"
+  | "image/gif"
+  | "image/jpeg"
+  | "image/pjpeg"
+  | "image/png"
+  | "image/svg+xml"
+  | "image/tiff"
+  | "image/webp"
+  | "image/x-icon";
+
+const props = defineProps({
+  modelValue: propTypes.oneOfType<string | string[]>([String, Array<String>])
+    .isRequired,
+  drag: propTypes.bool.def(true), // 是否支持拖拽上传 ==> 非必传(默认为 true)
+  disabled: propTypes.bool.def(false), // 是否禁用上传组件 ==> 非必传(默认为 false)
+  limit: propTypes.number.def(5), // 最大图片上传数 ==> 非必传(默认为 5张)
+  fileSize: propTypes.number.def(5), // 图片大小限制 ==> 非必传(默认为 5M)
+  fileType: propTypes.array.def(["image/jpeg", "image/png", "image/gif"]), // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"])
+  height: propTypes.string.def("150px"), // 组件高度 ==> 非必传(默认为 150px)
+  width: propTypes.string.def("150px"), // 组件宽度 ==> 非必传(默认为 150px)
+  borderradius: propTypes.string.def("8px"), // 组件边框圆角 ==> 非必传(默认为 8px)
+});
+
+const { uploadUrl, httpRequest, httpRequestOnlyPath } = useUpload();
+
+const fileList = ref<UploadUserFile[]>([]);
+const uploadNumber = ref<number>(0);
+const uploadList = ref<UploadUserFile[]>([]);
+/**
+ * @description 文件上传之前判断
+ * @param rawFile 上传的文件
+ * */
+const beforeUpload: UploadProps["beforeUpload"] = (rawFile) => {
+  const imgSize = rawFile.size / 1024 / 1024 < props.fileSize;
+  const imgType = props.fileType;
+  if (!imgType.includes(rawFile.type as FileTypes))
+    ElNotification({
+      title: "温馨提示",
+      message: "上传图片不符合所需的格式!",
+      type: "warning",
+    });
+  if (!imgSize)
+    ElNotification({
+      title: "温馨提示",
+      message: `上传图片大小不能超过 ${props.fileSize}M!`,
+      type: "warning",
+    });
+  uploadNumber.value++;
+  return imgType.includes(rawFile.type as FileTypes) && imgSize;
+};
+
+// 图片上传成功
+interface UploadEmits {
+  (e: "update:modelValue", value: string[]): void;
+}
+
+const emit = defineEmits<UploadEmits>();
+const uploadSuccess: UploadProps["onSuccess"] = (res: any): void => {
+  ElMessage.success("上传成功");
+  // 删除自身
+  const index = fileList.value.findIndex(
+    (item) => item.response?.data === res.data,
+  );
+  fileList.value.splice(index, 1);
+  uploadList.value.push({ name: res.data, url: res.data });
+  if (uploadList.value.length == uploadNumber.value) {
+    fileList.value.push(...uploadList.value);
+    uploadList.value = [];
+    uploadNumber.value = 0;
+    emitUpdateModelValue();
+  }
+};
+
+// 监听模型绑定值变动
+watch(
+  () => props.modelValue,
+  (val: string | string[]) => {
+    if (!val) {
+      fileList.value = []; // fix:处理掉缓存,表单重置后上传组件的内容并没有重置
+      return;
+    }
+
+    fileList.value = []; // 保障数据为空
+    fileList.value.push(
+      ...(val as string[]).map((url) => ({
+        name: url.substring(url.lastIndexOf("/") + 1),
+        url,
+      })),
+    );
+  },
+  { immediate: true, deep: true },
+);
+// 发送图片链接列表更新
+const emitUpdateModelValue = () => {
+  let result: string[] = fileList.value.map((file) => file.url!);
+  emit("update:modelValue", result);
+};
+// 删除图片
+const handleRemove = (uploadFile: UploadFile) => {
+  fileList.value = fileList.value.filter(
+    (item) => item.url !== uploadFile.url || item.name !== uploadFile.name,
+  );
+  emit(
+    "update:modelValue",
+    fileList.value.map((file) => file.url!),
+  );
+};
+
+// 图片上传错误提示
+const uploadError = () => {
+  ElNotification({
+    title: "温馨提示",
+    message: "图片上传失败,请您重新上传!",
+    type: "error",
+  });
+};
+
+// 文件数超出提示
+const handleExceed = () => {
+  ElNotification({
+    title: "温馨提示",
+    message: `当前最多只能上传 ${props.limit} 张图片,请移除后上传!`,
+    type: "warning",
+  });
+};
+</script>
+
+<!-- <style lang="scss" scoped>
+.is-error {
+  .upload {
+    :deep(.el-upload--picture-card),
+    :deep(.el-upload-dragger) {
+      border: 1px dashed var(--el-color-danger) !important;
+
+      &:hover {
+        border-color: var(--el-color-primary) !important;
+      }
+    }
+  }
+}
+
+:deep(.disabled) {
+  .el-upload--picture-card,
+  .el-upload-dragger {
+    cursor: not-allowed;
+    background: var(--el-disabled-bg-color) !important;
+    border: 1px dashed var(--el-border-color-darker);
+
+    &:hover {
+      border-color: var(--el-border-color-darker) !important;
+    }
+  }
+}
+
+.upload-box {
+  .no-border {
+    :deep(.el-upload--picture-card) {
+      border: none !important;
+    }
+  }
+
+  :deep(.upload) {
+    .el-upload-dragger {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 100%;
+      height: 100%;
+      padding: 0;
+      overflow: hidden;
+      border: 1px dashed var(--el-border-color-darker);
+      border-radius: v-bind(borderradius);
+
+      &:hover {
+        border: 1px dashed var(--el-color-primary);
+      }
+    }
+
+    .el-upload-dragger.is-dragover {
+      background-color: var(--el-color-primary-light-9);
+      border: 2px dashed var(--el-color-primary) !important;
+    }
+
+    .el-upload-list__item,
+    .el-upload--picture-card {
+      width: v-bind(width);
+      height: v-bind(height);
+      background-color: transparent;
+      border-radius: v-bind(borderradius);
+    }
+
+    .upload-image {
+      width: 100%;
+      height: 100%;
+      object-fit: contain;
+    }
+
+    .upload-handle {
+      position: absolute;
+      top: 0;
+      right: 0;
+      display: flex;
+      width: 100%;
+      height: 100%;
+      cursor: pointer;
+      background: rgb(0 0 0 / 60%);
+      opacity: 0;
+      box-sizing: border-box;
+      transition: var(--el-transition-duration-fast);
+      align-items: center;
+      justify-content: center;
+
+      .handle-icon {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        padding: 0 6%;
+        color: aliceblue;
+
+        .el-icon {
+          margin-bottom: 15%;
+          font-size: 140%;
+        }
+
+        span {
+          font-size: 100%;
+        }
+      }
+    }
+
+    .el-upload-list__item {
+      &:hover {
+        .upload-handle {
+          opacity: 1;
+        }
+      }
+    }
+
+    .upload-empty {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      font-size: 12px;
+      line-height: 30px;
+      color: var(--el-color-info);
+
+      .el-icon {
+        font-size: 28px;
+        color: var(--el-text-color-secondary);
+      }
+    }
+  }
+
+  .el-upload__tip {
+    line-height: 15px;
+    text-align: center;
+  }
+}
+</style> -->

+ 29 - 0
src/components/UploadFile/propTypes.ts

@@ -0,0 +1,29 @@
+import {
+  type VueTypeValidableDef,
+  type VueTypesInterface,
+  createTypes,
+  toValidableType,
+} from "vue-types";
+import { type CSSProperties } from "vue";
+
+type PropTypes = VueTypesInterface & {
+  readonly style: VueTypeValidableDef<CSSProperties>;
+};
+const newPropTypes = createTypes({
+  func: undefined,
+  bool: undefined,
+  string: undefined,
+  number: undefined,
+  object: undefined,
+  integer: undefined,
+}) as PropTypes;
+
+class propTypes extends newPropTypes {
+  static get style() {
+    return toValidableType("style", {
+      type: [String, Object],
+    });
+  }
+}
+
+export { propTypes };

+ 46 - 0
src/components/UploadFile/src/index.ts

@@ -0,0 +1,46 @@
+import request from "@/config/axios";
+
+// 文件预签名地址 Response VO
+export interface FilePresignedUrlRespVO {
+  // 文件配置编号
+  configId: number;
+  // 文件上传 URL
+  uploadUrl: string;
+  uploadUrlPath: string;
+  // 文件 URL
+  url: string;
+}
+
+// 查询文件列表
+export const getFilePage = (params: any) => {
+  return request.get({ url: "/infra/file/page", params });
+};
+
+// 删除文件
+export const deleteFile = (id: number) => {
+  return request.delete({ url: "/infra/file/delete?id=" + id });
+};
+
+// 获取文件预签名地址
+export const getFilePresignedUrl = (path: string) => {
+  return request.get<FilePresignedUrlRespVO>({
+    url: "/infra/file/presigned-url",
+    params: { path },
+  });
+};
+
+// 创建文件
+export const createFile = (data: any) => {
+  return request.post({ url: "/infra/file/create", data });
+};
+
+// 上传文件
+export const updateFile = (data: any) => {
+  return request.upload({ url: "/infra/file/upload", data });
+};
+export const updateFilePath = (data: any) => {
+  return request.upload({ url: "/infra/file/upload/path", data });
+};
+export const updateFileQhsePath = (data: any) => {
+  return request.upload({ url: "/infra/file/upload/qhse/path", data });
+};

+ 211 - 0
src/components/UploadFile/src/useUpload.ts

@@ -0,0 +1,211 @@
+import * as FileApi from "./index";
+import CryptoJS from "crypto-js";
+import {
+  type UploadRawFile,
+  type UploadRequestOptions,
+} from "element-plus/es/components/upload/src/upload";
+import axios from "axios";
+
+/**
+ * 获得上传 URL
+ */
+export const getUploadUrl = (): string => {
+  return (
+    import.meta.env.VITE_BASE_URL +
+    import.meta.env.VITE_API_URL +
+    "/infra/file/upload"
+  );
+};
+export const getUploadUrlOnlyPath = (): string => {
+  return (
+    import.meta.env.VITE_BASE_URL +
+    import.meta.env.VITE_API_URL +
+    "/infra/file/upload/path"
+  );
+};
+
+export const getUploadUrlQhsePath = (): string => {
+  return (
+    import.meta.env.VITE_BASE_URL +
+    import.meta.env.VITE_API_URL +
+    "/infra/file/upload/qhse/path"
+  );
+};
+export const useUpload = () => {
+  // 后端上传地址
+  const uploadUrl = getUploadUrl();
+  const uploadUrlPath = getUploadUrlOnlyPath();
+  const uploadUrlQhsePath = getUploadUrlQhsePath();
+  // 是否使用前端直连上传
+  const isClientUpload =
+    UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE;
+  // 重写ElUpload上传方法
+  const httpRequest = async (options: UploadRequestOptions) => {
+    // 模式一:前端上传
+    if (isClientUpload) {
+      // 1.1 生成文件名称
+      const fileName = await generateFileName(options.file);
+      // 1.2 获取文件预签名地址
+      const presignedInfo = await FileApi.getFilePresignedUrl(fileName);
+      // 1.3 上传文件(不能使用 ElUpload 的 ajaxUpload 方法的原因:其使用的是 FormData 上传,Minio 不支持)
+      return axios
+        .put(presignedInfo.uploadUrl, options.file, {
+          headers: {
+            "Content-Type": options.file.type,
+          },
+        })
+        .then(() => {
+          // 1.4. 记录文件信息到后端(异步)
+          createFile(presignedInfo, fileName, options.file);
+          // 通知成功,数据格式保持与后端上传的返回结果一致
+          return { data: presignedInfo.url };
+        });
+    } else {
+      // 模式二:后端上传
+      // 重写 el-upload httpRequest 文件上传成功会走成功的钩子,失败走失败的钩子
+      return new Promise((resolve, reject) => {
+        FileApi.updateFile({ file: options.file })
+          .then((res) => {
+            if (res.code === 0) {
+              resolve(res);
+            } else {
+              reject(res);
+            }
+          })
+          .catch((res) => {
+            reject(res);
+          });
+      });
+    }
+  };
+
+  const httpRequestOnlyPath = async (options: UploadRequestOptions) => {
+    // 模式一:前端上传
+    if (isClientUpload) {
+      // 1.1 生成文件名称
+      const fileName = await generateFileName(options.file);
+      // 1.2 获取文件预签名地址
+      const presignedInfoPath = await FileApi.getFilePresignedUrl(fileName);
+      // 1.3 上传文件(不能使用 ElUpload 的 ajaxUpload 方法的原因:其使用的是 FormData 上传,Minio 不支持)
+      return axios
+        .put(presignedInfoPath.uploadUrlPath, options.file, {
+          headers: {
+            "Content-Type": options.file.type,
+          },
+        })
+        .then(() => {
+          // 1.4. 记录文件信息到后端(异步)
+          createFile(presignedInfoPath, fileName, options.file);
+          // 通知成功,数据格式保持与后端上传的返回结果一致
+          return { data: presignedInfoPath.url };
+        });
+    } else {
+      // 模式二:后端上传
+      // 重写 el-upload httpRequest 文件上传成功会走成功的钩子,失败走失败的钩子
+      return new Promise((resolve, reject) => {
+        FileApi.updateFilePath({ file: options.file })
+          // FileApi.updateFile({ file: options.file })
+          .then((res) => {
+            if (res.code === 0) {
+              resolve(res);
+            } else {
+              reject(res);
+            }
+          })
+          .catch((res) => {
+            reject(res);
+          });
+      });
+    }
+  };
+
+  const httpRequestQhseOnlyPath = async (options: UploadRequestOptions) => {
+    // 妯″紡涓€锛氬墠绔笂浼?
+    if (isClientUpload) {
+      const fileName = await generateFileName(options.file);
+      const presignedInfoPath = await FileApi.getFilePresignedUrl(fileName);
+      return axios
+        .put(presignedInfoPath.uploadUrlPath, options.file, {
+          headers: {
+            "Content-Type": options.file.type,
+          },
+        })
+        .then(() => {
+          createFile(presignedInfoPath, fileName, options.file);
+          return { data: presignedInfoPath.url };
+        });
+    } else {
+      // 妯″紡浜岋細鍚庣涓婁紶
+      return new Promise((resolve, reject) => {
+        FileApi.updateFileQhsePath({ file: options.file })
+          .then((res) => {
+            if (res.code === 0) {
+              resolve(res);
+            } else {
+              reject(res);
+            }
+          })
+          .catch((res) => {
+            reject(res);
+          });
+      });
+    }
+  };
+
+  return {
+    uploadUrl,
+    uploadUrlPath,
+    uploadUrlQhsePath,
+    httpRequest,
+    httpRequestOnlyPath,
+    httpRequestQhseOnlyPath,
+  };
+};
+
+/**
+ * 创建文件信息
+ * @param vo 文件预签名信息
+ * @param name 文件名称
+ * @param file 文件
+ */
+function createFile(
+  vo: FileApi.FilePresignedUrlRespVO,
+  name: string,
+  file: UploadRawFile,
+) {
+  const fileVo = {
+    configId: vo.configId,
+    url: vo.url,
+    path: name,
+    name: file.name,
+    type: file.type,
+    size: file.size,
+  };
+  FileApi.createFile(fileVo);
+  return fileVo;
+}
+
+/**
+ * 生成文件名称(使用算法SHA256)
+ * @param file 要上传的文件
+ */
+async function generateFileName(file: UploadRawFile) {
+  // 读取文件内容
+  const data = await file.arrayBuffer();
+  const wordArray = CryptoJS.lib.WordArray.create(data);
+  // 计算SHA256
+  const sha256 = CryptoJS.SHA256(wordArray).toString();
+  // 拼接后缀
+  const ext = file.name.substring(file.name.lastIndexOf("."));
+  return `${sha256}${ext}`;
+}
+
+/**
+ * 上传类型
+ */
+enum UPLOAD_TYPE {
+  // 客户端直接上传(只支持S3服务)
+  CLIENT = "client",
+  // 客户端发送到后端上传
+  SERVER = "server",
+}

+ 50 - 103
src/views/index.vue

@@ -276,7 +276,7 @@
               <div class="text-[10px]">需要帮助?遇到系统操作问题?</div>
               <div
                 class="bg-white text-sm text-center text-[#004098] py-1 px-2 rounded-full mt-2 w-[30%] cursor-pointer"
-                @click="openConsultDialog"
+                @click="openConsult"
               >
                 立即咨询
               </div>
@@ -286,57 +286,6 @@
       </section>
     </main>
 
-    <el-dialog
-      v-model="consultDialogVisible"
-      title="在线咨询"
-      width="560px"
-      :close-on-click-modal="false"
-      class="consult-dialog"
-    >
-      <el-form
-        ref="consultFormRef"
-        :model="consultForm"
-        :rules="consultRules"
-        label-position="top"
-      >
-        <el-form-item label="问题描述" prop="description">
-          <el-input
-            v-model="consultForm.description"
-            type="textarea"
-            :rows="5"
-            maxlength="500"
-            show-word-limit
-            placeholder="请尽量描述清楚问题现象、操作步骤和报错信息"
-          />
-        </el-form-item>
-
-        <el-form-item label="上传图片">
-          <el-upload
-            v-model:file-list="consultImages"
-            list-type="picture-card"
-            accept="image/*"
-            :limit="3"
-            :on-exceed="handleConsultExceed"
-            :before-upload="handleConsultBeforeUpload"
-            :on-remove="handleConsultRemove"
-            :auto-upload="false"
-          >
-            <Icon icon="mdi:plus" class="text-[22px] text-[#409eff]" />
-          </el-upload>
-          <div class="mt-2 text-[12px] text-[#8a9ab0]">
-            最多 3 张,仅支持图片文件
-          </div>
-        </el-form-item>
-      </el-form>
-
-      <template #footer>
-        <div class="flex justify-end gap-2">
-          <el-button @click="consultDialogVisible = false">取消</el-button>
-          <el-button type="primary" @click="submitConsult">提交</el-button>
-        </div>
-      </template>
-    </el-dialog>
-
     <Footer />
   </div>
 </template>
@@ -350,7 +299,7 @@ import Footer from "@components/home/Footer.vue";
 import { useRouter } from "vue-router";
 import axios from "axios";
 import { Icon } from "@iconify/vue";
-import { ElLoading, ElMessage, type UploadProps, type UploadUserFile } from "element-plus";
+import { ElLoading } from "element-plus";
 import {
   getMCSsoToken,
   ssoLogin,
@@ -367,7 +316,6 @@ import { manualLogoutKey, reloginCancelKey } from "@/config/axios/service";
 import banner1 from "@/assets/images/banner1.png";
 import banner2 from "@/assets/images/banner2.jpg";
 import banner3 from "@/assets/images/banner3.jpg";
-import img3 from "@/assets/images/3.jpg";
 import oaimage from "@/assets/images/oa.jpg";
 import crmimage from "@/assets/images/crm.jpg";
 import ehrimage from "@/assets/images/ehr.jpg";
@@ -421,54 +369,6 @@ type NoticeItem = {
 const router = useRouter();
 const userStore = useUserStore();
 
-const consultDialogVisible = ref(false);
-const consultFormRef = ref();
-const consultForm = reactive({
-  description: "",
-});
-const consultImages = ref<UploadUserFile[]>([]);
-const consultRules = {
-  description: [
-    { required: true, message: "请先填写问题描述", trigger: "blur" },
-  ],
-};
-
-const openConsultDialog = () => {
-  consultDialogVisible.value = true;
-};
-
-const handleConsultBeforeUpload: UploadProps["beforeUpload"] = (file) => {
-  const isImage = file.type.startsWith("image/");
-  if (!isImage) {
-    ElMessage.error("仅支持上传图片文件");
-    return false;
-  }
-  return true;
-};
-
-const handleConsultExceed: UploadProps["onExceed"] = () => {
-  ElMessage.warning("最多只能上传 3 张图片");
-};
-
-const handleConsultRemove: UploadProps["onRemove"] = (_, fileList) => {
-  consultImages.value = fileList;
-};
-
-const resetConsultForm = () => {
-  consultForm.description = "";
-  consultImages.value = [];
-  consultFormRef.value?.resetFields?.();
-};
-
-const submitConsult = async () => {
-  await consultFormRef.value?.validate(async (valid: boolean) => {
-    if (!valid) return;
-    ElMessage.success("咨询内容已提交");
-    consultDialogVisible.value = false;
-    resetConsultForm();
-  });
-};
-
 const todoPanelTitle = "待办中心";
 const newsPanelTitle = "新闻中心";
 const noticeTabs = [
@@ -950,6 +850,52 @@ const handleTask = async (row) => {
   }
 };
 
+const openConsult = async () => {
+  const res = await ssoLogin({
+    username: userStore.getUser.username,
+  });
+
+  if (res) {
+    const ua = window.navigator.userAgent.toLowerCase();
+    if (ua.includes("dingtalk") || ua.includes("dingtalkwork")) {
+      dd.biz.util.openLink({
+        url:
+          "https://yfoa.keruioil.com/wui/index.html" +
+          "?ssoToken=" +
+          res +
+          "#/main", // 先跳你的 SSO 链接
+        onSuccess: () => {
+          // 延迟跳目标业务地址(和你原来 setTimeout 逻辑一致)
+          setTimeout(() => {
+            dd.biz.util.openLink({
+              url: `https://yfoa.keruioil.com/spa/workflow/static4form/index.html?_rdm=1778289187850#/main/workflow/req?iscreate=1&workflowid=488`,
+            });
+          }, 100);
+        },
+      });
+    } else {
+      const loading = ElLoading.service({
+        lock: true,
+        text: "正在跳转,请稍候...",
+        background: "rgba(0, 0, 0, 0.7)",
+      });
+      const newTab = window.open("", "_blank");
+      newTab.location.href =
+        "https://yfoa.keruioil.com/wui/index.html" +
+        "?ssoToken=" +
+        res +
+        "#/main";
+
+      setTimeout(function () {
+        newTab.location.href = `https://yfoa.keruioil.com/spa/workflow/static4form/index.html?_rdm=1778289187850#/main/workflow/req?iscreate=1&workflowid=488`;
+        setTimeout(() => {
+          loading.close();
+        }, 500);
+      }, 100);
+    }
+  }
+};
+
 const goNews = () => {
   router.push("/news");
 };
@@ -1208,7 +1154,8 @@ onUnmounted(() => {
   justify-content: flex-start;
   gap: 12px;
   border-radius: 8px;
-  padding: 0 18px;
+  padding: 0 10px;
+
   font-size: 14px;
   cursor: pointer;