Преглед на файлове

feat: 上传组件,下载hooks

Gaokun Wang преди 2 седмици
родител
ревизия
736b42b55f

+ 2 - 0
package.json

@@ -27,6 +27,7 @@
     "@vueuse/core": "^13.5.0",
     "axios": "^1.10.0",
     "element-plus": "^2.10.4",
+    "file-saver": "^2.0.5",
     "nprogress": "^0.2.0",
     "pinia": "^3.0.3",
     "pinia-plugin-persistedstate": "^4.4.1",
@@ -39,6 +40,7 @@
     "@commitlint/cli": "^19.8.1",
     "@commitlint/config-conventional": "^19.8.1",
     "@eslint/js": "^9.31.0",
+    "@types/file-saver": "^2.0.7",
     "@types/node": "^24.0.13",
     "@types/node-forge": "^1.3.13",
     "@types/nprogress": "^0.2.3",

+ 16 - 0
pnpm-lock.yaml

@@ -20,6 +20,9 @@ importers:
       element-plus:
         specifier: ^2.10.4
         version: 2.10.4(vue@3.5.17(typescript@5.8.3))
+      file-saver:
+        specifier: ^2.0.5
+        version: 2.0.5
       nprogress:
         specifier: ^0.2.0
         version: 0.2.0
@@ -51,6 +54,9 @@ importers:
       '@eslint/js':
         specifier: ^9.31.0
         version: 9.31.0
+      '@types/file-saver':
+        specifier: ^2.0.7
+        version: 2.0.7
       '@types/node':
         specifier: ^24.0.13
         version: 24.0.13
@@ -875,6 +881,9 @@ packages:
   '@types/estree@1.0.8':
     resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
 
+  '@types/file-saver@2.0.7':
+    resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==}
+
   '@types/json-schema@7.0.15':
     resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
 
@@ -1930,6 +1939,9 @@ packages:
     resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
     engines: {node: '>=16.0.0'}
 
+  file-saver@2.0.5:
+    resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==}
+
   filelist@1.0.4:
     resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
 
@@ -4493,6 +4505,8 @@ snapshots:
 
   '@types/estree@1.0.8': {}
 
+  '@types/file-saver@2.0.7': {}
+
   '@types/json-schema@7.0.15': {}
 
   '@types/lodash-es@4.17.12':
@@ -5788,6 +5802,8 @@ snapshots:
     dependencies:
       flat-cache: 4.0.1
 
+  file-saver@2.0.5: {}
+
   filelist@1.0.4:
     dependencies:
       minimatch: 5.1.6

+ 7 - 7
src/api/interface/system/files.ts

@@ -15,7 +15,7 @@ export interface FilesVO extends BaseEntity {
   bucket: string
   fileName: string
   originalName: string
-  sizeKb: string
+  sizeInfo: string
   fileSuffix: string
 }
 
