From 490bb901e175abe1d2b4c198a7c0a0424218c6df Mon Sep 17 00:00:00 2001
From: owen <owen@evolsun.com>
Date: Sun, 4 Feb 2024 19:41:48 +0800
Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=EF=BC=9A?=
 =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=89=8D=E7=AB=AF=E7=9B=B4=E8=BF=9E=E4=B8=8A?=
 =?UTF-8?q?=E4=BC=A0=E6=96=87=E4=BB=B6=E5=88=B0S3=E6=9C=8D=E5=8A=A1?=
 =?UTF-8?q?=E7=9A=84=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .env.dev                                     |  2 +
 .env.local                                   |  2 +
 .env.prod                                    |  2 +
 .env.stage                                   |  2 +
 .env.test                                    |  2 +
 src/api/infra/file/index.ts                  | 21 +++++
 src/components/UploadFile/src/UploadFile.vue | 22 +++--
 src/components/UploadFile/src/UploadImg.vue  | 12 +--
 src/components/UploadFile/src/UploadImgs.vue | 13 +--
 src/components/UploadFile/src/useUpload.ts   | 87 ++++++++++++++++++++
 src/views/infra/file/FileForm.vue            | 15 ++--
 src/views/infra/file/index.vue               | 13 ++-
 12 files changed, 152 insertions(+), 41 deletions(-)
 create mode 100644 src/components/UploadFile/src/useUpload.ts

diff --git a/.env.dev b/.env.dev
index 3c41cc68..689718b2 100644
--- a/.env.dev
+++ b/.env.dev
@@ -7,6 +7,8 @@ VITE_DEV=true
 VITE_BASE_URL='http://api-dashboard.yudao.iocoder.cn'
 # VITE_BASE_URL='http://dofast.demo.huizhizao.vip:20001'
 
+# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
+VITE_UPLOAD_TYPE=server
 # 上传路径
 VITE_UPLOAD_URL='http://api-dashboard.yudao.iocoder.cn/admin-api/infra/file/upload'
 
diff --git a/.env.local b/.env.local
index 2eb968c4..28f37538 100644
--- a/.env.local
+++ b/.env.local
@@ -6,6 +6,8 @@ VITE_DEV=true
 # 请求路径
 VITE_BASE_URL='http://localhost:48080'
 
+# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
+VITE_UPLOAD_TYPE=server
 # 上传路径
 VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
 
diff --git a/.env.prod b/.env.prod
index 070b43a7..35d729c3 100644
--- a/.env.prod
+++ b/.env.prod
@@ -6,6 +6,8 @@ VITE_DEV=false
 # 请求路径
 VITE_BASE_URL='http://localhost:48080'
 
+# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
+VITE_UPLOAD_TYPE=server
 # 上传路径
 VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
 
diff --git a/.env.stage b/.env.stage
index c0edf340..26f9516b 100644
--- a/.env.stage
+++ b/.env.stage
@@ -6,6 +6,8 @@ VITE_DEV=false
 # 请求路径
 VITE_BASE_URL='http://api-dashboard.yudao.iocoder.cn'
 
+# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
+VITE_UPLOAD_TYPE=server
 # 上传路径
 VITE_UPLOAD_URL='http://api-dashboard.yudao.iocoder.cn/admin-api/infra/file/upload'
 
diff --git a/.env.test b/.env.test
index 217ac6e2..addbfb4e 100644
--- a/.env.test
+++ b/.env.test
@@ -6,6 +6,8 @@ VITE_DEV=false
 # 请求路径
 VITE_BASE_URL='http://localhost:48080'
 
+# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
+VITE_UPLOAD_TYPE=server
 # 上传路径
 VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
 
diff --git a/src/api/infra/file/index.ts b/src/api/infra/file/index.ts
index f64bc0d6..7f543745 100644
--- a/src/api/infra/file/index.ts
+++ b/src/api/infra/file/index.ts
@@ -6,6 +6,14 @@ export interface FilePageReqVO extends PageParam {
   createTime?: Date[]
 }
 
