Эх сурвалжийг харах

Merge branch 'flow' of ruiqigogs/yf-portal-vue into master

yanghao 4 өдөр өмнө
parent
commit
36cf0d12cd

+ 4 - 0
components.d.ts

@@ -17,6 +17,7 @@ declare module 'vue' {
     ElButton: typeof import('element-plus/es')['ElButton']
     ElCard: typeof import('element-plus/es')['ElCard']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+    ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
@@ -26,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']
@@ -35,10 +37,12 @@ declare module 'vue' {
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
+    ElUpload: typeof import('element-plus/es')['ElUpload']
     Footer: typeof import('./src/components/home/Footer.vue')['default']
     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

BIN
src/assets/images/driveicon.png


BIN
src/assets/images/flow.png


BIN
src/assets/images/report.png


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

+ 2 - 2
src/components/home/header.vue

@@ -3,7 +3,7 @@
     class="fixed w-full top-0 z-100 bg-white border-b border-[#f0f2f5] shadow-sm"
   >
     <div
-      class="max-w-[1200px] mx-auto flex items-center justify-between px-5 h-15"
+      class="max-w-[1400px] mx-auto flex items-center justify-between px-5 h-15"
     >
       <div class="flex items-center gap-2 cursor-pointer" @click="goHome">
         <img :src="logo" alt="logo" class="w-9 h-9 rounded-md" />
@@ -17,7 +17,7 @@
         >
       </div>
 
-      <nav class="hidden lg:flex flex-1 mx-4 ml-80 text-sm">
+      <nav class="hidden lg:flex flex-1 mx-4 ml-150 text-sm">
         <ul class="flex items-center gap-6 text-[#303133] text-md">
           <!-- 首页 -->
           <li>

+ 30 - 2
src/views/drive/index.vue

@@ -36,7 +36,9 @@
       </section>
     </main>
 
-    <Footer />
+    <div class="mt-[500px] md:mt-[300px]">
+      <Footer />
+    </div>
   </div>
 </template>
 
@@ -81,6 +83,32 @@ const driveCards: DriveCard[] = [
     url: "https://report.deepoil.cc/webroot/decision/v10/entry/access/e836fb5b-092c-4d64-a324-3beeb4fac0cc?preview=true&page_number=1",
     bgColor: "#ca8a04",
   },
+
+  {
+    title: "供应链驾驶舱",
+    description: "查看供应链分析、物料需求与执行。",
+    icon: "mdi:shield-check",
+    url: "#",
+    bgColor: "#7c3aed",
+  },
+
+  // 市场驾驶舱
+  {
+    title: "市场驾驶舱",
+    description: "查看市场分析、销售数据与执行情况。",
+    icon: "mdi:chart-line",
+    url: "#",
+    bgColor: "#16a34a",
+  },
+
+  // QHSE驾驶舱
+  {
+    title: "QHSE驾驶舱",
+    description: "查看安全、健康、环境与质量数据。",
+    icon: "mdi:shield-alert",
+    url: "#",
+    bgColor: "#dc2626",
+  },
 ];
 
 const openDrive = async (option: DriveCard) => {
@@ -110,7 +138,7 @@ const openDrive = async (option: DriveCard) => {
   max-width: 1200px;
   margin: 0 auto;
   padding: 132px 24px 72px;
-  height: 93vh;
+  height: 100vh;
 }
 
 .drive-hero {

+ 4 - 6
src/views/flow/index.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="ehr-page">
     <Header />
-    <section class="hero max-w-[1300px] mx-auto">
+    <section class="hero max-w-[1400px] mx-auto">
       <div class="hero-inner">
         <!-- 判断上下午 -->
         <h1 class="hero-title">
@@ -15,7 +15,7 @@
     </section>
 
     <!-- 任务统计 -->
-    <section class="total max-w-[1300px] mx-auto">
+    <section class="total max-w-[1400px] mx-auto">
       <div class="total-card" v-for="(item, index) in stats" :key="index">
         <el-popover
           placement="top"
@@ -64,7 +64,7 @@
       </div>
     </section>
 
-    <div class="content max-w-[1300px] mx-auto">
+    <div class="content max-w-[1400px] mx-auto">
       <div class="search-bar">
         <div class="search-input">
           <Icon icon="mdi:magnify" class="search-icon" />
@@ -166,7 +166,6 @@
               <h3 class="item-name font-bold text-slate-900">
                 {{ item.flowName }}
               </h3>
-              <p class="item-desc">{{ item.remark || "暂无描述" }}</p>
             </div>
           </div>
         </div>
@@ -568,8 +567,7 @@ const go = async (item) => {
           //   },
           // });
           if (window.dd) {
-            const targetUrl1 =
-              "https://yfding.keruioil.com/spa/workflow/static4mobileform/index.html?_random=1778205102430#/req?iscreate=1&workflowid=681&isagent=0&f_weaver_belongto_userid=&beagenter=0&f_weaver_belongto_usertype=0";
+            const targetUrl1 = item.appUrl;
             dd.biz.util.openLink({
               url: targetUrl1,
               onSuccess: () => {},

+ 201 - 22
src/views/index.vue

@@ -2,7 +2,7 @@
   <div class="portal-home min-h-screen bg-[#eef3f9] text-[#17345f]">
     <Header />
 
-    <main class="mx-auto max-w-[1200px] px-6 pb-8 pt-20">
+    <main class="mx-auto max-w-[1400px] px-3 pb-8 pt-20">
       <section class="hero-banner overflow-hidden rounded-[6px] relative">
         <div class="">
           <!-- 轮播容器 -->
@@ -58,12 +58,39 @@
         </div>
       </section>
 
-      <section class="mt-3 grid gap-4 xl:grid-cols-[1.74fr_0.74fr]">
+      <section class="portal-mobile-shortcuts mt-3 md:hidden!">
+        <button
+          type="button"
+          class="portal-mobile-shortcut flex flex-col items-center justify-center cursor-pointer"
+          @click="router.push('/flow')"
+        >
+          <img src="../assets//images/flow.png" alt="" class="w-10 h-10" />
+          流程门户
+        </button>
+        <button
+          v-hasPermi="['portal:dashboard:view']"
+          type="button"
+          class="portal-mobile-shortcut flex flex-col items-center justify-center cursor-pointer"
+          @click="router.push('/drive')"
+        >
+          <img src="../assets//images/driveicon.png" alt="" class="w-10 h-10" />
+          驾驶舱门户
+        </button>
+        <button
+          type="button"
+          class="portal-mobile-shortcut flex flex-col items-center justify-center cursor-pointer"
+        >
+          <img src="../assets//images/report.png" alt="" class="w-10 h-10" />
+          报表门户
+        </button>
+      </section>
+
+      <section class="mt-3 grid gap-4 xl:grid-cols-[1.74fr_0.74fr] grid-cols-1">
         <div class="space-y-4">
           <article
             v-for="section in portalSections"
             :key="section.code"
-            class="platform-block"
+            class="platform-block rounded-sm"
             :style="{ minHeight: section.height }"
           >
             <div class="platform-block__header">
@@ -78,7 +105,7 @@
 
             <div
               v-if="section.apps?.length"
-              class="grid grid-cols-2 gap-4 p-6 md:grid-cols-4"
+              class="grid grid-cols-[repeat(2,minmax(0,1fr))] gap-2 p-6 px-3 md:px-6 md:grid-cols-[repeat(4,minmax(0,1fr))]"
             >
               <button
                 v-for="(app, appIndex) in section.apps"
@@ -105,7 +132,10 @@
                     class="text-[24px]"
                   />
                 </span>
-                <span class="text-[#004098] text-sm">{{ app.label }}</span>
+                <span
+                  class="platform-app__label text-[#004098] md:text-sm text-[12px] text-left"
+                  >{{ app.label }}</span
+                >
               </button>
             </div>
           </article>
@@ -267,16 +297,16 @@
           </section>
 
           <section
+            v-if="userStore.getUser.username"
             class="side-card side-card--placeholder rounded-md"
             :style="{ minHeight: '78px', backgroundColor: '#3575e4' }"
           >
             <div class="placeholder-panel flex flex-col text-left">
-              <div class="text-sm">需要帮助?</div>
-              <div class="text-[10px]">
-                遇到系统操作问题?我们的7 x 24 小时AI助手随时待命。
-              </div>
+              <!-- <div class="text-sm">需要帮助?</div> -->
+              <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="openConsult"
               >
                 立即咨询
               </div>
@@ -291,7 +321,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, onUnmounted, ref } from "vue";
+import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
 import * as authUtil from "@/utils/auth";
 import * as dd from "dingtalk-jsapi";
 import Header from "@components/home/header.vue";
@@ -316,7 +346,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";
@@ -392,17 +421,18 @@ const portalSections: PortalSection[] = [
         active: true,
       },
       { label: "客户管理(CRM)", image: crmimage },
+      { label: "经营驾驶舱(MC)", image: driveimage },
+      { label: "项目管理(PM)", image: pmimage },
+      { label: "开发需求管理", image: jishuimage2 },
+      { label: "鸿盘", image: hongpan },
       { label: "人力资源(EHR)", image: ehrimage },
       { label: "供应商管理(SRM)", image: scmimage },
       { label: "财务管理(FM)", image: erpimage },
-      { label: "经营驾驶舱(MC)", image: driveimage },
-      { label: "项目管理(PM)", image: pmimage },
-      { label: "技术研发管理", image: jishuimage2 },
+
+      { label: "技术研发管理(R&D)", image: jishuimage2 },
       { label: "战略解码与执行", image: zhanlueimage },
       { label: "组织资产管理", image: zuzhiimage },
       { label: "风控合规管理", image: safeimage },
-      { label: "研发需求管理", image: jishuimage2 },
-      { label: "鸿盘", image: hongpan },
     ],
   },
   {
@@ -416,10 +446,10 @@ const portalSections: PortalSection[] = [
       { label: "质量安全管理(QHSE)", image: qhseimage },
       { label: "智慧连油", image: lianyouimage },
       { label: "智慧注气", image: zhuqiimage },
+      { label: "视频中心(VCS)", image: videoimage },
       { label: "智能钻井", image: zuanjingimage },
       { label: "智慧压裂", image: yalieimage },
       { label: "数字油藏", image: youimage },
-      { label: "视频中心(VCS)", image: videoimage },
     ],
   },
   {
@@ -447,10 +477,11 @@ let boldLabes = ref([
   "质量安全管理(QHSE)",
   "智慧注气",
   "视频中心(VCS)",
-  "发需求管理",
+  "发需求管理",
   "经营驾驶舱(MC)",
   "项目管理(PM)",
   "鸿盘",
+  "智能钻井",
 ]);
 
 const getGreeting = () => {
@@ -634,7 +665,7 @@ const handlePortalAppClick = async (app: PortalApp) => {
     }
   }
 
-  if (app.label === "发需求管理") {
+  if (app.label === "发需求管理") {
     if (userStore.getUser.username && getAccessToken()) {
       const res = await zentaoSsoLogin({
         username: userStore.getUser.username,
@@ -650,6 +681,14 @@ const handlePortalAppClick = async (app: PortalApp) => {
     }
   }
 
+  if (app.label === "智能钻井") {
+    if (userStore.getUser.username && getAccessToken()) {
+      window.open(`http://172.21.0.224:8001/#/login`, "_blank");
+    } else {
+      router.push({ path: "/login" });
+    }
+  }
+
   if (app.label === "鸿盘") {
     if (userStore.getUser.username && getAccessToken()) {
       window.open(`https://pan.keruioil.com:52180`, "_blank");
@@ -841,6 +880,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");
 };
@@ -1069,12 +1154,17 @@ onUnmounted(() => {
   display: flex;
   align-items: baseline;
   gap: 28px;
+  min-width: 0;
 }
 
 .platform-block__title {
   color: #fff;
   font-size: 18px;
-
+  min-width: 0;
+  flex: 0 1 auto;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
   letter-spacing: 0.03em;
 }
 
@@ -1082,6 +1172,8 @@ onUnmounted(() => {
   color: rgba(255, 255, 255, 0.95);
   font-size: 13px;
   font-weight: 600;
+  flex: 0 0 auto;
+  white-space: nowrap;
 }
 
 .platform-block__watermark {
@@ -1094,12 +1186,15 @@ onUnmounted(() => {
 
 .platform-app {
   display: flex;
+  width: 100%;
+  min-width: 0;
   min-height: 34px;
   align-items: center;
   justify-content: flex-start;
-  gap: 12px;
+  gap: 5px;
   border-radius: 8px;
-  padding: 0 18px;
+  padding: 0 5px;
+
   font-size: 14px;
   cursor: pointer;
 
@@ -1130,8 +1225,35 @@ onUnmounted(() => {
   color: currentColor;
 }
 
+.platform-app__label {
+  min-width: 0;
+  flex: 1;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.portal-mobile-shortcuts {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 8px;
+}
+
+.portal-mobile-shortcut {
+  min-width: 0;
+  border: 0;
+  border-radius: 10px;
+  padding: 10px 8px;
+  background: linear-gradient(180deg, #ffffff 0%, #5b90e4 100%);
+  color: #0d4a9d;
+  font-size: 13px;
+  font-weight: 600;
+  box-shadow: 0 6px 16px rgba(58, 110, 187, 0.08);
+}
+
 .side-card {
   overflow: hidden;
+
   background: #e8f1f8;
   /* box-shadow: 0 12px 28px rgba(58, 110, 187, 0.06); */
 }
@@ -1385,4 +1507,61 @@ onUnmounted(() => {
   transition: all 0.3s ease;
   box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8);
 }
+
+.carousel-container {
+  position: relative;
+  /* 确保容器在移动端有合适的高度,或者使用 aspect-ratio */
+  height: 220px;
+  width: 100%;
+  overflow: hidden;
+}
+
+.carousel-slide {
+  position: absolute;
+  inset: 0; /* 确保填满父容器 */
+  width: 100%;
+  height: 100%;
+}
+
+.hero-visual {
+  position: absolute;
+  inset: 0; /* 关键:让背景层填满整个 slide */
+  width: 100%;
+  height: 100%;
+}
+
+.hero-visual img {
+  width: 100%;
+  height: 100%;
+  /* 关键修改:使用 cover 确保图片填满容器且不留白,即使部分被裁剪 */
+  object-fit: cover;
+  /* 如果希望完整显示图片但可能有留白,改用 object-fit: contain; 但通常 banner 推荐 cover */
+  display: block; /* 消除 img 默认的底部间隙 */
+}
+
+/* 移动端适配优化 */
+@media (max-width: 768px) {
+  .carousel-container {
+    /* 移动端可以适当减小高度,或者保持比例 */
+    height: 220px;
+  }
+
+  .carousel-caption {
+    /* 移动端调整文字位置,避免遮挡图片或超出屏幕 */
+    left: 20px;
+    right: 20px;
+    top: 50%;
+    transform: translateY(-50%);
+    text-align: center; /* 移动端居中通常更好看 */
+  }
+
+  .hero-script {
+    font-size: 40px; /* 适当减小字体 */
+  }
+
+  .hero-text {
+    font-size: 16px;
+    margin-top: 10px;
+  }
+}
 </style>

+ 2 - 2
src/views/login.vue

@@ -21,7 +21,7 @@
         <h1 class="text-2xl font-bold text-center">登录</h1>
 
         <!-- 用户名密码登陆 -->
-        <!-- <div>
+        <div>
           <el-form
             :model="form"
             :rules="rules"
@@ -62,7 +62,7 @@
               >
             </div>
           </div>
-        </div> -->
+        </div>
 
         <!-- 钉钉登陆 -->
         <div class="text-center">

+ 22 - 1
src/views/news/index.vue

@@ -3,7 +3,23 @@
     <Header />
 
     <div class="content-wrapper mt-15 max-w-[1200px] mx-auto">
-      <h2 class="page-title">新闻中心</h2>
+      <h2 class="page-title">
+        新闻中心
+
+        <el-button
+          type="primary"
+          round
+          size="small"
+          color="#02409b"
+          @click="router.back()"
+          ><Icon
+            icon="mynaui:corner-up-left"
+            class="icon pr-1"
+            width="20"
+            height="20"
+          />返回</el-button
+        >
+      </h2>
 
       <!-- 新闻列表区域 - 使用 el-table -->
       <div v-loading="loading" class="table-wrapper">
@@ -86,9 +102,12 @@ import { ref, reactive, onMounted } from "vue";
 import Header from "@components/home/header.vue";
 import Footer from "@components/home/Footer.vue";
 import { ElLoading } from "element-plus";
+import { Icon } from "@iconify/vue";
 import { getNews, ssoLogin } from "@/api/user";
 import { useUserStore } from "@/stores/useUserStore";
+import { useRouter } from "vue-router";
 const userStore = useUserStore();
+const router = useRouter();
 // 状态定义
 const loading = ref(false);
 const newsList = ref([]);
@@ -212,6 +231,8 @@ onMounted(() => {
   color: #303133;
   border-left: 5px solid #409eff;
   padding-left: 10px;
+  display: flex;
+  gap: 20px;
 }
 
 .table-wrapper {

+ 19 - 1
src/views/notices/index.vue

@@ -5,6 +5,20 @@
     <div class="content-wrapper mt-15 max-w-[1200px] mx-auto">
       <h2 class="page-title">
         {{ route.query.tabKey === "notice" ? "通知公告" : "红头文件" }}
+
+        <el-button
+          type="primary"
+          round
+          size="small"
+          color="#02409b"
+          @click="router.back()"
+          ><Icon
+            icon="mynaui:corner-up-left"
+            class="icon pr-1"
+            width="20"
+            height="20"
+          />返回</el-button
+        >
       </h2>
 
       <!-- 新闻列表区域 - 使用 el-table -->
@@ -88,11 +102,13 @@ import { ref, reactive, onMounted } from "vue";
 import Header from "@components/home/header.vue";
 import Footer from "@components/home/Footer.vue";
 import { ElLoading } from "element-plus";
+import { Icon } from "@iconify/vue";
 import { getNews, ssoLogin, getNotices, getRedHeadFiles } from "@/api/user";
 import { useUserStore } from "@/stores/useUserStore";
-import { useRoute } from "vue-router";
+import { useRoute, useRouter } from "vue-router";
 const userStore = useUserStore();
 const route = useRoute();
+const router = useRouter();
 // 状态定义
 const loading = ref(false);
 const newsList = ref([]);
@@ -218,6 +234,8 @@ onMounted(() => {
   color: #303133;
   border-left: 5px solid #409eff;
   padding-left: 10px;
+  display: flex;
+  gap: 20px;
 }
 
 .table-wrapper {