@@ -24,10 +24,10 @@ export interface FilesVO extends BaseEntity {
  */
 export interface FilesBO {
   fileId: string
-  engine: string
-  bucket: string
-  fileName: string
-  originalName: string
-  sizeKb: string
-  fileSuffix: string
+  engine?: string
+  bucket?: string
+  fileName?: string
+  originalName?: string
+  sizeInfo?: string
+  fileSuffix?: string
 }

+ 3 - 1
src/api/module/system/files.ts

@@ -25,6 +25,7 @@ class FilesApi {
   static add = (data: FilesBO): Promise<ResultData<any>> => {
     return http.post({ url: '/system/files/add', data })
   }
+
   /**
    * @name 更新
    * @returns returns
@@ -40,8 +41,9 @@ class FilesApi {
   static delete = (data: string[]): Promise<ResultData<any>> => {
     return http.delete({ url: '/system/files/delete', data })
   }
+
   /**
-   * @name 删除
+   * @name 删除物理文件
    * @returns returns
    */
   static deleteFile = (data: string[]): Promise<ResultData<any>> => {

+ 8 - 0
src/axios/config.ts

@@ -37,4 +37,12 @@ const defaultResponseInterceptors = (response: AxiosResponse) => {
     ElMessage.error(msg)
   }
 }
+
+export const globalHeaders = () => {
+  const userStore = useUserStore()
+  return {
+    Authorization: `Bearer ${userStore.token}`,
+    clientId: import.meta.env.VITE_APP_CLIENT_ID
+  }
+}
 export { defaultResponseInterceptors, defaultRequestInterceptors }

+ 67 - 0
src/components/Loading/index.scss

@@ -0,0 +1,67 @@
+.loading-box {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  .loading-wrap {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 98px;
+  }
+}
+.dot {
+  position: relative;
+  box-sizing: border-box;
+  display: inline-block;
+  width: 32px;
+  height: 32px;
+  font-size: 32px;
+  transform: rotate(45deg);
+  animation: ant-rotate 1.2s infinite linear;
+}
+.dot i {
+  position: absolute;
+  display: block;
+  width: 14px;
+  height: 14px;
+  background-color: var(--el-color-primary);
+  border-radius: 100%;
+  opacity: 0.3;
+  transform: scale(0.75);
+  transform-origin: 50% 50%;
+  animation: ant-spin-move 1s infinite linear alternate;
+}
+.dot i:nth-child(1) {
+  top: 0;
+  left: 0;
+}
+.dot i:nth-child(2) {
+  top: 0;
+  right: 0;
+  animation-delay: 0.4s;
+}
+.dot i:nth-child(3) {
+  right: 0;
+  bottom: 0;
+  animation-delay: 0.8s;
+}
+.dot i:nth-child(4) {
+  bottom: 0;
+  left: 0;
+  animation-delay: 1.2s;
+}
+
+@keyframes ant-rotate {
+  to {
+    transform: rotate(405deg);
+  }
+}
+
+@keyframes ant-spin-move {
+  to {
+    opacity: 1;
+  }
+}

+ 13 - 0
src/components/Loading/index.vue

@@ -0,0 +1,13 @@
+<template>
+  <div class="loading-box">
+    <div class="loading-wrap">
+      <span class="dot dot-spin"><i></i><i></i><i></i><i></i></span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts" name="Loading"></script>
+
+<style scoped lang="scss">
+@import './index';
+</style>

+ 45 - 0
src/components/Loading/loading.ts

@@ -0,0 +1,45 @@
+import { ElLoading } from 'element-plus'
+
+/* 全局请求 loading */
+let loadingInstance: ReturnType<typeof ElLoading.service>
+
+/**
+ * @description 开启 Loading
+ * */
+const startLoading = (content: string) => {
+  loadingInstance = ElLoading.service({
+    fullscreen: true,
+    lock: true,
+    text: content,
+    background: 'rgba(0, 0, 0, 0.7)'
+  })
+}
+
+/**
+ * @description 结束 Loading
+ * */
+const endLoading = () => {
+  loadingInstance.close()
+}
+
+/**
+ * @description 显示全屏加载
+ * */
+let needLoadingRequestCount = 0
+export const showLoading = (content: string = 'Loading') => {
+  if (needLoadingRequestCount === 0) {
+    startLoading(content)
+  }
+  needLoadingRequestCount++
+}
+
+/**
+ * @description 隐藏全屏加载
+ * */
+export const hideLoading = () => {
+  if (needLoadingRequestCount <= 0) return
+  needLoadingRequestCount--
+  if (needLoadingRequestCount === 0) {
+    endLoading()
+  }
+}

+ 232 - 0
src/components/Upload/UploadButton.vue

@@ -0,0 +1,232 @@
+<template>
+  <div class="upload-file">
+    <el-upload
+      ref="uploadRef"
+      :action="uploadFileUrl"
+      :file-list="_fileList"
+      class="upload-file-uploader"
+      :show-file-list="false"
+      :multiple="true"
+      :disabled="self_disabled"
+      :limit="limit"
+      :before-upload="beforeUpload"
+      :on-exceed="handleExceed"
+      :on-success="uploadSuccess"
+      :on-error="uploadError"
+      :accept="fileType.join(',')"
+      :headers="headers">
+      <el-button icon="Upload" type="primary">{{ text }}</el-button>
+    </el-upload>
+    <!-- 上传提示 -->
+    <div class="el-upload__tip" v-if="showTip">
+      请上传
+      <template v-if="fileSize">
+        大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
+      </template>
+      <template v-if="fileType">
+        格式为 <b style="color: #f56c6c">{{ fileType.join('/') }}</b>
+      </template>
+      的文件
+    </div>
+    <!-- 文件列表 -->
+    <transition-group class="upload-file-list el-upload-list el-upload-list--text" name="el-fade-in-linear" tag="ul">
+      <li :key="file.uid" class="el-upload-list__item ele-upload-list__item-content" v-for="(file, index) in _fileList">
+        <el-link :href="`${file.url}`" :underline="false" target="_blank">
+          <span class="document">
+            {{ file.fileName }}
+          </span>
+        </el-link>
+        <div class="ele-upload-list__item-content-action">
+          <el-link :underline="false" @click="handleRemove(index)" type="danger">删除</el-link>
+        </div>
+      </li>
+    </transition-group>
+  </div>
+</template>
+
+<script setup lang="ts" name="Upload">
+import { type UploadProps, type UploadFile, type UploadInstance, formContextKey, formItemContextKey } from 'element-plus'
+import { showLoading, hideLoading } from '@/components/Loading/loading'
+import { FilesBO, FilesVO } from '@/api/interface/system/files'
+import FilesApi from '@/api/module/system/files'
+import { listToString } from '@/utils'
+import { globalHeaders } from '@/axios/config'
+interface UploadFileProps {
+  modelValue?: string
+  disabled?: boolean // 是否禁用上传组件 ==> 非必传(默认为 false)
+  drag?: boolean // 是否支持拖拽上传 ==> 非必传(默认为 true)
+  limit?: number // 最大图片上传数 ==> 非必传(默认为 5张)
+  fileSize?: number // 图片大小限制 ==> 非必传(默认为 5M)
+  isShowTip?: boolean // 是否显示提示信息 ==> 非必传(默认为 true)
+  text?: string // 按钮文字
+  icon?: string
+  uploadApi?: string
+  fileType?: Array<string>
+}
+// 默认值
+const props = withDefaults(defineProps<UploadFileProps>(), {
+  modelValue: () => '',
+  drag: true,
+  disabled: false,
+  uploadApi: '/system/files/upload',
+  limit: 1,
+  fileSize: 5,
+  fileType: () => ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'txt', 'pdf'],
+  text: '文件上传',
+  isShowTip: true
+})
+
+const baseUrl = import.meta.env.VITE_API_PREFIX_PATH
+const uploadFileUrl = ref(`${baseUrl}${props.uploadApi}`) // 上传文件服务器地址
+const headers = ref(globalHeaders())
+const uploadRef = ref<UploadInstance>()
+const number = ref(0)
+const showTip = computed(() => props.isShowTip && (props.fileType || props.fileSize))
+const uploadList = ref<any[]>([])
+const emit = defineEmits<{
+  'update:modelValue': [value: any]
+}>()
+
+// 获取 el-form 组件上下文
+const formContext = inject(formContextKey, void 0)
+// 获取 el-form-item 组件上下文
+const formItemContext = inject(formItemContextKey, void 0)
+// 判断是否禁用上传和删除
+const self_disabled = computed(() => {
+  return props.disabled || formContext?.disabled
+})
+
+const _fileList = ref<any[]>([])
+
+// 监听 props.modelValue 列表默认值改变
+watch(
+  () => props.modelValue,
+  async (val: string) => {
+    if (val) {
+      let temp = 1
+      // 首先将值转为数组
+      let list: any[] = []
+      if (Array.isArray(val)) {
+        list = val as FilesBO[]
+      } else {
+        const res = await FilesApi.list({ fileId: val })
+        list = res.data.map((file: FilesVO) => {
+          return {
+            fileName: file.originalName,
+            fileId: file.fileId
+          }
+        })
+      }
+      // 然后将数组转为对象数组
+      _fileList.value = list.map(item => {
+        item = { name: item.fileName, url: item.fileId }
+        item.uid = new Date().getTime() + temp++
+        return item
+      })
+    } else {
+      _fileList.value = []
+      return []
+    }
+  },
+  { deep: true, immediate: true }
+)
+/**
+ * @description 文件上传之前判断
+ * @param rawFile 选择的文件
+ * */
+const beforeUpload: UploadProps['beforeUpload'] = rawFile => {
+  // 校验文件格式
+  const fileName = rawFile.name.split('.')
+  const fileExt = fileName[fileName.length - 1]
+  const isTypeOk = props.fileType.indexOf(fileExt) >= 0
+  // 校检文件大小
+  const isLt = rawFile.size / 1024 / 1024 < props.fileSize
+  if (!isTypeOk) {
+    ElMessage.error(`文件格式不正确, 请上传${props.fileType.join('/')}格式文件!`)
+    return false
+  }
+  if (!isLt) {
+    ElMessage.error(`文件大小不能超过 ${props.fileSize}M!`)
+    return false
+  }
+  number.value++
+  showLoading('正在上传文件,请稍候...')
+  return isTypeOk && isLt
+}
+
+/**
+ * @description 文件上传成功
+ * @param response 上传响应结果
+ * @param uploadFile 上传的文件
+ * */
+const uploadSuccess = (response: any | undefined, uploadFile: UploadFile) => {
+  if (response.code !== 200) {
+    number.value--
+    ElMessage.error(response.msg)
+    uploadRef.value?.handleRemove(uploadFile)
+    uploadedSuccessfully()
+    return
+  }
+  uploadList.value.push({ fileName: response.data.fileName, fileId: response.data.fileId })
+  uploadedSuccessfully()
+}
+
+// 上传结束处理
+const uploadedSuccessfully = () => {
+  if (number.value > 0 && uploadList.value.length === number.value) {
+    _fileList.value = _fileList.value.filter(f => f.url !== undefined).concat(uploadList.value)
+    uploadList.value = []
+    number.value = 0
+    emit('update:modelValue', listToString(_fileList.value))
+    hideLoading()
+  }
+  // 监听表单验证
+  formItemContext?.prop && formContext?.validateField([formItemContext.prop as string])
+}
+
+/**
+ * @description 删除图片
+ * @param file 删除的文件
+ * */
+const handleRemove = (index: number) => {
+  const fileId = _fileList.value[index].fileId
+  FilesApi.deleteFile([fileId])
+  _fileList.value.splice(index, 1)
+  emit('update:modelValue', listToString(_fileList.value))
+}
+
+/**
+ * @description 文件上传错误
+ * */
+const uploadError = () => {
+  ElMessage.error('文件上传失败,请您重新上传!')
+}
+
+/**
+ * @description 文件数超出
+ * */
+const handleExceed = () => {
+  ElMessage.warning(`当前最多只能上传 ${props.limit} 个文件 ,请移除后上传!`)
+}
+</script>
+
+<style scoped lang="scss">
+.upload-file-uploader {
+  margin-bottom: 5px;
+}
+.upload-file-list .el-upload-list__item {
+  position: relative;
+  margin-bottom: 10px;
+  line-height: 2;
+  border: 1px solid #e4e7ed;
+}
+.upload-file-list .ele-upload-list__item-content {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  color: inherit;
+}
+.ele-upload-list__item-content-action .el-link {
+  margin-right: 10px;
+}
+</style>

+ 234 - 0
src/components/Upload/UploadDrop.vue

@@ -0,0 +1,234 @@
+<template>
+  <div class="upload-file">
+    <el-upload
+      ref="uploadRef"
+      drag
+      :action="uploadFileUrl"
+      :file-list="_fileList"
+      class="upload-file-uploader"
+      :show-file-list="false"
+      :multiple="true"
+      :disabled="self_disabled"
+      :limit="limit"
+      :before-upload="beforeUpload"
+      :on-exceed="handleExceed"
+      :on-success="uploadSuccess"
+      :on-error="uploadError"
+      :accept="fileType.join(',')"
+      :headers="headers">
+      <el-icon class="el-icon--upload"><upload-filled /></el-icon>
+      <div class="el-upload__text">拖动文件到此区域进行上传或 <em>点击上传</em></div>
+    </el-upload>
+    <!-- 上传提示 -->
+    <div class="el-upload__tip" v-if="showTip">
+      请上传
+      <template v-if="fileSize">
+        大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
+      </template>
+      <template v-if="fileType">
+        格式为 <b style="color: #f56c6c">{{ fileType.join('/') }}</b>
+      </template>
+      的文件
+    </div>
+    <!-- 文件列表 -->
+    <transition-group class="upload-file-list el-upload-list el-upload-list--text" name="el-fade-in-linear" tag="ul">
+      <li :key="file.uid" class="el-upload-list__item ele-upload-list__item-content" v-for="(file, index) in _fileList">
+        <el-link :href="`${file.url}`" :underline="false" target="_blank">
+          <span class="document">
+            {{ file.fileName }}
+          </span>
+        </el-link>
+        <div class="ele-upload-list__item-content-action">
+          <el-link :underline="false" @click="handleRemove(index)" type="danger">删除</el-link>
+        </div>
+      </li>
+    </transition-group>
+  </div>
+</template>
+
+<script setup lang="ts" name="Upload">
+import { type UploadProps, type UploadFile, type UploadInstance, formContextKey, formItemContextKey } from 'element-plus'
+import { showLoading, hideLoading } from '@/components/Loading/loading'
+import { FilesBO, FilesVO } from '@/api/interface/system/files'
+import FilesApi from '@/api/module/system/files'
+import { listToString } from '@/utils'
+import { globalHeaders } from '@/axios/config'
+interface UploadFileProps {
+  modelValue?: string
+  disabled?: boolean // 是否禁用上传组件 ==> 非必传(默认为 false)
+  drag?: boolean // 是否支持拖拽上传 ==> 非必传(默认为 true)
+  limit?: number // 最大图片上传数 ==> 非必传(默认为 5张)
+  fileSize?: number // 图片大小限制 ==> 非必传(默认为 5M)
+  isShowTip?: boolean // 是否显示提示信息 ==> 非必传(默认为 true)
+  text?: string // 按钮文字
+  icon?: string
+  uploadApi?: string
+  fileType?: Array<string>
+}
+// 默认值
+const props = withDefaults(defineProps<UploadFileProps>(), {
+  modelValue: () => '',
+  drag: true,
+  disabled: false,
+  uploadApi: '/system/files/upload',
+  limit: 1,
+  fileSize: 5,
+  fileType: () => ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'txt', 'pdf'],
+  text: '文件上传',
+  isShowTip: true
+})
+
+const baseUrl = import.meta.env.VITE_API_PREFIX_PATH
+const uploadFileUrl = ref(`${baseUrl}${props.uploadApi}`) // 上传文件服务器地址
+const headers = ref(globalHeaders())
+const uploadRef = ref<UploadInstance>()
+const number = ref(0)
+const showTip = computed(() => props.isShowTip && (props.fileType || props.fileSize))
+const uploadList = ref<any[]>([])
+const emit = defineEmits<{
+  'update:modelValue': [value: any]
+}>()
+
+// 获取 el-form 组件上下文
+const formContext = inject(formContextKey, void 0)
+// 获取 el-form-item 组件上下文
+const formItemContext = inject(formItemContextKey, void 0)
+// 判断是否禁用上传和删除
+const self_disabled = computed(() => {
+  return props.disabled || formContext?.disabled
+})
+
+const _fileList = ref<any[]>([])
+
+// 监听 props.modelValue 列表默认值改变
+watch(
+  () => props.modelValue,
+  async (val: string) => {
+    if (val) {
+      let temp = 1
+      // 首先将值转为数组
+      let list: any[] = []
+      if (Array.isArray(val)) {
+        list = val as FilesBO[]
+      } else {
+        const res = await FilesApi.list({ fileId: val })
+        list = res.data.map((file: FilesVO) => {
+          return {
+            fileName: file.originalName,
+            fileId: file.fileId
+          }
+        })
+      }
+      // 然后将数组转为对象数组
+      _fileList.value = list.map(item => {
+        item = { name: item.fileName, url: item.fileId }
+        item.uid = new Date().getTime() + temp++
+        return item
+      })
+    } else {
+      _fileList.value = []
+      return []
+    }
+  },
+  { deep: true, immediate: true }
+)
+/**
+ * @description 文件上传之前判断
+ * @param rawFile 选择的文件
+ * */
+const beforeUpload: UploadProps['beforeUpload'] = rawFile => {
+  // 校验文件格式
+  const fileName = rawFile.name.split('.')
+  const fileExt = fileName[fileName.length - 1]
+  const isTypeOk = props.fileType.indexOf(fileExt) >= 0
+  // 校检文件大小
+  const isLt = rawFile.size / 1024 / 1024 < props.fileSize
+  if (!isTypeOk) {
+    ElMessage.error(`文件格式不正确, 请上传${props.fileType.join('/')}格式文件!`)
+    return false
+  }
+  if (!isLt) {
+    ElMessage.error(`文件大小不能超过 ${props.fileSize}M!`)
+    return false
+  }
+  number.value++
+  showLoading('正在上传文件,请稍候...')
+  return isTypeOk && isLt
+}
+
+/**
+ * @description 文件上传成功
+ * @param response 上传响应结果
+ * @param uploadFile 上传的文件
+ * */
+const uploadSuccess = (response: any | undefined, uploadFile: UploadFile) => {
+  if (response.code !== 200) {
+    number.value--
+    ElMessage.error(response.msg)
+    uploadRef.value?.handleRemove(uploadFile)
+    uploadedSuccessfully()
+    return
+  }
+  uploadList.value.push({ fileName: response.data.fileName, fileId: response.data.fileId })
+  uploadedSuccessfully()
+}
+
+// 上传结束处理
+const uploadedSuccessfully = () => {
+  if (number.value > 0 && uploadList.value.length === number.value) {
+    _fileList.value = _fileList.value.filter(f => f.url !== undefined).concat(uploadList.value)
+    uploadList.value = []
+    number.value = 0
+    emit('update:modelValue', listToString(_fileList.value))
+    hideLoading()
+  }
+  // 监听表单验证
+  formItemContext?.prop && formContext?.validateField([formItemContext.prop as string])
+}
+
+/**
+ * @description 删除图片
+ * @param file 删除的文件
+ * */
+const handleRemove = (index: number) => {
+  const fileId = _fileList.value[index].fileId
+  FilesApi.deleteFile([fileId])
+  _fileList.value.splice(index, 1)
+  emit('update:modelValue', listToString(_fileList.value))
+}
+
+/**
+ * @description 文件上传错误
+ * */
+const uploadError = () => {
+  ElMessage.error('文件上传失败,请您重新上传!')
+}
+
+/**
+ * @description 文件数超出
+ * */
+const handleExceed = () => {
+  ElMessage.warning(`当前最多只能上传 ${props.limit} 个文件 ,请移除后上传!`)
+}
+</script>
+
+<style scoped lang="scss">
+.upload-file-uploader {
+  margin-bottom: 5px;
+}
+.upload-file-list .el-upload-list__item {
+  position: relative;
+  margin-bottom: 10px;
+  line-height: 2;
+  border: 1px solid #e4e7ed;
+}
+.upload-file-list .ele-upload-list__item-content {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  color: inherit;
+}
+.ele-upload-list__item-content-action .el-link {
+  margin-right: 10px;
+}
+</style>

