# 文件上传与下载
# 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>
2
3
4
5
6
在上述示例中:
enctype="multipart/form-data"用于告诉服务器表单数据包含文件。input元素的type属性设置为 "file",这样用户就可以选择文件。action属性定义了表单数据将被发送到的服务器端的处理程序。在这里,它被设置为 "/upload",你需要将其替换为实际的服务器端处理程序的地址。
# formdata上传
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)
})
}
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);
2
namevalue中包含的数据对应的表单名称。value表单的值。可以是USVString(opens new window) 或Blob(opens new window) (包括子类型,如File(opens new window))。filename可选传给服务器的文件名称 (一个
USVString(opens new window)), 当一个Blob(opens new window) 或File(opens new window) 被作为第二个参数的时候,Blob(opens new window) 对象的默认文件名是 "blob"。File(opens new window) 对象的默认文件名是该文件的名称。
注意: 如果你指定一个
Blob(opens new window) 作为数据添加到FormData对象中, 文件名会被放在 "Content-Disposition" 头部(常常会根据浏览器变化而变化)传给服务器。
# formData上传blob
formData上传图片,当append File对象时可以正常上传,而当append一个Blob对象时上传失败。
原因是formData上传blob时默认文件名是'blob',需要设置第三个参数filename
# 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>
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)
})
},
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)
}
})
})
}
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)
},
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>
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
}
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
)}%`
)
}
}
})
}
}
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 })
}
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!')
}
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
)}%`
)
}
}
})
2
3
4
5
6
7
8
9
10
11
12
13
progressEvent属性
通过(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>
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>
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
}
}
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
}
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)
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
← 浏览器资源加载的优先级 前端图片压缩 →