# 文件上传与下载

# form表单上传

表单上传通常指的是在网页上使用 HTML <form> 元素来上传文件或提交数据

<form action="/upload" method="post" enctype="multipart/form-data">
    <label for="file">选择文件:</label>
    <input type="file" name="file" id="file">
    <br>
    <input type="submit" value="上传">
</form>
1
2
3
4
5
6

在上述示例中:

  • enctype="multipart/form-data" 用于告诉服务器表单数据包含文件。
  • input 元素的 type 属性设置为 "file",这样用户就可以选择文件。
  • action 属性定义了表单数据将被发送到的服务器端的处理程序。在这里,它被设置为 "/upload",你需要将其替换为实际的服务器端处理程序的地址。

# formdata上传

MDN (opens new window)

FormData对象用以将数据编译成键值对,以便用XMLHttpRequest来发送数据。其主要用于发送表单数据,但亦可用于发送带键数据(keyed data),而独立于表单使用。如果表单enctype属性设为multipart/form-data ,则会使用表单的submit() (opens new window)方法来发送数据,从而,发送数据具有同样形式。

function picUpload() {
  let formdata = new FormData()
  // formdata.append('name', 'zs');
  console.log('filelist: ', fileSelect.files)
  // 添加formdata节点
  formdata.append('myfile', fileSelect.files[0])
  axios
    .post('http://127.0.0.1:3000', formdata, {
      headers: {
        'Content-Type': 'multipart/form-data',
      },
    })
    .then((res) => {
      console.log(res)
    })
    .catch((err) => {
      console.log(err)
    })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# FormData.append()

formData.append(name, value);
formData.append(name, value, filename);
1
2

注意: 如果你指定一个 Blob (opens new window) 作为数据添加到 FormData 对象中, 文件名会被放在 "Content-Disposition" 头部(常常会根据浏览器变化而变化)传给服务器。

# formData上传blob

formData上传图片,当append File对象时可以正常上传,而当append一个Blob对象时上传失败。

原因是formData上传blob时默认文件名是'blob',需要设置第三个参数filename

image-20220126175518126

# Van-uploader图片上传功能实现

多图上传的实现

<van-uploader
  :after-read="afterRead"
  :before-read="beforeReadPromise"
  :limit="3"
  v-model="fileList"
  accept="image/*"
  capture="camera"
  :max-count="9"
  multiple
/>

<script>
    /**
     * @param {Blob} file 文件 blob格式
     */
    afterRead(file, { name, index }) {
      console.log('文件读取完毕', file)
      console.log('index:', index)
      console.log('name:', name)
      // 多张图片
      if (Array.isArray(file)) {
        file.map(({ file }) => {
          this.uploadImage(file)
        })
      } else {
        // 单张图片
        this.uploadImage(file.file)
      }
    },
    beforeReadPromise(file) {
      console.log('file: ', file)
      // 多张图片
      if (Array.isArray(file)) {
        console.log('多张图片')
        let fileList = file.map(async (item) => {
          let res = await this.resolveImage(item)
          return res
        })
        console.log('fileList: ', fileList)
        return true
        // return new Promise((resolve) => {
        //   console.log('fileList over: ', fileList)
        //   resolve(fileList)
        // })
      } else {
        // 单张图片
        return new Promise((resolve) => {
          resolve(this.resolveImage(file))
        })
      }
    },

</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

处理图片,判断文件类型

    async resolveImage(file) {
      // let _this = this
      return new Promise(async(resolve, reject) => {
        if (!['jpeg', 'png', 'jpg', 'heif', 'heic'].includes(file.type.slice(6).toLowerCase())) {
          this.$toast({
            type: 'fail',
            message: '请正确上传图片文件'
          })
          reject(new Error())
        } else {
          // heif/heic格式不进行压缩处理
          if (['heif', 'heic'].includes(file.type.slice(6).toLowerCase())) {
            console.log('上传heic图片')
            heic2any({
              blob: file, // 将heic转换成一个buffer数组的图片
              toType: 'image/jpeg', // 要转化成具体的图片格式,可以是png/gif
              quality: 0.6 // 图片的质量,参数在0-1之间
            }).then(async(result) => {
              /** @param {Blob} result  */
              console.log('result: ', result)
              let compressFile = await this.compressPic(result)
              console.log('compressFile: ', compressFile)
              resolve(compressFile)
            })
          }
          // console.log('上传前处理图片')
          // 上传前处理图片
          // 压缩图片
          // eslint-disable-next-line
        }
        let compressFile = await this.compressPic(file)
        console.log('compressFile: ', compressFile)
        resolve(compressFile)
      })
    },
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

压缩图片

/**
     * @description: 压缩图片
     * @param {File|Blob} file 文件
     * @return {*}
     */
    compressPic(file) {
      console.log('开始压缩文件file: ', file)
      let _this = this
      return new Promise((resolve, reject) => {
        // eslint-disable-next-line
        new Compressor(file, {
          success(res) {
          // 压缩并转成了blob
            let size = res.size / 1024 / 1024 // 单位 MB
            if (size > 8) {
              _this.$toast({
                type: 'fail',
                message: '上传图片过大'
              })
              // eslint-disable-next-line
                reject()
            }
            console.log('压缩完成res: ', res)
            resolve(res)
          },
          error(err) {
            console.log('压缩失败', err.message)
          }
        })
      })
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

最后上传图片

/**
     * @description: 上传图片至oss
     * @param {Blob|File} file 文件对象 包含name,size,type
     * @return {*}
     */
    async uploadImage(file) {
      let formdata = new FormData()
      let fileName = `${new Date().getTime()}.jpeg`
      formdata.append('files', file, fileName)
      let putData = {
        bucket: 'img',
        isCover: '0',
        name: fileName,
        size: file.size
      }
      let res = await createToken(putData)
      let token = res.data.data
      formdata.append('ossToken', token)
      let res1 = await storageFile(formdata)
      if (res1 && res1.data.code === 200) {
        this.msg += res1.data.msg + ','
        if (this.msg.length > 0) {
          this.formData.medicalInfoImg = this.msg.substr(0, this.msg.length - 1)
        }
      }
      console.log('上传完成-----------', this.formData.medicalInfoImg)
    },
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

# 图片上传遇到的问题

安卓机input file 时发现无法调起摄像头,网上查阅得知需要添加accept="image/*"属性。

capture="camera" 为调起摄像头

相关文档:

input上传图片安卓机无法选择相机 (opens new window)

# 前端文件切片与断点续传

断点续传的原理就是将文件切片上传,当上传中断后,重新上传时先检查已上传了哪些片段,再继续上传剩下的片段即可

<input type="file" @change="handleFileChange" />
<button @click="handleUpload">upload</button>
1
2
/**
 * 切片文件
 * @param file
 * @param chunkSize
 */
function sliceFile(file: File, chunkSize = 5 * 1024 * 1024) {
    const chunks = []
    let current = 0
    while (current < file.size) {
        const slice = file.slice(current, current + chunkSize)
        chunks.push(slice)
        current += chunkSize
    }
    return chunks
}

/**
 * 生成文件唯一标识
 * @param file
 */
async function generateFileHash(file: File) {
    const buffer = await file.arrayBuffer()
    const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
    const hashArray = Array.from(new Uint8Array(hashBuffer))
    return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
}
/**
 * 检查已上传分片
 * @param fileHash 
 */
async function checkUploadedChunks(fileHash: string) {
    const res = await checkFileChunk({ fileHash })
    return res
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

上传分片

/**
 * 上传分片
 * @param file
 * @param chunks
 * @param uploadedChunks
 * @param fileHash
 */
async function uploadChunks(
    file: File,
    chunks: Blob[],
    uploadedChunks: number[] | undefined,
    fileHash: string
) {
    for (let i = 0; i < chunks.length; i++) {
        if (uploadedChunks && uploadedChunks.includes(i)) {
            console.log(`Chunk ${i} already uploaded, skipping...`)
            continue
        }
        console.log('chunks: ', chunks)
        const formData = new FormData()
        formData.append('chunk', chunks[i])
        formData.append('chunkIndex', String(i))
        formData.append('fileHash', fileHash)

        await axios.post('http://127.0.0.1:3000/upload', formData, {
            headers: { 'Content-Type': 'multipart/form-data' },
            onUploadProgress: (progressEvent) => {
                if (progressEvent.total) {
                    console.log(
                        `Chunk ${i} progress: ${Math.round(
                            (progressEvent.loaded / progressEvent.total) * 100
                        )}%`
                    )
                }
            }
        })
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

合并分片

/**
 * 合并分片
 * @param fileHash
 * @param totalChunks
 */
async function mergeChunks(fileHash: string, totalChunks: number) {
    await mergeFileChunk({ fileHash, totalChunks })
}
1
2
3
4
5
6
7
8

最终提交

async function uploadFile(file: File) {
    const chunkSize = 5 * 1024 * 1024 // 5M
    const fileHash = await generateFileHash(file)
    const chunks = sliceFile(file, chunkSize)

    // 检查已上传分片
    const uploadedChunks = await checkUploadedChunks(fileHash)
    // 上传分片
    await uploadChunks(file, chunks, uploadedChunks, fileHash)
    // 合并分片
    await mergeChunks(fileHash, chunks.length)
    console.log('File uploaded successfully!')
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 使用onUploadProgress监听文件上传进度

Axios可以使用onUploadProgress来监听文件上传进度

await axios.post('http://127.0.0.1:3000/upload', formData, {
    headers: { 'Content-Type': 'multipart/form-data' },
    onUploadProgress: (progressEvent) => {
        console.log('progressEvent: ', progressEvent);
        if (progressEvent.total) {
            console.log(
                `Chunk ${i} progress: ${Math.round(
                    (progressEvent.loaded / progressEvent.total) * 100
                )}%`
            )
        }
    }
})
1
2
3
4
5
6
7
8
9
10
11
12
13

progressEvent属性

image-20250120102134620

通过(progressEvent.loaded / progressEvent.total) * 100来获取上传进度

# FileUpload组件封装

<script setup lang="ts">
import { uploadRecord } from '@/api/audio'
import { useHomeContext } from '@/stores/modules/audio'
import { Plus } from '@element-plus/icons-vue'
import Progress from './Progress.vue'
import {
  ElButton,
  ElIcon,
  ElLoading,
  ElMessage,
  ElUpload,
  type UploadRequestOptions
} from 'element-plus'
import { ref } from 'vue'

type FileExternal = {
  uid: number
  percent?: number
  status: 'uploading' | 'success'
} & File

const fileList = ref<FileExternal[]>([])

const fileTypeWhiteList = ['audio/wav', 'audio/mpeg']

const { refreshAudios } = useHomeContext()

const httpRequest = (options: UploadRequestOptions) => {
  const { file, onProgress, onSuccess, onError } = options
  if (!fileTypeWhiteList.includes(file.type)) {
    ElMessage.warning('上传文件格式不支持')
    return false
  }
  if (file.size > 200 * 1024 * 1024) {
    ElMessage.warning('上传文件大小最大200M')
    return false
  }
  fileList.value.push(file)
  console.log('fileList: ', fileList.value)
  const formData = new FormData()
  formData.append('audio', file)

  // const loadingInstance = ElLoading.service({
  //   lock: true,
  //   text: '上传中',
  //   background: 'rgba(0, 0, 0, 0.7)'
  // })

  uploadRecord(formData, (progressEvent) => {
    if (progressEvent.total) {
      let percentCompleted = Math.round((progressEvent.loaded / progressEvent.total) * 100)
      const uploadFile = fileList.value.find((item) => item.uid === file.uid)
      if (uploadFile) {
        uploadFile.percent = percentCompleted
      }
      onProgress({ percent: percentCompleted })
    }
  })
    .then((response) => {
      if (response.code !== 200) {
        ElMessage.error(response.msg)
        return
      }
      onSuccess(response.data)
      // fileList.value = fileList.value.filter((item) => item.uid !== file.uid)
      refreshAudios()
    })
    .catch((error) => {
      onError(error)
    })
    .finally(() => {
      // loadingInstance.close()
    })
}
const handleSuccess = () => {
  ElMessage.success('上传成功')
}
const handleError = () => {
  ElMessage.error('上传失败')
}
</script>

<template>
  <ElUpload
    v-model:file-list="fileList"
    :http-request="httpRequest"
    :show-file-list="false"
    @success="handleSuccess"
    @error="handleError"
    :accept="'.mp3,.wav'"
  >
    <slot name="default">
      <ElButton type="primary" class="upload-btn--style">
        <template #default> 添加音频 </template>
        <template #icon>
          <ElIcon size="16" class="mr-2">
            <Plus />
          </ElIcon>
        </template>
      </ElButton>
    </slot>
  </ElUpload>
  <div v-for="(file, index) in fileList" :key="file.uid">
    <Progress
      v-if="file.uid"
      :index="index"
      :name="file.name"
      :percentage="file.percent || 0"
      :status="file.status"
    />
  </div>
</template>

<style lang="css" scoped>
.upload-btn--style {
  border-radius: 10px;
  opacity: 1;
  background: linear-gradient(180deg, #328eda 0%, #0039a4 100%);
  width: 192px;
  height: 40px;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122

Progress是进度条组件

<script setup lang="ts">
import { ElProgress } from 'element-plus'
import { computed } from 'vue'

interface ProgressProps {
  index: number
  percentage: number
  name: string
  status: 'success' | 'uploading'
}

const props = defineProps<ProgressProps>()

/**
 * 定义百分比,未完成时进度为99
 */
const percent = computed(() => {
  if (props.percentage === 100 && props.status === 'uploading') {
    return 99
  } else return props.percentage
})
</script>

<template>
  <Teleport to="body">
    <ElProgress
      :style="[{ top: `${index * 2 + 2}rem` }]"
      v-if="status === 'uploading'"
      :class="$style.progress"
      :percentage="percent"
      striped
      striped-flow
      text-inside
      :stroke-width="15"
    ></ElProgress>
  </Teleport>
</template>

<style lang="css" module>
.progress {
  position: fixed;
  top: 2rem;
  right: 1.5rem;
  width: 20rem;
  height: 20px;
  max-width: 500px;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

# ElUpload的httpRequest参数

当一次上传多个文件时,options会输出多次

const httpRequest = (options: UploadRequestOptions) => {
  const { file, onProgress, onSuccess, onError } = options
  console.log('options: ', options);
  if (!fileTypeWhiteList.includes(file.type)) {
    ElMessage.warning('上传文件格式不支持')
    return false
  }
  if (file.size > 200 * 1024 * 1024) {
    ElMessage.warning('上传文件大小最大200M')
    return false
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
/**
 * 计算文件哈希值
 */
const calculateFileHash = (file: File): Promise<string> => {
  return new Promise((resolve) => {
    const reader = new FileReader()
    reader.onload = (e) => {
      const arrayBuffer = e.target?.result as ArrayBuffer
      const hashArray = Array.from(new Uint8Array(arrayBuffer)).slice(0, 1024) // 取前1024字节作为简单哈希
      const hashString = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
      resolve(hashString)
    }
    // 只读取文件的前部分来计算哈希,避免大文件性能问题
    const blob = file.slice(0, 1024 * 1024) // 读取前1MB
    reader.readAsArrayBuffer(blob)
  })
}

// 存储文件哈希值的映射
const fileNameToHash = ref<Map<string, string>>(new Map())
const hashToFileName = ref<Map<string, string>>(new Map())

const beforeUpload = async (file: File) => {
  // 计算文件哈希
  const fileHash = await calculateFileHash(file)

  // 检查文件是否重复
  const isDuplicateName = fileNameToHash.value.has(file.name)
  const isDuplicateContent = hashToFileName.value.has(fileHash)

  if (isDuplicateName && isDuplicateContent) {
    ElMessage.warning(`文件"${file.name}"已存在!`)
    return false
  }
  fileNameToHash.value.set(file.name, fileHash)
  hashToFileName.value.set(fileHash, file.name)

  // 将文件先添加到待处理列表中
  pendingFiles.value.push(file)
  fileSizeTotal.value += file.size
  if (fileSizeTotal.value > MAX_UPLOAD_LIMIT) {
    ElMessage.warning('上传总文件大小最大500M')
    // 回退添加的文件大小
    fileSizeTotal.value -= file.size
    // 移除文件哈希
    fileNameToHash.value.delete(file.name)
    hashToFileName.value.delete(fileHash)
    pendingFiles.value = []
    return false
  }
  return true
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

# 文件类型判断

1.通过MIME判断

上传的file.type是文件的MIME类型

但是单纯靠MIME来判断可能会有问题

在macos上传的markdown文件其file.type="text/x-markdown",但是在Windows上传的md文件file.type=""。

这是因为:

  • Windows 原生没有 .md 的 MIME 类型映射(特别是默认情况下未安装支持 Markdown 的软件)。
  • 由于 Windows 的 MIME 类型主要由 注册表 控制,而 .md 可能没有在注册表中正确映射,因此 file.type 可能为空。

所以文件类型判断要结合扩展名和MIME类型来综合判断

// 获取文件扩展名
const fileName = file.name
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase()

// 检查文件是否符合允许的类型
// 1. 如果文件类型不为空,检查是否在白名单中
// 2. 如果文件类型为空,检查扩展名是否在允许列表中
const isValidType =
      (file.type && allowedMimeTypes.includes(file.type)) ||
      (!file.type && allowedExtensions.includes(fileExtension))

if (!isValidType) {
    ElMessage.warning('上传文件格式不支持')
    return false
}

// 计算文件哈希
const fileHash = await calculateFileHash(file)

// 检查文件是否重复
const isDuplicateName = fileNameToHash.value.has(file.name)
const isDuplicateContent = hashToFileName.value.has(fileHash)

if (isDuplicateContent) {
    ElMessage.warning(`已存在相同内容的文件!`)
    return false
}
if (isDuplicateName && isDuplicateContent) {
    ElMessage.warning(`文件"${file.name}"已存在!`)
    return false
}
// 后端校验
const isDuplicateOnServer = await vetrfyFileOnServer(file.name)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33