+ 1 - 0
src/hooks/index.ts

@@ -2,3 +2,4 @@ export * from './useTable'
 export * from './useSelection'
 export * from './useDictOptions'
 export * from './useHandleData'
+export * from './useDownload'

+ 114 - 0
src/hooks/useDownload.ts

@@ -0,0 +1,114 @@
+import { checkStatus } from '@/axios/checkStatus'
+import { globalHeaders } from '@/axios/config'
+import { hideLoading, showLoading } from '@/components/Loading/loading'
+import { blobValidate } from '@/utils'
+import axios from 'axios'
+import { ElNotification } from 'element-plus'
+import { saveAs } from 'file-saver'
+/**
+ * @description 接收数据流生成 blob,创建链接,下载文件
+ * @param {Function} api 导出表格的api方法 (必传)
+ * @param {String} tempName 导出的文件名 (必传)
+ * @param {Object} params 导出的参数 (默认{})
+ * @param {Boolean} isNotify 是否有导出消息提示 (默认为 true)
+ * @param {String} fileType 导出的文件格式 (默认为.xlsx)
+ * */
+export const useDownload = async (
+  api: (param: any) => Promise<any>,
+  tempName: string,
+  params: any = {},
+  isNotify: boolean = false,
+  fileType: string = '.xlsx',
+  fileName?: string
+) => {
+  if (isNotify) {
+    ElNotification({
+      title: '温馨提示',
+      message: '如果数据庞大会导致下载缓慢哦,请您耐心等待!',
+      type: 'info',
+      duration: 3000
+    })
+  }
+  const saveAsFn = (text: string | Blob, name: string | undefined, opts?: any) => {
+    saveAs(text, name, opts)
+  }
+  const printErrMsg = async data => {
+    const resText = await data.text()
+    const rspObj = JSON.parse(resText)
+    checkStatus(rspObj.code)
+  }
+  try {
+    const data = await api(params)
+    const isBlob = await blobValidate(data)
+    if (isBlob) {
+      const blob = fileType == 'zip' ? new Blob([data], { type: 'application/zip' }) : new Blob([data])
+      const name = fileType == 'zip' ? fileName : `${tempName}_${new Date().getTime()}${fileType}`
+      saveAsFn(blob, name, null)
+      console.log('%s ====>>>导出成功', tempName)
+    } else {
+      printErrMsg(data)
+    }
+  } catch (error) {
+    console.log(error)
+    ElMessage.error('下载文件出现错误,请联系管理员!')
+  }
+}
+
+const baseURL = import.meta.env.VITE_API_PREFIX_PATH
+
+export default {
+  async download(fileId: string) {
+    const url = baseURL + '/system/files/download/' + fileId
+    showLoading('正在下载数据,请稍候...')
+    try {
+      const res = await axios({
+        method: 'get',
+        url: url,
+        responseType: 'blob',
+        headers: globalHeaders()
+      })
+      const isBlob = blobValidate(res.data)
+      if (isBlob) {
+        const blob = new Blob([res.data], { type: 'application/octet-stream;charset=UTF-8' })
+        saveAs(blob, decodeURIComponent(res.headers['download-filename'] as string))
+      } else {
+        this.printErrMsg(res.data)
+      }
+      hideLoading()
+    } catch (r) {
+      console.error(r)
+      ElMessage.error('下载文件出现错误,请联系管理员!')
+      hideLoading()
+    }
+  },
+  async printErrMsg(data: any) {
+    const resText = await data.text()
+    const rspObj = JSON.parse(resText)
+    checkStatus(rspObj.code)
+  },
+  async zip(url: string, name: string) {
+    url = baseURL + url
+    showLoading('正在下载数据,请稍候...')
+
+    try {
+      const res = await axios({
+        method: 'get',
+        url: url,
+        responseType: 'blob',
+        headers: globalHeaders()
+      })
+      const isBlob = blobValidate(res.data)
+      if (isBlob) {
+        const blob = new Blob([res.data], { type: 'application/zip' })
+        saveAs(blob, name)
+      } else {
+        this.printErrMsg(res.data)
+      }
+      hideLoading()
+    } catch (r) {
+      console.error(r)
+      ElMessage.error('下载文件出现错误,请联系管理员!')
+      hideLoading()
+    }
+  }
+}

