瀏覽代碼

feat: 文件上传

wanggaokun 1 年之前
父節點
當前提交
b850332a89

+ 8 - 1
README.md

@@ -1,3 +1,10 @@
 # km-web
 
-前端脚手架
+```
+yarn install
+
+## 启动
+yarn serve
+```
+
+前端脚手架

+ 2 - 0
package.json

@@ -48,6 +48,7 @@
     "echarts-liquidfill": "^3.1.0",
     "element-plus": "^2.4.3",
     "file-saver": "^2.0.5",
+    "image-conversion": "^2.1.1",
     "js-cookie": "^3.0.5",
     "jsencrypt": "^3.3.2",
     "md5": "^2.3.0",
@@ -64,6 +65,7 @@
     "vue-cropper": "^1.1.2",
     "vue-i18n": "^9.6.4",
     "vue-router": "^4.2.5",
+    "vue-types": "^5.1.1",
     "vuedraggable": "^4.1.0"
   },
   "devDependencies": {

+ 7 - 0
src/api/index.ts

@@ -31,6 +31,13 @@ const config = {
 const encryptHeader = 'encrypt-key'
 // 是否显示重新登录
 export let isReLogin = { show: false }
+
+export const globalHeaders = () => {
+  return {
+    Authorization: 'Bearer ' + getToken(),
+    clientid: import.meta.env.VITE_APP_CLIENT_ID
+  }
+}
 axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
 axios.defaults.headers['clientid'] = import.meta.env.VITE_APP_CLIENT_ID
 const axiosCanceler = new AxiosCanceler()

+ 25 - 0
src/api/interface/system/config.ts

@@ -0,0 +1,25 @@
+import { PageQuery, BaseEntity } from '@/api/interface/index'
+export interface ConfigVO extends BaseEntity {
+  configId: number | string
+  configName: string
+  configKey: string
+  configValue: string
+  configType: string
+  remark: string
+}
+
+export interface ConfigForm {
+  configId: number | string | undefined
+  configName: string
+  configKey: string
+  configValue: string
+  configType: string
+  version: number
+  remark: string
+}
+
+export interface ConfigQuery extends PageQuery {
+  configName: string
+  configKey: string
+  configType: string
+}

+ 24 - 0
src/api/interface/system/oss.ts

@@ -0,0 +1,24 @@
+import { PageQuery, BaseEntity } from '@/api/interface/index'
+
+export interface OssVO extends BaseEntity {
+  ossId: string | number
+  fileName: string
+  originalName: string
+  fileSuffix: string
+  url: string
+  createByName: string
+  service: string
+}
+
+export interface OssQuery extends PageQuery {
+  fileName: string
+  originalName: string
+  fileSuffix: string
+  createTime: string
+  service: string
+  orderByColumn: string
+  isAsc: string
+}
+export interface OssForm {
+  file: undefined | string
+}

+ 41 - 0
src/api/interface/system/ossConfig.ts

@@ -0,0 +1,41 @@
+import { PageQuery, BaseEntity } from '@/api/interface/index'
+
+export interface OssConfigVO extends BaseEntity {
+  ossConfigId: number | string
+  configKey: string
+  accessKey: string
+  secretKey: string
+  bucketName: string
+  prefix: string
+  endpoint: string
+  domain: string
+  isHttps: string
+  region: string
+  status: string
+  ext1: string
+  remark: string
+  accessPolicy: string
+}
+
+export interface OssConfigQuery extends PageQuery {
+  configKey: string
+  bucketName: string
+  status: string
+}
+
+export interface OssConfigForm {
+  ossConfigId: string | number | undefined
+  configKey: string
+  accessKey: string
+  secretKey: string
+  bucketName: string
+  prefix: string
+  endpoint: string
+  domain: string
+  isHttps: string
+  accessPolicy: string
+  region: string
+  status: string
+  version: number
+  remark: string
+}

+ 86 - 0
src/api/modules/system/config.ts

@@ -0,0 +1,86 @@
+import { ConfigVO } from '@/api/interface/system/config'
+import http from '@/api'
+/**
+ * @name 根据参数键名查询参数值
+ * @param ossConfigId ossConfigId
+ * @returns returns
+ */
+export const getConfigKeyApi = (configKey: string) => {
+  return http.get<ConfigVO>(`/system/config/configKey/${configKey}`)
+}
+/**
+ * @name 查询参数配置列表
+ * @param query 参数
+ * @returns 返回列表
+ */
+export const listConfigApi = (query: any) => {
+  return http.get<any>('/system/config/list', query, { loading: true })
+}
+
+/**
+ * @name 查询参数配置详细
+ * @param configId configId
+ * @returns returns
+ */
+export const getConfigApi = (configId: any) => {
+  return http.get<any>(`/system/config/${configId}`)
+}
+
+/**
+ * @name 新增参数配置
+ * @param data data
+ * @returns returns
+ */
+export const addConfigApi = (data: any) => {
+  return http.post<any>('/system/config', data, { loading: false })
+}
+
+/**
+ * @name 修改参数配置
+ * @param data data
+ * @returns returns
+ */
+export const updateConfigApi = (data: any) => {
+  return http.put<any>('/system/config', data, { loading: false })
+}
+/**
+ * @name 修改参数配置
+ * @param data data
+ * @returns returns
+ */
+export const updateConfigByKeyApi = (data: any) => {
+  return http.put<any>('/system/config/updateByKey', data, { loading: false })
+}
+
+/**
+ * @name 删除参数配置
+ * @param configId configId
+ * @returns returns
+ */
+export const delConfigApi = (configId: any) => {
+  return http.delete<any>(`/system/config/${configId}`)
+}
+
+/**
+ * @name 下载模板
+ * @returns returns
+ */
+export const importTemplateApi = () => {
+  return http.downloadPost('/system/config/importTemplate', {})
+}
+
+/**
+ * @name 导入数据
+ * @returns returns
+ */
+export const importConfigDataApi = (data: any) => {
+  return http.post('/system/config/importData', data)
+}
+
+/**
+ * @name 导出数据
+ * @returns returns
+ */
+export const exportConfigApi = (data: any) => {
+  return http.downloadPost('/system/config/export', data)
+}

+ 78 - 0
src/api/modules/system/oss.ts

@@ -0,0 +1,78 @@
+import http from '@/api'
+
+/**
+ * @name 查询OSS对象存储列表
+ * @param query 参数
+ * @returns 返回列表
+ */
+export const listOssApi = (query: any) => {
+  return http.get<any>('/resource/oss/list', query, { loading: true })
+}
+
+/**
+ * @name 查询OSS对象存储详细
+ * @param ossId ossId
+ * @returns returns
+ */
+export const getOssApi = (ossId: any) => {
+  return http.get<any>(`/resource/oss/${ossId}`)
+}
+/**
+ * @name 查询OSS对象基于id串
+ * @param ossId ossId
+ * @returns returns
+ */
+export const getListByIdsApi = (ossIds: any) => {
+  return http.get<any>(`/resource/oss/listByIds/${ossIds}`)
+}
+
+/**
+ * @name 新增OSS对象存储
+ * @param data data
+ * @returns returns
+ */
+export const addOssApi = (data: any) => {
+  return http.post<any>('/resource/oss', data, { loading: false })
+}
+
+/**
+ * @name 修改OSS对象存储
+ * @param data data
+ * @returns returns
+ */
+export const updateOssApi = (data: any) => {
+  return http.put<any>('/resource/oss', data, { loading: false })
+}
+
+/**
+ * @name 删除OSS对象存储
+ * @param ossId ossId
+ * @returns returns
+ */
+export const delOssApi = (ossId: any) => {
+  return http.delete<any>(`/resource/oss/${ossId}`)
+}
+
+/**
+ * @name 下载模板
+ * @returns returns
+ */
+export const importTemplateApi = () => {
+  return http.downloadPost('/resource/oss/importTemplate', {})
+}
+
+/**
+ * @name 导入数据
+ * @returns returns
+ */
+export const importOssDataApi = (data: any) => {
+  return http.post('/resource/oss/importData', data)
+}
+
+/**
+ * @name 导出数据
+ * @returns returns
+ */
+export const exportOssApi = (data: any) => {
+  return http.downloadPost('/resource/oss/export', data)
+}

+ 77 - 0
src/api/modules/system/ossConfig.ts

@@ -0,0 +1,77 @@
+import http from '@/api'
+/**
+ * @name 查询对象存储配置列表
+ * @param query 参数
+ * @returns 返回列表
+ */
+export const listOssConfigApi = (query: any) => {
+  return http.get<any>('/resource/oss/config/list', query, { loading: true })
+}
+
+/**
+ * @name 查询对象存储配置详细
+ * @param ossConfigId ossConfigId
+ * @returns returns
+ */
+export const getOssConfigApi = (ossConfigId: any) => {
+  return http.get<any>(`/resource/oss/config/${ossConfigId}`)
+}
+
+/**
+ * @name 新增对象存储配置
+ * @param data data
+ * @returns returns
+ */
+export const addOssConfigApi = (data: any) => {
+  return http.post<any>('/resource/oss/config', data, { loading: false })
+}
+
+/**
+ * @name 修改对象存储配置
+ * @param data data
+ * @returns returns
+ */
+export const updateOssConfigApi = (data: any) => {
+  return http.put<any>('/resource/oss/config', data, { loading: false })
+}
+/**
+ * @name 对象存储状态修改
+ * @param data data
+ * @returns returns
+ */
+export const changeOssConfigStatusApi = (data: any) => {
+  return http.put<any>('/resource/oss/config/changeStatus', data, { loading: false })
+}
+
+/**
+ * @name 删除对象存储配置
+ * @param ossConfigId ossConfigId
+ * @returns returns
+ */
+export const delOssConfigApi = (ossConfigId: any) => {
+  return http.delete<any>(`/resource/oss/config/${ossConfigId}`)
+}
+
+/**
+ * @name 下载模板
+ * @returns returns
+ */
+export const importTemplateApi = () => {
+  return http.downloadPost('/resource/oss/config/importTemplate', {})
+}
+
+/**
+ * @name 导入数据
+ * @returns returns
+ */
+export const importOssConfigDataApi = (data: any) => {
+  return http.post('/resource/oss/config/importData', data)
+}
+
+/**
+ * @name 导出数据
+ * @returns returns
+ */
+export const exportOssConfigApi = (data: any) => {
+  return http.downloadPost('/resource/oss/config/export', data)
+}

+ 0 - 15
src/components/CustomDialog/index.vue

@@ -12,8 +12,6 @@
     <template #footer>
       <span class="dialog-footer">
         <slot name="footer" :parameter="parameter"></slot>
-        <!-- <el-button type="primary" v-if="parameter.isEdit" :loading="butLoading" @click="handleSubmit">确认</el-button>
-        <el-button @click="handleCancel">取消</el-button> -->
       </span>
     </template>
   </el-dialog>
@@ -42,19 +40,6 @@ const parameter = ref<FormParameterProps>({
   model: {},
   api: undefined
 })
-// 提交
-// const handleSubmit = () => {
-//   butLoading.value = true
-//   parameter.value.api!(parameter.value.model).then(res => {
-//     if (res.code == 200) {
-//       ElMessage.success('操作成功')
-//       dialogVisible.value = false
-//     } else {
-//       console.log('message', res.message)
-//     }
-//   })
-//   butLoading.value = false
-// }
 
 // 取消按钮,重置表单,关闭弹框
 const handleCancel = () => {

+ 3 - 0
src/components/FormDialog/index.vue

@@ -5,6 +5,7 @@
     :title="parameter.title"
     :destroy-on-close="true"
     :width="parameter.width"
+    :top="parameter.top"
     draggable
   >
     <ProFrom ref="proFormRef" :items-options="parameter.itemsOptions" :form-options="_options" :model="parameter.model" />
@@ -28,6 +29,7 @@ export interface FormParameterProps {
   labelWidth?: number // label宽度
   api?: (params: any) => Promise<any> // 表单提交api
   isEdit?: boolean // 是否编辑
+  top?: string // 离顶部距离
   formOptions?: ProForm.FormOptions // 表单配置
   itemsOptions: ProForm.ItemsOptions[] // 动态表单字段配置
   model?: Record<ProForm.FormItem['prop'], ProForm.FormItem['value']> // 表单数据对象
@@ -40,6 +42,7 @@ const butLoading = ref(false)
 const parameter = ref<FormParameterProps>({
   title: '',
   width: 500,
+  top: '10vh',
   itemsOptions: [],
   formOptions: {},
   isEdit: true

+ 73 - 0
src/components/ImagePreview/index.vue

@@ -0,0 +1,73 @@
+<template>
+  <el-image :src="`${realSrc}`" fit="cover" :style="`width:${realWidth};height:${realHeight};`" :preview-src-list="realSrcList" preview-teleported>
+    <template #error>
+      <div class="image-slot">
+        <el-icon><picture-filled /></el-icon>
+      </div>
+    </template>
+  </el-image>
+</template>
+
+<script setup lang="ts" name="ImgPreview">
+import { computed } from 'vue'
+
+interface ImgPreviewProps {
+  src: string
+  height?: string | number // 组件高度 ==> 非必传(默认为 150px)
+  width?: string | number // 组件宽度 ==> 非必传(默认为 150px)
+}
+
+const props = withDefaults(defineProps<ImgPreviewProps>(), {
+  src: '',
+  height: '150px',
+  width: '150px'
+})
+
+const realSrc = computed(() => {
+  if (!props.src) {
+    return
+  }
+  let real_src = props.src.split(',')[0]
+  return real_src
+})
+
+const realSrcList = computed(() => {
+  if (!props.src) {
+    return []
+  }
+  let real_src_list = props.src.split(',')
+  let srcList: string[] = []
+  real_src_list.forEach((item: string) => {
+    return srcList.push(item)
+  })
+  return srcList
+})
+
+const realWidth = computed(() => (typeof props.width == 'string' ? props.width : `${props.width}px`))
+
+const realHeight = computed(() => (typeof props.height == 'string' ? props.height : `${props.height}px`))
+</script>
+
+<style lang="scss" scoped>
+.el-image {
+  background-color: #ebeef5;
+  border-radius: 5px;
+  box-shadow: 0 0 5px 1px #cccccc;
+  :deep(.el-image__inner) {
+    cursor: pointer;
+    transition: all 0.3s;
+    &:hover {
+      transform: scale(1.2);
+    }
+  }
+  :deep(.image-slot) {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+    font-size: 30px;
+    color: #909399;
+  }
+}
+</style>

+ 4 - 4
src/components/Loading/fullScreen.ts

@@ -6,11 +6,11 @@ let loadingInstance: ReturnType<typeof ElLoading.service>
 /**
  * @description 开启 Loading
  * */
-const startLoading = () => {
+const startLoading = (content: string) => {
   loadingInstance = ElLoading.service({
     fullscreen: true,
     lock: true,
-    text: 'Loading',
+    text: content,
     background: 'rgba(0, 0, 0, 0.7)'
   })
 }
@@ -26,9 +26,9 @@ const endLoading = () => {
  * @description 显示全屏加载
  * */
 let needLoadingRequestCount = 0
-export const showFullScreenLoading = () => {
+export const showFullScreenLoading = (content: string = 'Loading') => {
   if (needLoadingRequestCount === 0) {
-    startLoading()
+    startLoading(content)
   }
   needLoadingRequestCount++
 }

+ 24 - 3
src/components/ProForm/index.vue

@@ -27,6 +27,15 @@
             <template v-else-if="item.compOptions.elTagName === 'file-upload'">
               <FileUpload v-model:model-value="formModel[item.prop]" />
             </template>
+            <template v-else-if="item.compOptions.elTagName === 'img-upload'">
+              <Imgs v-model="formModel[item.prop]" v-bind="$attrs" />
+            </template>
+            <template v-else-if="item.compOptions.elTagName === 'file-upload-s3'">
+              <FileUploadS3 v-model:model-value="formModel[item.prop]" />
+            </template>
+            <template v-else-if="item.compOptions.elTagName === 'img-upload-s3'">
+              <ImgsS3 v-model="formModel[item.prop]" v-bind="$attrs" />
+            </template>
             <Item v-else :item="item" :form-model="formModel" />
           </component>
         </el-col>
@@ -48,16 +57,20 @@
 
 <script setup lang="ts" name="ProForm">
 import { ref, computed, ComputedRef, watch, unref, provide } from 'vue'
-import type { FormInstance } from 'element-plus'
+import { ElMessage, type FormInstance } from 'element-plus'
 import Item from '@/components/ProForm/components/Item.vue'
 import SelectIcon from '@/components/SelectIcon/index.vue'
 import FileUpload from '@/components/Upload/File.vue'
+import Imgs from '@/components/Upload/Imgs.vue'
+import FileUploadS3 from '@/components/Upload/FileS3.vue'
+import ImgsS3 from '@/components/Upload/ImgsS3.vue'
 // import { handleProp } from '@/utils'
 // 表单整体配置项
 export interface ProFormProps {
   formOptions?: ProForm.FormOptions
   itemsOptions: ProForm.ItemsOptions[]
   model?: Record<ProForm.FormItem['prop'], ProForm.FormItem['value']>
+  api?: (params: any) => Promise<any> // 表单提交api
 }
 
 // 表单的数据
@@ -140,15 +153,23 @@ watch(
   },
   { immediate: true }
 )
-
 // 提交按钮
 const onSubmit = (formEl: FormInstance | undefined) => {
   console.log('表单提交数据', formModel.value)
   if (!formEl) return
   formEl.validate(valid => {
     if (valid) {
-      emits('submit', formModel.value)
+      if (props.api) emits('submit', formModel.value)
+      props.api!({ ...formModel }).then(res => {
+        if (res.code == 200) {
+          resetForm(formEl)
+          ElMessage.success('操作成功')
+        } else {
+          console.log('message', res.message)
+        }
+      })
     } else {
+      console.log('校验失败')
     }
   })
 }

+ 2 - 153
src/components/Upload/File.vue

@@ -189,169 +189,18 @@ const handleRemove = (index: number) => {
  * @description 文件上传错误
  * */
 const uploadError = () => {
-  ElNotification({
-    title: '温馨提示',
-    message: '文件上传失败,请您重新上传!',
-    type: 'error'
-  })
+  ElMessage.error('文件上传失败,请您重新上传!')
 }
 
 /**
  * @description 文件数超出
  * */
 const handleExceed = () => {
-  ElNotification({
-    title: '温馨提示',
-    message: `当前最多只能上传 ${props.limit} 个文件 ,请移除后上传!`,
-    type: 'warning'
-  })
+  ElMessage.error(`当前最多只能上传 ${props.limit} 个文件 ,请移除后上传!`)
 }
-
-/**
- * @description 图片预览
- * @param file 预览的文件
- * */
-// const viewImageUrl = ref('')
-// const imgViewVisible = ref(false)
-// const handlePictureCardPreview: UploadProps['onPreview'] = file => {
-//   viewImageUrl.value = file.url!
-//   imgViewVisible.value = true
-// }
 </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;
-// }
-// .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;
-//       box-sizing: border-box;
-//       display: flex;
-//       align-items: center;
-//       justify-content: center;
-//       width: 100%;
-//       height: 100%;
-//       cursor: pointer;
-//       background: rgb(0 0 0 / 60%);
-//       opacity: 0;
-//       transition: var(--el-transition-duration-fast);
-//       .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;
-//   }
-// }
 .upload-file-uploader {
   margin-bottom: 5px;
 }

+ 235 - 0
src/components/Upload/FileS3.vue

@@ -0,0 +1,235 @@
+<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="icon" 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="`${baseUrl}${file.url}`" :underline="false" target="_blank">
+          <span class="document">
+            {{ file.name }}
+          </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="UploadImgs">
+import { ref, computed, inject, watch } from 'vue'
+import type { UploadProps, UploadFile } from 'element-plus'
+import { ElMessage, formContextKey, formItemContextKey, UploadInstance } from 'element-plus'
+import { showFullScreenLoading, tryHideFullScreenLoading } from '@/components/Loading/fullScreen'
+import { globalHeaders } from '@/api'
+import { OssVO } from '@/api/interface/system/oss'
+import { getListByIdsApi, delOssApi } from '@/api/modules/system/oss'
+import { listToString } from '@/utils/common'
+interface UploadFileProps {
+  modelValue?: string | object | Array<any>
+  disabled?: boolean // 是否禁用上传组件 ==> 非必传(默认为 false)
+  drag?: boolean // 是否支持拖拽上传 ==> 非必传(默认为 true)
+  limit?: number // 最大图片上传数 ==> 非必传(默认为 5张)
+  fileSize?: number // 图片大小限制 ==> 非必传(默认为 5M)
+  isShowTip?: boolean // 是否显示提示信息 ==> 非必传(默认为 true)
+  text?: string // 按钮文字
+  icon?: string
+  fileType?: Array<string>
+}
+// 默认值
+const props = withDefaults(defineProps<UploadFileProps>(), {
+  modelValue: () => '',
+  drag: true,
+  disabled: false,
+  limit: 1,
+  fileSize: 5,
+  fileType: () => ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'txt', 'pdf'],
+  text: '文件上传',
+  isShowTip: true
+})
+
+const baseUrl = import.meta.env.VITE_API_URL
+const uploadFileUrl = ref(baseUrl + '/resource/oss/upload') // 上传文件服务器地址
+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 | object | Array<any>) => {
+    if (val) {
+      let temp = 1
+      // 首先将值转为数组
+      let list: any[] = []
+      if (Array.isArray(val)) {
+        list = val as OssVO[]
+      } else {
+        const res = await getListByIdsApi(val)
+        list = res.data.map(oss => {
+          return {
+            name: oss.originalName,
+            url: oss.url,
+            ossId: oss.ossId
+          }
+        })
+      }
+      // 然后将数组转为对象数组
+      _fileList.value = list.map(item => {
+        item = { name: item.name, url: item.url, ossId: item.ossId }
+        item.uid = 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++
+  showFullScreenLoading('正在上传文件,请稍候...')
+  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({ name: response.data.fileName, url: response.data.url, ossId: response.data.ossId })
+  uploadedSuccessfully()
+}
+
+// 上传结束处理
+const uploadedSuccessfully = () => {
+  debugger
+  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))
+    tryHideFullScreenLoading()
+  }
+  // 监听表单验证
+  formItemContext?.prop && formContext?.validateField([formItemContext.prop as string])
+}
+
+/**
+ * @description 删除图片
+ * @param file 删除的文件
+ * */
+const handleRemove = (index: number) => {
+  let ossId = _fileList.value[index].ossId
+  delOssApi(ossId)
+  _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>

+ 361 - 0
src/components/Upload/ImgsS3.vue

@@ -0,0 +1,361 @@
+<template>
+  <div class="upload-box">
+    <el-upload
+      :file-list="_fileList"
+      :action="uploadImgUrl"
+      list-type="picture-card"
+      :before-upload="beforeUpload"
+      :on-success="uploadSuccess"
+      :on-exceed="handleExceed"
+      :on-error="uploadError"
+      :class="['upload', self_disabled ? 'disabled' : '', drag ? 'no-border' : '', { hide: _fileList.length >= limit }]"
+      :multiple="true"
+      :headers="headers"
+      :disabled="self_disabled"
+      :limit="limit"
+      :drag="drag"
+      :accept="fileType.join(',')"
+    >
+      <div class="upload-empty">
+        <slot name="empty">
+          <el-icon><Plus /></el-icon>
+          <!-- <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="handlePictureCardPreview(file)">
+            <el-icon><ZoomIn /></el-icon>
+            <span>查看</span>
+          </div>
+          <div v-if="!self_disabled" class="handle-icon" @click="handleRemove(file)">
+            <el-icon><Delete /></el-icon>
+            <span>删除</span>
+          </div>
+        </div>
+      </template>
+    </el-upload>
+    <div v-if="showTip" class="el-upload__tip">
+      请上传
+      <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>
+    <div class="el-upload__tip">
+      <slot name="tip"></slot>
+    </div>
+    <el-image-viewer v-if="imgViewVisible" :url-list="[viewImageUrl]" @close="imgViewVisible = false" />
+  </div>
+</template>
+
+<script setup lang="ts" name="UploadImgs">
+import { ref, computed, inject, watch } from 'vue'
+import { Plus } from '@element-plus/icons-vue'
+import type { UploadProps, UploadFile } from 'element-plus'
+import { ElMessage, formContextKey, formItemContextKey } from 'element-plus'
+import { listToString } from '@/utils/common'
+import { showFullScreenLoading, tryHideFullScreenLoading } from '@/components/Loading/fullScreen'
+import { compressAccurately } from 'image-conversion'
+import { OssVO } from '@/api/interface/system/oss'
+import { globalHeaders } from '@/api'
+import { getListByIdsApi, delOssApi } from '@/api/modules/system/oss'
+import { ResultData } from '@/api/interface'
+interface UploadFileProps {
+  modelValue: string | object | Array<any>
+  drag?: boolean // 是否支持拖拽上传 ==> 非必传(默认为 true)
+  disabled?: boolean // 是否禁用上传组件 ==> 非必传(默认为 false)
+  limit?: number // 最大图片上传数 ==> 非必传(默认为 5张)
+  fileSize?: number // 图片大小限制 ==> 非必传(默认为 5M)
+  fileType?: File.ImageMimeType[] // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"])
+  height?: string // 组件高度 ==> 非必传(默认为 150px)
+  width?: string // 组件宽度 ==> 非必传(默认为 150px)
+  isShowTip?: boolean
+  borderRadius?: string // 组件边框圆角 ==> 非必传(默认为 8px)
+  compressSupport?: boolean // 是否支持图片压缩 ==> 非必传(默认为 false)
+  compressTargetSize?: number // 图片压缩目标大小 ==> 非必传(默认为 300kb)
+}
+
+const props = withDefaults(defineProps<UploadFileProps>(), {
+  modelValue: () => '',
+  drag: true,
+  disabled: false,
+  limit: 5,
+  fileSize: 5,
+  isShowTip: true,
+  compressSupport: false,
+  compressTargetSize: 300,
+  fileType: () => ['image/jpeg', 'image/png', 'image/gif'],
+  height: '150px',
+  width: '150px',
+  borderRadius: '8px'
+})
+
+const baseUrl = import.meta.env.VITE_API_URL
+const uploadImgUrl = ref(baseUrl + '/resource/oss/upload') // 上传的图片服务器地址
+const headers = ref(globalHeaders())
+// 获取 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 showTip = computed(() => props.isShowTip && (props.fileType || props.fileSize))
+const _fileList = ref<any[]>([])
+const uploadList = ref<any[]>([])
+const number = ref(0)
+const imageUploadRef = ref<ElUploadInstance>()
+// 监听 props.modelValue 列表默认值改变
+watch(
+  () => props.modelValue,
+  async (val: string | object | Array<any>) => {
+    if (val) {
+      // 首先将值转为数组
+      let list: OssVO[] = []
+      if (Array.isArray(val)) {
+        list = val as OssVO[]
+      } else {
+        const res = await getListByIdsApi(val)
+        list = res.data
+      }
+      // 然后将数组转为对象数组
+      _fileList.value = list.map(item => {
+        // 字符串回显处理 如果此处存的是url可直接回显 如果存的是id需要调用接口查出来
+        let itemData
+        if (typeof item === 'string') {
+          itemData = { name: item, url: item }
+        } else {
+          // 此处name使用ossId 防止删除出现重名
+          itemData = { name: item.ossId, url: item.url, ossId: item.ossId }
+        }
+        return itemData
+      })
+    } else {
+      _fileList.value = []
+      return []
+    }
+  },
+  { deep: true, immediate: true }
+)
+
+/**
+ * @description 文件上传之前判断
+ * @param rawFile 选择的文件
+ * */
+const beforeUpload: UploadProps['beforeUpload'] = rawFile => {
+  const imgSize = rawFile.size / 1024 / 1024 < props.fileSize
+  const imgType = props.fileType.includes(rawFile.type as File.ImageMimeType)
+  if (!imgType) {
+    ElMessage.error(`上传图片不符合所需的格式,  请上传${props.fileType.join('/')}图片格式文件!`)
+    return false
+  }
+  if (!imgSize) {
+    ElMessage.error('图片大小不能超过 ' + props.fileSize + 'M!')
+    return false
+  }
+  if (props.compressSupport && rawFile.size / 1024 > props.compressTargetSize) {
+    return compressAccurately(rawFile, props.compressTargetSize)
+  }
+  showFullScreenLoading('正在上传图片,请稍候...')
+  number.value++
+  console.log('number', number.value)
+  return imgType && imgSize
+}
+
+/**
+ * @description 图片上传成功
+ * @param response 上传响应结果
+ * @param uploadFile 上传的文件
+ * */
+const emit = defineEmits<{
+  'update:modelValue': [value: string]
+}>()
+const uploadSuccess = (response: ResultData, uploadFile: UploadFile) => {
+  if (response.code !== 200) {
+    number.value--
+    ElMessage.error(response.msg)
+    imageUploadRef.value?.handleRemove(uploadFile)
+    uploadedSuccessfully()
+    return
+  }
+  uploadList.value.push({ name: response.data.fileName, url: response.data.url, ossId: response.data.ossId })
+  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))
+    tryHideFullScreenLoading()
+  }
+  // 监听表单验证
+  formItemContext?.prop && formContext?.validateField([formItemContext.prop as string])
+}
+
+/**
+ * @description 删除图片
+ * @param file 删除的文件
+ * */
+const handleRemove = (file: UploadFile) => {
+  const fIndex = _fileList.value.map(f => f.name).indexOf(file.name)
+  if (fIndex > -1 && uploadList.value.length === number.value) {
+    let ossId = _fileList.value[fIndex].ossId
+    delOssApi(ossId)
+    _fileList.value.splice(fIndex, 1)
+    emit('update:modelValue', listToString(_fileList.value))
+    formItemContext?.prop && formContext?.validateField([formItemContext.prop as string])
+    return false
+  }
+  return true
+}
+
+/**
+ * @description 图片上传错误
+ * */
+const uploadError = () => {
+  ElMessage.error('图片上传失败,请您重新上传!')
+}
+
+/**
+ * @description 文件数超出
+ * */
+const handleExceed = () => {
+  ElMessage.warning(`当前最多只能上传 ${props.limit} 张图片,请移除后上传!`)
+}
+
+/**
+ * @description 图片预览
+ * @param file 预览的文件
+ * */
+const viewImageUrl = ref('')
+const imgViewVisible = ref(false)
+const handlePictureCardPreview: UploadProps['onPreview'] = file => {
+  viewImageUrl.value = file.url!
+  imgViewVisible.value = true
+}
+</script>
+
+<style scoped lang="scss">
+.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;
+      box-sizing: border-box;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 100%;
+      height: 100%;
+      cursor: pointer;
+      background: rgb(0 0 0 / 60%);
+      opacity: 0;
+      transition: var(--el-transition-duration-fast);
+      .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;
+  }
+}
+</style>

+ 3 - 0
src/components/Upload/index.scss

@@ -0,0 +1,3 @@
+.upload {
+  width: 80%;
+}

+ 257 - 0
src/components/Upload/index.vue

@@ -0,0 +1,257 @@
+<template>
+  <el-dialog v-model="dialogVisible" :width="parameter.width" :top="parameter.top" :title="`${parameter.title}`" :destroy-on-close="true" draggable>
+    <el-form class="drawer-multiColumn-form" label-width="100px">
+      <el-form-item label="文件上传">
+        <el-upload
+          ref="imageUpload"
+          :file-list="_fileList"
+          :action="uploadImgUrl"
+          list-type="picture-card"
+          :class="['upload', { hide: _fileList.length >= limit }]"
+          :headers="headers"
+          :limit="limit"
+          :multiple="multiple"
+          :show-file-list="true"
+          :before-upload="beforeUpload"
+          :on-exceed="handleExceed"
+          :on-success="uploadSuccess"
+          :on-error="uploadError"
+          :accept="fileType!.join(',')"
+        >
+          <div class="upload-empty">
+            <slot name="empty">
+              <el-icon><Plus /></el-icon>
+              <!-- <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="handlePictureCardPreview(file)">
+                <el-icon><ZoomIn /></el-icon>
+                <span>查看</span>
+              </div>
+              <div v-if="!self_disabled" class="handle-icon" @click="handleRemove(file)">
+                <el-icon><Delete /></el-icon>
+                <span>删除</span>
+              </div>
+            </div>
+          </template>
+        </el-upload>
+        <div class="el-upload__tip">
+          <slot name="tip"></slot>
+        </div>
+        <el-image-viewer v-if="imgViewVisible" :url-list="[viewImageUrl]" @close="imgViewVisible = false" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button type="primary" @click="handleSubmit">确认</el-button>
+        <el-button @click="dialogVisible = false">取消</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts" name="ImportExcel">
+import { ref, computed, inject, watch } from 'vue'
+import { ElMessage, ElNotification, UploadInstance, formContextKey, formItemContextKey } from 'element-plus'
+import type { UploadProps, UploadFile, UploadUserFile } from 'element-plus'
+import { compressAccurately } from 'image-conversion'
+import { globalHeaders } from '@/api'
+import { OssVO } from '@/api/interface/system/oss'
+import { listToString } from '@/utils/common'
+import { getListByIdsApi } from '@/api/modules/system/oss'
+import { showFullScreenLoading, tryHideFullScreenLoading } from '@/components/Loading/fullScreen'
+export interface UploadImgProps {
+  modelValue?: string | object | any[]
+  fileSize?: number // 上传文件的大小
+  limit?: number // 上传文件个数
+  width?: number | string // 组件高度 ==> 非必传(默认为 150px
+  height?: number | string // 组件高度 ==> 非必传(默认为 150px)
+  multiple?: boolean // 是否支持多选文件
+  fileType?: File.ImageMimeType[] // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"])
+  drag?: boolean // 是否支持拖拽上传 ==> 非必传(默认为 true)
+  disabled?: boolean // 是否禁用上传组件 ==> 非必传(默认为 false)
+  borderRadius?: string // 组件边框圆角 ==> 非必传(默认为 8px)
+  compressTargetSize?: number // 压缩图片尺寸 ==> 非必传(默认为 300)
+  compressSupport?: boolean // 是否支持压缩图片 ==> 非必传(默认为 false)
+}
+export interface DialogProps {
+  title: string // 标题
+  top?: string // 离顶部距离
+  width?: number | string // 弹框宽度
+  getTableList?: () => void // 获取表格数据的Api
+}
+
+// 统计图片数量
+// const number = ref(0)
+// const uploadList = ref<any[]>([])
+// dialog状态
+const dialogVisible = ref(false)
+// 获取 el-form 组件上下文
+const formContext = inject(formContextKey, void 0)
+// 获取 el-form-item 组件上下文
+const formItemContext = inject(formItemContextKey, void 0)
+// 父组件传过来的参数
+const props = withDefaults(defineProps<UploadImgProps>(), {
+  fileList: () => [],
+  drag: true,
+  modelValue: () => [],
+  disabled: false,
+  limit: 5,
+  fileSize: 5,
+  multiple: true,
+  fileType: () => ['image/jpeg', 'image/png', 'image/gif'],
+  height: '150px',
+  width: '150px',
+  borderRadius: '8px',
+  compressTargetSize: 300,
+  compressSupport: false
+})
+// 弹窗参数
+const parameter = ref<DialogProps>({
+  title: '',
+  width: 500,
+  top: '20vh'
+})
+const _fileList = ref<UploadUserFile[]>([])
+
+// 监听 props.fileList 列表默认值改变
+watch(
+  () => props.modelValue,
+  async (val: string | object | any[]) => {
+    if (val) {
+      // 首先将值转为数组
+      let list: OssVO[] = []
+      if (Array.isArray(val)) {
+        list = val as OssVO[]
+      } else {
+        const res = await getListByIdsApi(val)
+        list = res.data
+      }
+      // 然后将数组转为对象数组
+      _fileList.value = list.map(item => {
+        // 字符串回显处理 如果此处存的是url可直接回显 如果存的是id需要调用接口查出来
+        let itemData
+        if (typeof item === 'string') {
+          itemData = { name: item, url: item }
+        } else {
+          // 此处name使用ossId 防止删除出现重名
+          itemData = { name: item.ossId, url: item.url, ossId: item.ossId }
+        }
+        return itemData
+      })
+    } else {
+      _fileList.value = []
+      return []
+    }
+  },
+  { deep: true, immediate: true }
+)
+const baseUrl = import.meta.env.VITE_API_URL
+const uploadImgUrl = ref(baseUrl + '/resource/oss/upload') // 上传的图片服务器地址
+const headers = ref(globalHeaders())
+// 接收父组件参数
+const acceptParams = (params: DialogProps) => {
+  debugger
+  parameter.value = { ...parameter.value, ...params }
+  dialogVisible.value = true
+}
+
+const self_disabled = computed(() => {
+  return props.disabled || formContext?.disabled
+})
+/**
+ * @description 文件数超出
+ * */
+const handleExceed = () => {
+  ElMessage.error(`当前最多只能上传 ${props.limit} 张图片,请移除后上传!`)
+}
+const uploadRef = ref<UploadInstance>()
+const handleSubmit = () => {
+  uploadRef.value!.submit()
+}
+
+/**
+ * @description 文件上传之前判断
+ * @param rawFile 选择的文件
+ * */
+const beforeUpload: UploadProps['beforeUpload'] = rawFile => {
+  const imgSize = rawFile.size / 1024 / 1024 < props.fileSize
+  const imgType = props.fileType.includes(rawFile.type as File.ImageMimeType)
+  if (!imgType)
+    ElNotification({
+      title: '温馨提示',
+      message: '上传图片不符合所需的格式!',
+      type: 'warning'
+    })
+  if (!imgSize)
+    setTimeout(() => {
+      ElNotification({
+        title: '温馨提示',
+        message: `上传图片大小不能超过 ${props.fileSize}M!`,
+        type: 'warning'
+      })
+    }, 0)
+  if (props.compressSupport && rawFile.size / 1024 > props.compressTargetSize) {
+    return compressAccurately(rawFile, props.compressTargetSize)
+  }
+  showFullScreenLoading('正在上传图片,请稍候...')
+  return imgType && imgSize
+}
+
+const emit = defineEmits<{
+  'update:modelValue': [value: string]
+}>()
+/**
+ * @description 图片上传成功
+ * @param response 上传响应结果
+ * @param uploadFile 上传的文件
+ * */
+const uploadSuccess = (response: { fileUrl: string } | undefined, uploadFile: UploadFile) => {
+  if (!response) return
+  uploadFile.url = response.fileUrl
+  emit('update:modelValue', listToString(_fileList.value))
+  // 调用 el-form 内部的校验方法(可自动校验)
+  formItemContext?.prop && formContext?.validateField([formItemContext.prop as string])
+  tryHideFullScreenLoading()
+  ElNotification({
+    title: '温馨提示',
+    message: '图片上传成功!',
+    type: 'success'
+  })
+}
+
+// 上传错误提示
+const uploadError = () => {
+  ElMessage.error('图片上传失败,请您重新上传!')
+  tryHideFullScreenLoading()
+}
+
+/**
+ * @description 删除图片
+ * @param file 删除的文件
+ * */
+const handleRemove = (file: UploadFile) => {
+  _fileList.value = _fileList.value.filter(item => item.url !== file.url || item.name !== file.name)
+  emit('update:modelValue', listToString(_fileList.value))
+}
+
+/**
+ * @description 图片预览
+ * @param file 预览的文件
+ * */
+const viewImageUrl = ref('')
+const imgViewVisible = ref(false)
+const handlePictureCardPreview: UploadProps['onPreview'] = file => {
+  viewImageUrl.value = file.url!
+  imgViewVisible.value = true
+}
+defineExpose({
+  acceptParams
+})
+</script>
+<style lang="scss" scoped>
+@import './index.scss';
+</style>

+ 98 - 0
src/components/UploadDialog/index.vue

@@ -0,0 +1,98 @@
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    :close-on-click-modal="false"
+    :title="parameter.title"
+    :destroy-on-close="true"
+    :width="parameter.width"
+    :top="parameter.top"
+    draggable
+  >
+    <ProFrom ref="proFormRef" :items-options="itemsOptions" :form-options="_options" :model="model" />
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button type="primary" :loading="butLoading" @click="handleSubmit">确认</el-button>
+        <el-button @click="handleCancel">取消</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts" name="ImageUploadDialog">
+import { ref, computed, ComputedRef } from 'vue'
+import ProFrom from '@/components/ProForm/index.vue'
+export interface DialogProps {
+  title: string // 标题
+  width?: number // 弹框宽度
+  top?: string // 离顶部距离
+  type?: File.FileUploadType // 弹框类型 默认file-upload-s3:s3服务器上传文件
+  getTableList?: () => void // 获取表格数据的Api
+}
+// 表单的数据
+const model = ref<Record<string, any>>({ file: undefined })
+// dialog状态
+const dialogVisible = ref(false)
+const butLoading = ref(false)
+// 父组件传过来的参数
+const parameter = ref<DialogProps>({
+  title: '',
+  width: 500,
+  top: '10vh',
+  type: 'file-upload-s3'
+})
+const _options: ComputedRef<ProForm.FormOptions> = computed(() => {
+  return {
+    labelWidth: 120,
+    hasFooter: false,
+    disabled: false
+  }
+})
+let itemsOptions: ProForm.ItemsOptions[] = []
+const setItemsOptions = () => {
+  itemsOptions = [
+    {
+      label: '上传',
+      prop: 'file',
+      rules: [{ required: true, message: '文件不能为空', trigger: 'change' }],
+      compOptions: {
+        elTagName: parameter.value.type
+      }
+    }
+  ]
+}
+
+const proFormRef = ref<InstanceType<typeof ProFrom> | null>(null)
+
+// 表单提交校验
+const handleSubmit = () => {
+  const formEl = proFormRef.value?.proFormRef
+  console.log(proFormRef.value?.formModel)
+  butLoading.value = true
+  if (!formEl) return
+  formEl.validate(valid => {
+    if (valid) {
+      parameter.value.getTableList && parameter.value.getTableList()
+      dialogVisible.value = false
+    }
+    butLoading.value = false
+  })
+}
+
+// 取消按钮,重置表单,关闭弹框
+const handleCancel = () => {
+  butLoading.value = false
+  dialogVisible.value = false
+}
+
+// 接收父组件参数
+const openDialog = (params: DialogProps) => {
+  parameter.value = { ...parameter.value, ...params }
+  butLoading.value = false
+  setItemsOptions()
+  dialogVisible.value = true
+}
+
+defineExpose({
+  openDialog
+})
+</script>

+ 30 - 0
src/components/iFrame/index.vue

@@ -0,0 +1,30 @@
+<template>
+  <div v-loading="loading" :style="'height:' + height">
+    <iframe :src="url" frameborder="no" style="width: 100%; height: 100%" scrolling="auto" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+
+interface IFrameProps {
+  src: string
+}
+
+const props = withDefaults(defineProps<IFrameProps>(), {
+  src: ''
+})
+
+const height = ref(document.documentElement.clientHeight - 94.5 + 'px;')
+const loading = ref(true)
+const url = computed(() => props.src)
+
+onMounted(() => {
+  setTimeout(() => {
+    loading.value = false
+  }, 300)
+  window.onresize = function temp() {
+    height.value = document.documentElement.clientHeight - 94.5 + 'px;'
+  }
+})
+</script>

+ 65 - 2
src/hooks/useDownload.ts

@@ -1,6 +1,10 @@
-import { ElNotification, ElMessage } from 'element-plus'
-import { saveAs } from 'file-saver'
+import { ElNotification, ElMessage, ElLoading } from 'element-plus'
+import { LoadingInstance } from 'element-plus/es/components/loading/src/loading'
 import { blobValidate } from '@/utils/common'
+import axios from 'axios'
+import { globalHeaders } from '@/api'
+let downloadLoadingInstance: LoadingInstance
+import { saveAs } from 'file-saver'
 import errorCode from '@/utils/errorCode'
 /**
  * @description 接收数据流生成 blob,创建链接,下载文件
@@ -51,3 +55,62 @@ export const useDownload = async (
     ElMessage.error('下载文件出现错误,请联系管理员!')
   }
 }
+
+const baseURL = import.meta.env.VITE_API_URL
+
+export default {
+  async oss(ossId: string | number) {
+    const url = baseURL + '/resource/oss/download/' + ossId
+    downloadLoadingInstance = ElLoading.service({ text: '正在下载数据,请稍候', background: 'rgba(0, 0, 0, 0.7)' })
+    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' })
+        saveAs(blob, decodeURIComponent(res.headers['download-filename'] as string))
+      } else {
+        this.printErrMsg(res.data)
+      }
+      downloadLoadingInstance.close()
+    } catch (r) {
+      console.error(r)
+      ElMessage.error('下载文件出现错误,请联系管理员!')
+      downloadLoadingInstance.close()
+    }
+  },
+  async printErrMsg(data: any) {
+    const resText = await data.text()
+    const rspObj = JSON.parse(resText)
+    const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode['default']
+    ElMessage.error(errMsg)
+  },
+  async zip(url: string, name: string) {
+    url = baseURL + url
+    downloadLoadingInstance = ElLoading.service({ text: '正在下载数据,请稍候', background: 'rgba(0, 0, 0, 0.7)' })
+    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)
+      }
+      downloadLoadingInstance.close()
+    } catch (r) {
+      console.error(r)
+      ElMessage.error('下载文件出现错误,请联系管理员!')
+      downloadLoadingInstance.close()
+    }
+  }
+}

+ 22 - 24
src/layouts/components/Header/components/Avatar.vue

@@ -1,38 +1,29 @@
 <template>
   <el-dropdown trigger="click">
-    <div class="avatar">
+    <div class="avatar" v-if="userStore.avatar.indexOf('undefined') === -1">
+      <img :src="userStore.avatar" />
+    </div>
+    <div class="avatar-dft" v-else>
       <img src="@/assets/icons/avatar-user.svg" alt="avatar" />
     </div>
     <template #dropdown>
       <el-dropdown-menu>
-        <!-- <el-dropdown-item @click="openDialog('infoRef')">
-          <el-icon><User /></el-icon>{{ $t('header.personalData') }}
-        </el-dropdown-item> -->
-        <router-link to="system/user/profile" style="text-decoration: none">
-          <el-dropdown-item>
-            <el-icon><User /></el-icon>{{ $t('header.personalCenter') }}
-          </el-dropdown-item>
-        </router-link>
-        <!-- <el-dropdown-item @click="openDialog('passwordRef')">
-          <el-icon><Edit /></el-icon>{{ $t('header.changePassword') }}
-        </el-dropdown-item> -->
+        <el-dropdown-item @click="toProfile()">
+          <el-icon><User /></el-icon>{{ $t('header.personalCenter') }}
+        </el-dropdown-item>
         <el-dropdown-item divided @click="logout">
           <el-icon><SwitchButton /></el-icon>{{ $t('header.logout') }}
         </el-dropdown-item>
       </el-dropdown-menu>
     </template>
   </el-dropdown>
-  <!-- infoDialog -->
   <InfoDialog ref="infoRef"></InfoDialog>
-  <!-- passwordDialog -->
   <PasswordDialog ref="passwordRef"></PasswordDialog>
 </template>
 
 <script setup lang="ts">
-// import { ref } from 'vue'
 import { LOGIN_URL } from '@/config'
 import { useRouter } from 'vue-router'
-// import { logoutApi } from '@/api/modules/login'
 import { useUserStore } from '@/stores/modules/user'
 import { ElMessageBox, ElMessage } from 'element-plus'
 import InfoDialog from './InfoDialog.vue'
@@ -40,7 +31,11 @@ import PasswordDialog from './PasswordDialog.vue'
 
 const router = useRouter()
 const userStore = useUserStore()
+console.log('userStore.avatar', userStore.avatar.indexOf('undefined') !== -1)
 
+const toProfile = () => {
+  router.push('/system/user/profile')
+}
 // 退出登录
 const logout = () => {
   ElMessageBox.confirm('您是否确认退出登录?', '温馨提示', {
@@ -56,18 +51,21 @@ const logout = () => {
     ElMessage.success('退出登录成功!')
   })
 }
-
-// 打开修改密码和个人信息弹窗
-// const infoRef = ref<InstanceType<typeof InfoDialog> | null>(null)
-// const passwordRef = ref<InstanceType<typeof PasswordDialog> | null>(null)
-// const openDialog = (ref: string) => {
-//   if (ref == 'infoRef') infoRef.value?.openDialog()
-//   if (ref == 'passwordRef') passwordRef.value?.openDialog()
-// }
 </script>
 
 <style scoped lang="scss">
 .avatar {
+  width: 40px;
+  height: 40px;
+  overflow: hidden;
+  cursor: pointer;
+  border-radius: 50%;
+  img {
+    width: 100%;
+    height: 100%;
+  }
+}
+.avatar-dft {
   width: 40px;
   height: 40px;
   overflow: hidden;

+ 15 - 0
src/routers/modules/routerData.json

@@ -74,6 +74,21 @@
         "affix": false,
         "noCache": true
       }
+    },
+    {
+      "path": "/system/oss-config/index",
+      "name": "OssConfig",
+      "component": "system/oss/config",
+      "hidden": true,
+      "meta": {
+        "icon": "",
+        "title": "配置管理",
+        "activeMenu": "/system/oss",
+        "link": "",
+        "full": false,
+        "affix": false,
+        "noCache": true
+      }
     }
   ],
   "msg": "成功"

+ 3 - 0
src/typings/ProForm.d.ts

@@ -25,6 +25,9 @@ declare namespace ProForm {
     | 'radio-group'
     | 'radio-button'
     | 'file-upload'
+    | 'img-upload'
+    | 'file-upload-s3'
+    | 'img-upload-s3'
     | 'slot'
     | 'rate'
     | 'slider'

+ 16 - 0
src/typings/global.d.ts

@@ -22,6 +22,7 @@ declare namespace Menu {
 
 /* FileType */
 declare namespace File {
+  type FileUploadType = 'img-upload-s3' | 'img-upload' | 'file-upload-s3' | 'file-upload'
   type ImageMimeType =
     | 'image/apng'
     | 'image/bmp'
@@ -35,6 +36,12 @@ declare namespace File {
     | 'image/x-icon'
 
   type ExcelMimeType = 'application/vnd.ms-excel' | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+  type WordMimeType = 'application/msword' | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+  type PdfMimeType = 'application/pdf'
+  type PptMimeType = 'application/vnd.ms-powerpoint' | 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
+  type TxtMimeType = 'text/plain'
+  type ZipMimeType = 'application/zip'
+  type FileMimeType = ExcelMimeType | WordMimeType | PdfMimeType | PptMimeType | TxtMimeType | ZipMimeType
 }
 
 /* Vite */
@@ -70,3 +77,12 @@ declare const __APP_INFO__: {
   }
   lastBuildTime: string
 }
+/**
+ * 界面字段隐藏属性
+ */
+declare interface FieldOption {
+  key: number
+  label: string
+  visible: boolean
+  children?: Array<FieldOption>
+}

+ 26 - 0
src/utils/common.ts

@@ -1,5 +1,19 @@
 import lodash from 'lodash'
 import { unref } from 'vue'
+
+export const addDateRange = (params: any, dateRange: any[], propName?: string) => {
+  const search = params
+  search.params = typeof search.params === 'object' && search.params !== null && !Array.isArray(search.params) ? search.params : {}
+  dateRange = Array.isArray(dateRange) ? dateRange : []
+  if (typeof propName === 'undefined') {
+    search.params['beginTime'] = dateRange[0]
+    search.params['endTime'] = dateRange[1]
+  } else {
+    search.params['begin' + propName] = dateRange[0]
+    search.params['end' + propName] = dateRange[1]
+  }
+  return search
+}
 /**
  * 通用防抖
  *
@@ -141,3 +155,15 @@ export const handelEnum = (enumMap, item: any) => {
   }
   return enumData
 }
+
+// 对象转成指定字符串分隔
+export const listToString = (list: any[], separator?: string) => {
+  let strings = ''
+  separator = separator || ','
+  for (let 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) : ''
+}

+ 312 - 0
src/views/system/oss/config.vue

@@ -0,0 +1,312 @@
+<template>
+  <div class="table-box">
+    <ProTable ref="proTable" :columns="columns" row-key="ossConfigId" :request-api="listOssConfigApi">
+      <!-- 表格 header 按钮 -->
+      <template #tableHeader="scope">
+        <el-button type="primary" v-auth="['system:ossConfig:add']" icon="CirclePlus" @click="openDialog(1, '对象存储配置新增')"> 新增 </el-button>
+        <el-button
+          type="danger"
+          v-auth="['system:ossConfig:remove']"
+          icon="Delete"
+          plain
+          :disabled="!scope.isSelected"
+          @click="batchDelete(scope.selectedListIds)"
+        >
+          批量删除
+        </el-button>
+      </template>
+      <!-- 表格操作 -->
+      <template #operation="scope">
+        <el-tooltip content="编辑" placement="top">
+          <el-button type="primary" link icon="EditPen" v-auth="['system:ossConfig:edit']" @click="openDialog(2, '对象存储配置编辑', scope.row)">
+          </el-button>
+        </el-tooltip>
+        <el-tooltip content="删除" placement="top">
+          <el-button type="primary" link icon="Delete" v-auth="['system:ossConfig:remove']" @click="deleteOssConfig(scope.row)"> </el-button>
+        </el-tooltip>
+      </template>
+    </ProTable>
+    <FormDialog ref="formDialogRef" />
+    <ImportExcel ref="dialogRef" />
+  </div>
+</template>
+
+<script setup lang="tsx" name="OssConfig">
+import { ref, reactive } from 'vue'
+import { useHandleData } from '@/hooks/useHandleData'
+import ProTable from '@/components/ProTable/index.vue'
+import ImportExcel from '@/components/ImportExcel/index.vue'
+import FormDialog from '@/components/FormDialog/index.vue'
+import { ProTableInstance, ColumnProps } from '@/components/ProTable/interface'
+import {
+  listOssConfigApi,
+  delOssConfigApi,
+  addOssConfigApi,
+  updateOssConfigApi,
+  getOssConfigApi,
+  changeOssConfigStatusApi
+} from '@/api/modules/system/ossConfig'
+import { getDictsApi } from '@/api/modules/system/dictData'
+import { OssConfigVO } from '@/api/interface/system/ossConfig'
+
+// ProTable 实例
+const proTable = ref<ProTableInstance>()
+
+// 删除对象存储配置信息
+const deleteOssConfig = async (params: any) => {
+  await useHandleData(delOssConfigApi, params.ossConfigId, `删除【params.ossConfigId】对象存储配置`)
+  proTable.value?.getTableList()
+}
+
+// 批量删除对象存储配置信息
+const batchDelete = async (ids: string[]) => {
+  await useHandleData(delOssConfigApi, ids, '删除所选对象存储配置信息')
+  proTable.value?.clearSelection()
+  proTable.value?.getTableList()
+}
+// 切换用户状态
+const changeStatus = async (row: OssConfigVO) => {
+  let text = row.status === '1' ? '启用' : '停用'
+  await useHandleData(
+    changeOssConfigStatusApi,
+    { ossConfigId: row.ossConfigId, version: row.version, status: row.status == '1' ? 0 : 1, configKey: row.configKey },
+    `'要${text}"${row.configKey}'"配置吗?`
+  )
+  proTable.value?.getTableList()
+}
+
+const formDialogRef = ref<InstanceType<typeof FormDialog> | null>(null)
+// 打开弹框的功能
+const openDialog = async (type: number, title: string, row?: any) => {
+  let res = { data: {} }
+  if (row?.ossConfigId) {
+    res = await getOssConfigApi(row?.ossConfigId || null)
+  }
+  // 重置表单
+  setItemsOptions()
+  const params = {
+    title,
+    width: 680,
+    top: '8vh',
+    isEdit: type !== 3,
+    itemsOptions: itemsOptions,
+    model: type == 1 ? {} : res.data,
+    api: type == 1 ? addOssConfigApi : updateOssConfigApi,
+    getTableList: proTable.value?.getTableList
+  }
+  formDialogRef.value?.openDialog(params)
+}
+
+const types = [
+  {
+    label: 'private',
+    value: '0'
+  },
+  {
+    label: 'public',
+    value: '1'
+  },
+  {
+    label: 'custom',
+    value: '2'
+  }
+]
+const statusTypes = [
+  {
+    label: '启用',
+    value: '1'
+  },
+  {
+    label: '停用',
+    value: '0'
+  }
+]
+
+// 表格配置项
+const columns = reactive<ColumnProps<any>[]>([
+  {
+    prop: 'ossConfigId',
+    label: '主建'
+  },
+  {
+    prop: 'endpoint',
+    label: '访问站点'
+  },
+  {
+    prop: 'domainName',
+    label: '自定义域名'
+  },
+  {
+    prop: 'configKey',
+    label: '配置key',
+    search: {
+      el: 'input'
+    }
+  },
+  {
+    prop: 'bucketName',
+    label: '桶名称',
+    search: {
+      el: 'input'
+    }
+  },
+  {
+    prop: 'prefix',
+    label: '前缀'
+  },
+  {
+    prop: 'region',
+    label: '域'
+  },
+  {
+    prop: 'accessPolicy',
+    label: '桶权限类型',
+    tag: true,
+    enum: types
+  },
+  {
+    prop: 'status',
+    label: '是否默认',
+    enum: statusTypes,
+    search: { el: 'tree-select' },
+    render: scope => {
+      return (
+        <el-switch
+          model-value={scope.row.status}
+          active-text={scope.row.status === '1' ? '停用' : '启用'}
+          active-value={'0'}
+          inactive-value={'1'}
+          onClick={() => changeStatus(scope.row)}
+        />
+      )
+    }
+  },
+  { prop: 'operation', label: '操作', width: 230, fixed: 'right' }
+])
+// 表单配置项
+let itemsOptions: ProForm.ItemsOptions[] = []
+const setItemsOptions = () => {
+  itemsOptions = [
+    {
+      label: '配置key',
+      prop: 'configKey',
+      rules: [{ required: true, message: '配置key不能为空', trigger: 'blur' }],
+      compOptions: {
+        placeholder: '请输入配置key'
+      }
+    },
+    {
+      label: '访问站点',
+      prop: 'endpoint',
+      rules: [
+        { required: true, message: '访问站点不能为空', trigger: 'blur' },
+        {
+          min: 2,
+          max: 100,
+          message: '访问站点名称长度必须介于 2 和 100 之间',
+          trigger: 'blur'
+        }
+      ],
+      compOptions: {
+        placeholder: '请输入访问站点'
+      }
+    },
+    {
+      label: '自定义域名',
+      prop: 'domainName',
+      compOptions: {
+        placeholder: '请输入自定义域名'
+      }
+    },
+    {
+      label: 'accessKey',
+      prop: 'accessKey',
+      rules: [
+        { required: true, message: 'accessKey不能为空', trigger: 'blur' },
+        {
+          min: 2,
+          max: 200,
+          message: 'accessKey长度必须介于 2 和 100 之间',
+          trigger: 'blur'
+        }
+      ],
+      compOptions: {
+        placeholder: '请输入accessKey'
+      }
+    },
+    {
+      label: 'secretKey',
+      prop: 'secretKey',
+      rules: [
+        { required: true, message: 'secretKey不能为空', trigger: 'blur' },
+        {
+          min: 2,
+          max: 100,
+          message: 'secretKey长度必须介于 2 和 100 之间',
+          trigger: 'blur'
+        }
+      ],
+      compOptions: {
+        showPassword: true,
+        placeholder: '请输入secretKey'
+      }
+    },
+    {
+      label: '桶名称',
+      prop: 'bucketName',
+      rules: [
+        { required: true, message: '桶名称不能为空', trigger: 'blur' },
+        {
+          min: 2,
+          max: 100,
+          message: '桶名称长度必须介于 2 和 100 之间',
+          trigger: 'blur'
+        }
+      ],
+      compOptions: {
+        placeholder: '请输入桶名称'
+      }
+    },
+    {
+      label: '前缀',
+      prop: 'prefix',
+      compOptions: {
+        placeholder: '请输入前缀'
+      }
+    },
+    {
+      label: '是否https',
+      prop: 'isHttps',
+      compOptions: {
+        elTagName: 'radio-group',
+        enum: () => getDictsApi('sys_yes_no'),
+        labelKey: 'dictLabel',
+        valueKey: 'dictValue'
+      }
+    },
+    {
+      label: '桶权限类型',
+      prop: 'accessPolicy',
+      compOptions: {
+        elTagName: 'radio-group',
+        enum: types,
+        placeholder: '请输入桶权限类型'
+      }
+    },
+    {
+      label: '域',
+      prop: 'region',
+      compOptions: {
+        placeholder: '请输入域'
+      }
+    },
+    {
+      label: '备注',
+      prop: 'remark',
+      compOptions: {
+        placeholder: '请输入备注'
+      }
+    }
+  ]
+}
+</script>

+ 177 - 0
src/views/system/oss/index.vue

@@ -0,0 +1,177 @@
+<template>
+  <div class="table-box">
+    <ProTable ref="proTable" :columns="columns" row-key="ossId" :data-callback="dataCallback" :request-api="listOssApi">
+      <!-- 表格 header 按钮 -->
+      <template #tableHeader="scope">
+        <el-button type="primary" v-auth="['system:oss:upload']" icon="Upload" plain @click="handleFile('file-upload-s3')"> 上传文件 </el-button>
+        <el-button type="primary" v-auth="['system:oss:upload']" icon="Upload" plain @click="handleFile('img-upload-s3')"> 上传图片 </el-button>
+        <el-button
+          type="danger"
+          v-auth="['system:oss:remove']"
+          icon="Delete"
+          plain
+          :disabled="!scope.isSelected"
+          @click="batchDelete(scope.selectedListIds)"
+        >
+          批量删除
+        </el-button>
+        <el-button
+          :type="previewListResource ? 'danger' : 'warning'"
+          v-auth="['system:oss:edit']"
+          plain
+          @click="handlePreviewListResource(!previewListResource)"
+        >
+          预览开关 : {{ previewListResource ? '禁用' : '启用' }}
+        </el-button>
+        <el-button type="info" v-auth="['system:oss:list']" icon="Operation" @click="handleOssConfig()"> 配置管理 </el-button>
+      </template>
+      <!-- 菜单图标 -->
+      <template #url="scope">
+        <ImagePreview
+          v-if="previewListResource && checkFileSuffix(scope.row.fileSuffix)"
+          :width="100"
+          :height="100"
+          :src="scope.row.url"
+          :preview-src-list="[scope.row.url]"
+        />
+        <span v-if="!checkFileSuffix(scope.row.fileSuffix) || !previewListResource" v-text="scope.row.url" />
+      </template>
+      <!-- 表格操作 -->
+      <template #operation="scope">
+        <el-tooltip content="下载" placement="top">
+          <el-button type="primary" link icon="Download" v-auth="['system:oss:download']" @click="handleDownload(scope.row)"> </el-button>
+        </el-tooltip>
+        <el-tooltip content="删除" placement="top">
+          <el-button type="primary" link icon="Delete" v-auth="['system:oss:remove']" @click="deleteOss(scope.row)"> </el-button>
+        </el-tooltip>
+      </template>
+    </ProTable>
+    <UploadDialog ref="uploadDialogRef" />
+  </div>
+</template>
+
+<script setup lang="tsx" name="Oss">
+import { ref, reactive } from 'vue'
+import { useHandleData } from '@/hooks/useHandleData'
+import useDownload from '@/hooks/useDownload'
+import UploadDialog from '@/components/UploadDialog/index.vue'
+import { useRouter } from 'vue-router'
+import ProTable from '@/components/ProTable/index.vue'
+import ImagePreview from '@/components/ImagePreview/index.vue'
+import { ProTableInstance, ColumnProps } from '@/components/ProTable/interface'
+import { listOssApi, delOssApi } from '@/api/modules/system/oss'
+
+import { getConfigKeyApi, updateConfigByKeyApi } from '@/api/modules/system/config'
+import { OssVO } from '@/api/interface/system/oss'
+const router = useRouter()
+// ProTable 实例
+const proTable = ref<ProTableInstance>()
+
+// 预览开关
+const previewListResource = ref(true)
+
+// dataCallback 是对于返回的表格数据做处理
+const dataCallback = (res: any) => {
+  getConfigKeyApi('sys.oss.previewListResource').then(
+    res => (previewListResource.value = res?.data === undefined ? true : res.data.configValue === 'true')
+  )
+  return res
+}
+
+const handleOssConfig = () => {
+  router.push('/system/oss-config/index')
+}
+
+const checkFileSuffix = (fileSuffix: string | string[]) => {
+  const arr = ['.png', '.jpg', '.jpeg']
+  const suffixArray = Array.isArray(fileSuffix) ? fileSuffix : [fileSuffix]
+  return suffixArray.some(suffix => arr.includes(suffix.toLowerCase()))
+}
+const handlePreviewListResource = async (preview: boolean) => {
+  const res = await getConfigKeyApi('sys.oss.previewListResource')
+  let text = preview ? '启用' : '停用'
+  getConfigKeyApi
+  await useHandleData(
+    updateConfigByKeyApi,
+    { configKey: 'sys.oss.previewListResource', configValue: preview, version: res.data.version },
+    `要${text}预览列表图片"配置吗?`
+  )
+  proTable.value?.getTableList()
+}
+
+// 删除OSS对象存储信息
+const deleteOss = async (params: any) => {
+  await useHandleData(delOssApi, params.ossId, `删除【${params.ossId}】OSS对象存储`)
+  proTable.value?.getTableList()
+}
+
+// 批量删除OSS对象存储信息
+const batchDelete = async (ids: string[]) => {
+  await useHandleData(delOssApi, ids, '删除所选OSS对象存储信息')
+  proTable.value?.clearSelection()
+  proTable.value?.getTableList()
+}
+
+/** 下载按钮操作 */
+const handleDownload = (row: OssVO) => {
+  console.log(row)
+  useDownload.oss(row.ossId)
+}
+
+// 上传文件
+const uploadDialogRef = ref<InstanceType<typeof UploadDialog> | null>(null)
+const handleFile = (type: File.FileUploadType) => {
+  const params = {
+    title: '上传文件',
+    type,
+    getTableList: proTable.value?.getTableList
+  }
+  uploadDialogRef.value?.openDialog(params)
+}
+
+// 表格配置项
+const columns = reactive<ColumnProps<any>[]>([
+  { type: 'selection', fixed: 'left', width: 70 },
+  {
+    prop: 'fileName',
+    label: '文件名',
+    search: {
+      el: 'input'
+    },
+    width: 420
+  },
+  {
+    prop: 'originalName',
+    label: '原名',
+    search: {
+      el: 'input'
+    },
+    width: 220
+  },
+  {
+    prop: 'fileSuffix',
+    label: '文件后缀名',
+    search: {
+      el: 'input'
+    },
+    width: 100
+  },
+  {
+    prop: 'url',
+    label: 'URL地址',
+    showOverflowTooltip: false,
+    search: {
+      el: 'input'
+    }
+  },
+  {
+    prop: 'service',
+    label: '服务商',
+    search: {
+      el: 'input'
+    },
+    width: 120
+  },
+  { prop: 'operation', label: '操作', width: 230, fixed: 'right' }
+])
+</script>

+ 17 - 5
yarn.lock

@@ -4557,6 +4557,11 @@ ignore@^5.2.0, ignore@^5.2.4, ignore@^5.3.1:
   resolved "https://registry.npmmirror.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
   integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
 
+image-conversion@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.npmmirror.com/image-conversion/-/image-conversion-2.1.1.tgz#8213fc19033f7dfdee20bb19792e8dcebd052417"
+  integrity sha512-hnMOmP7q2jxA+52FZ+wHNhg3fdFRlgfngsQH2JQHEQkafY7tj/8F15e6Rv/RxDegc872jvyaRHwMbkTZK1Cjbg==
+
 image-size@^0.5.1:
   version "0.5.5"
   resolved "https://registry.npmmirror.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c"
@@ -4820,6 +4825,11 @@ is-plain-obj@^1.1, is-plain-obj@^1.1.0:
   resolved "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
   integrity sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==
 
+is-plain-object@5.0.0, is-plain-object@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
+  integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
+
 is-plain-object@^2.0.3, is-plain-object@^2.0.4:
   version "2.0.4"
   resolved "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
@@ -4827,11 +4837,6 @@ is-plain-object@^2.0.3, is-plain-object@^2.0.4:
   dependencies:
     isobject "^3.0.1"
 
-is-plain-object@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
-  integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
-
 is-regex@^1.1.4:
   version "1.1.4"
   resolved "https://registry.npmmirror.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
@@ -7757,6 +7762,13 @@ vue-tsc@^1.8.25:
     "@vue/language-core" "1.8.27"
     semver "^7.5.4"
 
+vue-types@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.npmmirror.com/vue-types/-/vue-types-5.1.1.tgz#1052b85f440a90ad4ea8249d5aa6f231b92d062e"
+  integrity sha512-FMY/JCLWePXgGIcMDqYdJsQm1G0CDxEjq6W0+tZMJZlX37q/61eSGSIa/XFRwa9T7kkKXuxxl94/2kgxyWQqKw==
+  dependencies:
+    is-plain-object "5.0.0"
+
 vue@^3.3.11:
   version "3.4.25"
   resolved "https://registry.npmmirror.com/vue/-/vue-3.4.25.tgz#e59d4ed36389647b52ff2fd7aa84bb6691f4205b"