+// 文件预签名地址 Response VO
+export interface FilePresignedUrlRespVO {
+  // 文件配置编号
+  configId: number
+  // 文件预签名地址
+  url: string
+}
+
 // 查询文件列表
 export const getFilePage = (params: FilePageReqVO) => {
   return request.get({ url: '/infra/file/page', params })
@@ -15,3 +23,16 @@ export const getFilePage = (params: FilePageReqVO) => {
 export const deleteFile = (id: number) => {
   return request.delete({ url: '/infra/file/delete?id=' + id })
 }
+
+// 获取文件预签名地址
+export const getFilePresignedUrl = (fileName: string) => {
+  return request.get<FilePresignedUrlRespVO>({
+    url: '/infra/file/presigned-url',
+    params: { fileName }
+  })
+}
+
+// 创建文件
+export const createFile = (data: any) => {
+  return request.post({ url: '/infra/file/create', data })
+}
diff --git a/src/components/UploadFile/src/UploadFile.vue b/src/components/UploadFile/src/UploadFile.vue
index a0ef08d6..576dc054 100644
--- a/src/components/UploadFile/src/UploadFile.vue
+++ b/src/components/UploadFile/src/UploadFile.vue
@@ -3,11 +3,10 @@
     <el-upload
       ref="uploadRef"
       v-model:file-list="fileList"
-      :action="updateUrl"
+      :action="uploadUrl"
       :auto-upload="autoUpload"
       :before-upload="beforeUpload"
       :drag="drag"
-      :headers="uploadHeaders"
       :limit="props.limit"
       :multiple="props.limit > 1"
       :on-error="excelUploadError"
@@ -16,6 +15,7 @@
       :on-remove="handleRemove"
       :on-success="handleFileSuccess"
       :show-file-list="true"
+      :http-request="httpRequest"
       class="upload-file-uploader"
       name="file"
     >
@@ -36,9 +36,10 @@
 </template>
 <script lang="ts" setup>
 import { propTypes } from '@/utils/propTypes'
-import { getAccessToken, getTenantId } from '@/utils/auth'
 import type { UploadInstance, UploadProps, UploadRawFile, UploadUserFile } from 'element-plus'
 import { isString } from '@/utils/is'
+import { useUpload } from '@/components/UploadFile/src/useUpload'
+import { UploadFile } from 'element-plus/es/components/upload/src/upload'
 
 defineOptions({ name: 'UploadFile' })
 
@@ -48,7 +49,6 @@ const emit = defineEmits(['update:modelValue'])
 const props = defineProps({
   modelValue: propTypes.oneOfType<string | string[]>([String, Array<String>]).isRequired,
   title: propTypes.string.def('文件上传'),
-  updateUrl: propTypes.string.def(import.meta.env.VITE_UPLOAD_URL),
   fileType: propTypes.array.def(['doc', 'xls', 'ppt', 'txt', 'pdf']), // 文件类型, 例如['png', 'jpg', 'jpeg']
   fileSize: propTypes.number.def(5), // 大小限制(MB)
   limit: propTypes.number.def(5), // 数量限制
@@ -62,10 +62,8 @@ const uploadRef = ref<UploadInstance>()
 const uploadList = ref<UploadUserFile[]>([])
 const fileList = ref<UploadUserFile[]>([])
 const uploadNumber = ref<number>(0)
-const uploadHeaders = ref({
-  Authorization: 'Bearer ' + getAccessToken(),
-  'tenant-id': getTenantId()
-})
+
+const { uploadUrl, httpRequest } = useUpload()
 
 // 文件上传之前判断
 const beforeUpload: UploadProps['beforeUpload'] = (file: UploadRawFile) => {
@@ -120,10 +118,10 @@ const excelUploadError: UploadProps['onError'] = (): void => {
   message.error('导入数据失败,请您重新上传!')
 }
 // 删除上传文件
-const handleRemove = (file) => {
-  const findex = fileList.value.map((f) => f.name).indexOf(file.name)
-  if (findex > -1) {
-    fileList.value.splice(findex, 1)
+const handleRemove = (file: UploadFile) => {
+  const index = fileList.value.map((f) => f.name).indexOf(file.name)
+  if (index > -1) {
+    fileList.value.splice(index, 1)
     emitUpdateModelValue()
   }
 }
diff --git a/src/components/UploadFile/src/UploadImg.vue b/src/components/UploadFile/src/UploadImg.vue
index a79c84b5..34566dda 100644
--- a/src/components/UploadFile/src/UploadImg.vue
+++ b/src/components/UploadFile/src/UploadImg.vue
@@ -3,15 +3,15 @@
     <el-upload
       :id="uuid"
       :accept="fileType.join(',')"
-      :action="updateUrl"
+      :action="uploadUrl"
       :before-upload="beforeUpload"
       :class="['upload', drag ? 'no-border' : '']"
       :drag="drag"
-      :headers="uploadHeaders"
       :multiple="false"
       :on-error="uploadError"
       :on-success="uploadSuccess"
       :show-file-list="false"
+      :http-request="httpRequest"
     >
       <template v-if="modelValue">
         <img :src="modelValue" class="upload-image" />
@@ -50,8 +50,8 @@ import type { UploadProps } from 'element-plus'
 
 import { generateUUID } from '@/utils'
 import { propTypes } from '@/utils/propTypes'
-import { getAccessToken, getTenantId } from '@/utils/auth'
 import { createImageViewer } from '@/components/ImageViewer'
+import { useUpload } from '@/components/UploadFile/src/useUpload'
 
 defineOptions({ name: 'UploadImg' })
 
@@ -70,7 +70,6 @@ type FileTypes =
 // 接受父组件参数
 const props = defineProps({
   modelValue: propTypes.string.def(''),
-  updateUrl: propTypes.string.def(import.meta.env.VITE_UPLOAD_URL),
   drag: propTypes.bool.def(true), // 是否支持拖拽上传 ==> 非必传(默认为 true)
   disabled: propTypes.bool.def(false), // 是否禁用上传组件 ==> 非必传(默认为 false)
   fileSize: propTypes.number.def(5), // 图片大小限制 ==> 非必传(默认为 5M)
@@ -101,10 +100,7 @@ const deleteImg = () => {
   emit('update:modelValue', '')
 }
 
-const uploadHeaders = ref({
-  Authorization: 'Bearer ' + getAccessToken(),
-  'tenant-id': getTenantId()
-})
+const { uploadUrl, httpRequest } = useUpload()
 
 const editImg = () => {
   const dom = document.querySelector(`#${uuid.value} .el-upload__input`)
diff --git a/src/components/UploadFile/src/UploadImgs.vue b/src/components/UploadFile/src/UploadImgs.vue
index aa465b56..bd19ebbe 100644
--- a/src/components/UploadFile/src/UploadImgs.vue
+++ b/src/components/UploadFile/src/UploadImgs.vue
@@ -3,16 +3,16 @@
     <el-upload
       v-model:file-list="fileList"
       :accept="fileType.join(',')"
-      :action="updateUrl"
+      :action="uploadUrl"
       :before-upload="beforeUpload"
       :class="['upload', drag ? 'no-border' : '']"
       :drag="drag"
-      :headers="uploadHeaders"
       :limit="limit"
       :multiple="true"
       :on-error="uploadError"
       :on-exceed="handleExceed"
       :on-success="uploadSuccess"
+      :http-request="httpRequest"
       list-type="picture-card"
     >
       <div class="upload-empty">
@@ -50,7 +50,7 @@ import type { UploadFile, UploadProps, UploadUserFile } from 'element-plus'
 import { ElNotification } from 'element-plus'
 
 import { propTypes } from '@/utils/propTypes'
-import { getAccessToken, getTenantId } from '@/utils/auth'
+import { useUpload } from '@/components/UploadFile/src/useUpload'
 
 defineOptions({ name: 'UploadImgs' })
 
@@ -70,7 +70,6 @@ type FileTypes =
 
 const props = defineProps({
   modelValue: propTypes.oneOfType<string | string[]>([String, Array<String>]).isRequired,
-  updateUrl: propTypes.string.def(import.meta.env.VITE_UPLOAD_URL),
   drag: propTypes.bool.def(true), // 是否支持拖拽上传 ==> 非必传(默认为 true)
   disabled: propTypes.bool.def(false), // 是否禁用上传组件 ==> 非必传(默认为 false)
   limit: propTypes.number.def(5), // 最大图片上传数 ==> 非必传(默认为 5张)
@@ -81,10 +80,7 @@ const props = defineProps({
   borderradius: propTypes.string.def('8px') // 组件边框圆角 ==> 非必传(默认为 8px)
 })
 
-const uploadHeaders = ref({
-  Authorization: 'Bearer ' + getAccessToken(),
-  'tenant-id': getTenantId()
-})
+const { uploadUrl, httpRequest } = useUpload()
 
 const fileList = ref<UploadUserFile[]>([])
 const uploadNumber = ref<number>(0)
@@ -121,7 +117,6 @@ const emit = defineEmits<UploadEmits>()
 const uploadSuccess: UploadProps['onSuccess'] = (res: any): void => {
   message.success('上传成功')
   // 删除自身
-  debugger
   const index = fileList.value.findIndex((item) => item.response?.data === res.data)
   fileList.value.splice(index, 1)
   uploadList.value.push({ name: res.data, url: res.data })
diff --git a/src/components/UploadFile/src/useUpload.ts b/src/components/UploadFile/src/useUpload.ts
new file mode 100644
index 00000000..bee0d584
--- /dev/null
+++ b/src/components/UploadFile/src/useUpload.ts
@@ -0,0 +1,87 @@
+import { getAccessToken, getTenantId } from '@/utils/auth'
+import * as FileApi from '@/api/infra/file'
+import CryptoJS from 'crypto-js'
+import { UploadRawFile, UploadRequestOptions } from 'element-plus/es/components/upload/src/upload'
+import { ajaxUpload } from 'element-plus/es/components/upload/src/ajax'
+import axios from 'axios'
+
+export const useUpload = () => {
+  // 后端上传地址
+  const uploadUrl = import.meta.env.VITE_UPLOAD_URL
+  // 是否使用前端直连上传
+  const isClientUpload = UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE
+  // 重写ElUpload上传方法
+  const httpRequest = async (options: UploadRequestOptions) => {
+    // 模式一:前端上传
+    if (isClientUpload) {
+      // 1.1 生成文件名称
+      const fileName = await generateFileName(options.file)
+      // 1.2 获取文件预签名地址
+      const presignedInfo = await FileApi.getFilePresignedUrl(fileName)
+      // 1.3 上传文件(不能使用ElUpload的ajaxUpload方法的原因:其使用的是FormData上传,Minio不支持)
+      return axios.put(presignedInfo.url, options.file).then(() => {
+        // 1.4. 记录文件信息到后端
+        const fileVo = createFile(presignedInfo.configId, fileName, presignedInfo.url, options.file)
+        // 通知成功,数据格式保持与后端上传的返回结果一致
+        return { data: fileVo.url }
+      })
+    } else {
+      // 模式二:后端上传(需要增加后端身份认证请求头)
+      options.headers['Authorization'] = 'Bearer ' + getAccessToken()
+      options.headers['tenant-id'] = getTenantId()
+      // 使用ElUpload的上传方法
+      return ajaxUpload(options)
+    }
+  }
+
+  return {
+    uploadUrl,
+    httpRequest
+  }
+}
+
+/**
+ * 创建文件信息
+ * @param configId 文件配置编号
+ * @param name 文件名称
+ * @param url 文件地址
+ * @param file 文件
+ */
+function createFile(configId: number, name: string, url: string, file: UploadRawFile) {
+  const fileVo = {
+    configId: configId,
+    path: name,
+    // 移除预签名参数:参数只在上传时有用,查看时不需要
+    url: url.substring(0, url.indexOf('?')),
+    name: file.name,
+    type: file.type,
+    size: file.size
+  }
+  FileApi.createFile(fileVo)
+  return fileVo
+}
+
+/**
+ * 生成文件名称(使用算法SHA256)
+ * @param file 要上传的文件
+ */
+async function generateFileName(file: UploadRawFile) {
+  // 读取文件内容
+  const data = await file.arrayBuffer()
+  const wordArray = CryptoJS.lib.WordArray.create(data)
+  // 计算SHA256
+  const sha256 = CryptoJS.SHA256(wordArray).toString()
+  // 拼接后缀
+  const ext = file.name.substring(file.name.lastIndexOf('.'))
+  return `${sha256}${ext}`
+}
+
+/**
+ * 上传类型
+ */
+enum UPLOAD_TYPE {
+  // 客户端直接上传(只支持S3服务)
+  CLIENT = 'client',
+  // 客户端发送到后端上传
+  SERVER = 'server'
+}
diff --git a/src/views/infra/file/FileForm.vue b/src/views/infra/file/FileForm.vue
index beeaea01..1de1e253 100644
--- a/src/views/infra/file/FileForm.vue
+++ b/src/views/infra/file/FileForm.vue
@@ -3,16 +3,16 @@
     <el-upload
       ref="uploadRef"
       v-model:file-list="fileList"
-      :action="url"
+      :action="uploadUrl"
       :auto-upload="false"
       :data="data"
       :disabled="formLoading"
-      :headers="uploadHeaders"
       :limit="1"
       :on-change="handleFileChange"
       :on-error="submitFormError"
       :on-exceed="handleExceed"
       :on-success="submitFormSuccess"
+      :http-request="httpRequest"
       accept=".jpg, .png, .gif"
       drag
     >
@@ -31,7 +31,7 @@
   </Dialog>
 </template>
 <script lang="ts" setup>
-import { getAccessToken, getTenantId } from '@/utils/auth'
+import { useUpload } from '@/components/UploadFile/src/useUpload'
 
 defineOptions({ name: 'InfraFileForm' })
 
@@ -40,12 +40,12 @@ const message = useMessage() // 消息弹窗
 
 const dialogVisible = ref(false) // 弹窗的是否展示
 const formLoading = ref(false) // 表单的加载中
-const url = import.meta.env.VITE_UPLOAD_URL
-const uploadHeaders = ref() // 上传 Header 头
 const fileList = ref([]) // 文件列表
 const data = ref({ path: '' })
 const uploadRef = ref()
 
+const { uploadUrl, httpRequest } = useUpload()
+
 /** 打开弹窗 */
 const open = async () => {
   dialogVisible.value = true
@@ -64,11 +64,6 @@ const submitFileForm = () => {
     message.error('请上传文件')
     return
   }
-  // 提交请求
-  uploadHeaders.value = {
-    Authorization: 'Bearer ' + getAccessToken(),
-    'tenant-id': getTenantId()
-  }
   unref(uploadRef)?.submit()
 }
 
diff --git a/src/views/infra/file/index.vue b/src/views/infra/file/index.vue
index 7732bb65..17967312 100644
--- a/src/views/infra/file/index.vue
+++ b/src/views/infra/file/index.vue
@@ -70,8 +70,17 @@
             preview-teleported
             fit="cover"
           />
-          <el-link v-else-if="row.type.includes('pdf')" type="primary" :href="row.url" :underline="false" target="_blank">预览</el-link>
-          <el-link v-else type="primary" download :href="row.url" :underline="false" target="_blank">下载</el-link>
+          <el-link
+            v-else-if="row.type.includes('pdf')"
+            type="primary"
+            :href="row.url"
+            :underline="false"
+            target="_blank"
+            >预览</el-link
+          >
+          <el-link v-else type="primary" download :href="row.url" :underline="false" target="_blank"
+            >下载</el-link
+          >
         </template>
       </el-table-column>
       <el-table-column