+ 7 - 1
src/types/auto-components.d.ts

@@ -20,7 +20,7 @@ declare module 'vue' {
     Avatar: typeof import('./../layouts/components/AppTools/Avatar.vue')['default']
     ColSetting: typeof import('./../components/ProTable/ColSetting.vue')['default']
     ConfigDrawer: typeof import('./../views/system/config/components/ConfigDrawer.vue')['default']
-    copy: typeof import('./../views/system/user/components/AddRoleDialog copy.vue')['default']
+    copy: typeof import('./../components/Upload/UploadButton copy.vue')['default']
     DictDrawer: typeof import('./../views/system/dict/components/DictDrawer.vue')['default']
     EcoDialog: typeof import('./../components/EcoDialog/index.vue')['default']
     EcoDialogForm: typeof import('./../components/EcoDialogForm/index.vue')['default']
@@ -47,6 +47,7 @@ declare module 'vue' {
     ElIcon: typeof import('element-plus/es')['ElIcon']
     ElInput: typeof import('element-plus/es')['ElInput']
     ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
+    ElLink: typeof import('element-plus/es')['ElLink']
     ElMain: typeof import('element-plus/es')['ElMain']
     ElMenu: typeof import('element-plus/es')['ElMenu']
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
@@ -70,12 +71,14 @@ declare module 'vue' {
     ElTransfer: typeof import('element-plus/es')['ElTransfer']
     ElTree: typeof import('element-plus/es')['ElTree']
     ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
+    ElUpload: typeof import('element-plus/es')['ElUpload']
     FilesDrawer: typeof import('./../views/system/files/components/FilesDrawer.vue')['default']
     Form: typeof import('./../views/system/org/components/form.vue')['default']
     Grid: typeof import('./../components/Grid/index.vue')['default']
     GridItem: typeof import('./../components/Grid/GridItem.vue')['default']
     HelloWorld: typeof import('./../components/HelloWorld.vue')['default']
     IconChoose: typeof import('./../components/IconChoose/index.vue')['default']
+    Loading: typeof import('./../components/Loading/index.vue')['default']
     LoginForm: typeof import('./../views/login/components/LoginForm.vue')['default']
     MenuDrawer: typeof import('./../views/system/menu/components/MenuDrawer.vue')['default']
     MenuItem: typeof import('./../layouts/components/AppMenu/MenuItem.vue')['default']
@@ -98,6 +101,9 @@ declare module 'vue' {
     SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
     TableColumn: typeof import('./../components/ProTable/TableColumn.vue')['default']
     TreeFilter: typeof import('./../components/TreeFilter/index.vue')['default']
+    Upload: typeof import('./../components/Upload/index.vue')['default']
+    UploadButton: typeof import('./../components/Upload/UploadButton.vue')['default']
+    UploadDrop: typeof import('./../components/Upload/UploadDrop.vue')['default']
     UserDrawer: typeof import('./../views/system/user/components/UserDrawer.vue')['default']
   }
   export interface GlobalDirectives {

+ 17 - 0
src/utils/index.ts

@@ -449,3 +449,20 @@ export const deepCloneTableRow = (obj: any, hash = new WeakMap()): Record<string
 
   return newObj
 }
+
+// 对象转成指定字符串分隔
+export const listToString = (list: any[], separator?: string) => {
+  let strings = ''
+  separator = separator || ','
+  for (const i in list) {
+    if (undefined !== list[i].ossId && list[i].url.indexOf('blob:') !== 0) {
+      strings += list[i].ossId + separator
+    }
+  }
+  return strings != '' ? strings.substring(0, strings.length - 1) : ''
+}
+
+// 验证是否为blob格式
+export function blobValidate(data: any) {
+  return data.type !== 'application/json'
+}

+ 26 - 6
src/views/system/files/index.vue

@@ -3,34 +3,46 @@
     <ProTable ref="proTableRef" title="参数配置" row-key="filesId" :columns="columns" :search-columns="searchColumns" :request-api="getTableList">
       <!-- 表格 header 按钮 -->
       <template #tableHeader="scope">
-        <el-button type="primary" icon="CirclePlus" @click="upload()"> 上传 </el-button>
+        <el-button type="primary" icon="Upload" @click="upload()"> 上传Dialog </el-button>
+        <el-button type="primary" icon="Upload" @click="upload1()"> 上传Drawer </el-button>
         <el-button type="danger" icon="Delete" plain :disabled="!scope.isSelected" @click="batchDelete(scope.selectedListIds)"> 批量删除 </el-button>
       </template>
 
       <template #operation="{ row }">
         <el-button type="primary" link icon="Delete" @click="deleteRow(row)"> 删除 </el-button>
+        <el-button type="primary" link icon="Download" @click="useDownload.download(row.fileId)"> 下载 </el-button>
       </template>
     </ProTable>
+    <EcoDialog :height="300" width="500px" v-model="visible" title="文件上传" @confirm="confirm">
+      <UploadDrop></UploadDrop>
+      <UploadButton />
+    </EcoDialog>
+    <EcoDrawer v-model="visible1" title="文件上传" @confirm="confirm">
+      <UploadDrop></UploadDrop>
+      <UploadButton />
+    </EcoDrawer>
   </div>
 </template>
 <script lang="tsx" setup name="FilesManage">
 import FilesApi from '@/api/module/system/files'
 import { ColumnProps, ProTableInstance, SearchProps } from '@/components/ProTable/interface'
 import { useHandleData } from '@/hooks'
+import useDownload from '@/hooks/useDownload'
 import { FilesQuery, FilesVO } from '@/api/interface/system/files'
 const proTableRef = ref<ProTableInstance>()
-
+const visible = ref(false)
+const visible1 = ref(false)
 // 表格配置项
 const columns: ColumnProps<FilesVO>[] = [
   { type: 'selection', width: 60 },
   { type: 'index', width: 60 },
   { prop: 'originalName', label: '文件名称' },
-  { prop: 'fileName', label: '文件别名', tag: true },
+  { prop: 'fileName', label: '文件别名', tag: true, width: 260 },
   { prop: 'sizeInfo', label: '文件大小' },
   { prop: 'fileSuffix', label: '文件类型' },
   { prop: 'createByName', label: '创建人' },
-  { prop: 'createTime', label: '创建时间' },
-  { prop: 'operation', label: '操作', width: 250, fixed: 'right' }
+  { prop: 'updateTime', label: '更新时间' },
+  { prop: 'operation', label: '操作', width: 200, fixed: 'right' }
 ]
 
 // 表格配置项
@@ -54,6 +66,14 @@ const batchDelete = async (ids: (string | number)[]) => {
   proTableRef.value?.clearSelection()
   proTableRef.value?.getTableList()
 }
+const confirm = () => {
+  proTableRef.value?.getTableList()
+}
 
-const upload = () => {}
+const upload = () => {
+  visible.value = true
+}
+const upload1 = () => {
+  visible1.value = true
+}
 </script>