功能: 多文件同步上传、拖动上传、实时上传进度条、上传前的删除文件、原生file控件的美化
搁置的功能: 取消上传(上传过程中取消,即取消网络请求abort)、上传文件夹、大文件切片、以及很多限制条件未处理(重复上传、文件格式。。。)
bug: 文件总大小(。。。竟然从data选项上获取的数组是类数组)
Node服务器的前置准备:
js
新建文件夹: file_upload_serve
初始化npm: npm init -y
安装工具: npm add express multer
nodemon工具: npm install nodemon -g
axios: npm install axios -s
Node运行版本: 18.17.1
修改package.json文件
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
改为:监听app.js
"scripts": {
"dev": "nodemon ./app.js"
},
启动: npm run dev
Node > file_upload_serve > app.js
按前置准备完成,其他无需更改,请求部分全在app.js
javascript
/*
* @Description:
* @Last Date: Do not edit
*/
const express = require('express')
// post请求解析body
const bodyParser = require('body-parser')
// 上传工具库
const multer = require('multer')
const { writeFileSync } = require('fs')
const { resolve } = require('path')
const path = require('path')
const fs = require('fs')
const app = express()
app.use(bodyParser.json({limit: '10mb', extended: true}))
// 静态资源共享(下载需要)
app.use(express.static(path.join(__dirname, 'public')))
// const storage = multer.diskStorage({
// destination: function (req, file, callback) {
// // 第一个参数: errorMessage; 参数2: 目标,即下载到哪个文件夹下
// callback(null, 'uploads/')
// },
// filename: function (req, file, callback) {
// // 获取上传文件的后缀名
// const ext = file.originalname.split('.')[1]
// callback(null, Date.now() + '.' + ext)
// }
// })
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/') // 分片存储目录
},
filename: (req, file, cb) => {
const ext = file.originalname.split('.')[1]
if(req.body.rename) {
cb(null, Date.now() + '.' + ext) // 单文件名
} else {
cb(null, `${req.body.index}-${req.body.fileName}`) // 分片文件名
}
}
})
// 生成upload对象
const upload = multer({
storage,
})
// 设置请求头
app.all('*', (req, res, next) => {
// 允许所有不同源的地址访问
res.header('Access-Control-Allow-Origin', '*');
// 跨域允许的请求方式
res.header('Access-Control-Allow-Methods', 'GET, POST');
// x-ext: 获取文件的后缀名
// res.header('Access-Control-Allow-Headers', 'Content-Type, x-ext');
// res.header('Access-Control-Allow-Headers', 'Content-Type');
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, x-ext");
if (req.method.toLowerCase() == 'options'){
res.send(200); //让options尝试请求快速结束
} else {
next()
}
})
/* 上传方式1: multipart/form-data
*
* upload.single 单文件上传
*/
app.post('/file', upload.single('file'), (req, res) => {
if(req.file){
res.send('formData上传成功')
} else {
res.send('form-data上传失败')
}
})
/* 上传方式2: base64
*
* upload.single 单文件上传
*/
app.post('/base64', (req, res) => {
const { file, ext, fileName } = req.body
const binaryData = Buffer.from(file, 'base64')
if(!fileName) {
writeFileSync(resolve(__dirname, 'uploads/' + Date.now() + '.' + ext), binaryData, 'binary')
} else {
writeFileSync(resolve(__dirname, 'uploads/' + fileName), binaryData, 'binary')
}
res.send('base64文件流上传成功')
})
/* 上传方式3: binary 二进制
*
* upload.single 单文件上传
*/
app.post('/binary', (req, res) => {
const ext = req.headers['x-ext']
const buffers = []
req.on('data', chunk => {
buffers.push(chunk)
}).on('end', () => {
const binaryData = Buffer.concat(buffers)
writeFileSync(resolve(__dirname, 'uploads/' + Date.now() + '.' + ext), binaryData, 'binary')
res.send('二进制流上传成功')
})
})
/* 多文件上传: formData
*
* upload.array('formData中的字段名', 最大上传数量):
*/
app.post('/files', upload.array('files', 4), (req, res) => {
console.log(req.files)
if(req.files){
res.send('多文件formData上传成功')
} else {
res.send('多文件formData上传失败')
}
})
/* 文件下载
* __dirname: 代表当前文件<app.js>所在的文件路径
*/
app.get('/download', (req, res) => {
try{
// 下载路径: __dirname 拼接 第二个参数的路径
const filePath = path.join(__dirname, '/public/download/1731726859151.txt')
res.download(filePath)
}catch(e){
console.log(e)
}
})
app.post('/merge', async (req, res) => {
const uploadPath = '/uploads'
let files = fs.readdirSync(path.join(process.cwd(), uploadPath)) // 获取所有的分片数据
console.log(files)
console.log(req.body.fileName)
files = files.sort((a, b) => a.split('-')[0] - b.split('-')[0]) // 将分片按照文件名进行排序
const writePath = path.join(process.cwd(), uploadPath, `${req.body.fileName}`) // 生成新的文件路径
files.forEach((item) => {
fs.appendFileSync(writePath, fs.readFileSync(path.join(process.cwd(), uploadPath, item))) // 读取分片信息,追加到新文件路径尾部
fs.unlinkSync(path.join(process.cwd(), uploadPath, item)) // 将读取过的分片进行删除
})
res.send('ok')
})
app.listen(8888, () => {console.log("链接成功")})
客户端
javascript
<!--
* @Description:
* @Last Date: Do not edit
-->
<template>
<div class="container">
<header>
<div
class="box"
@drop="handleClick"
@dragenter="handleClick"
@dragover="handleClick"
@dragleave="handleClick"
>
<div class="box-font">
<div>
<span style="display: flex; align-items: center"
><i class="el-icon-upload"> </i>
<p>将目录或多个文件拖拽到此进行扫描</p></span
>
</div>
<div>
<span>支持的文件类型: .JPG、.JPEG、.BMP、.PNG、.GIF、.ZIP、</span>
</div>
<div><span>每个文件允许的最大尺寸: 1M</span></div>
</div>
</div>
</header>
<main>
<div class="main-choose-files-btn">
<div class="file-box">
<input type="button" class="btn" value="选择文件" />
<input
type="file"
class="file"
@change="previewMoreFilesByFormData"
multiple
/>
</div>
<div class="file-box">
<input type="button" class="btn" value="选择文件夹" />
<input
type="file"
class="file"
@change="previewMoreFilesByFormData"
multiple
/>
</div>
</div>
<div>
<el-table :data="tableData" stripe style="width: 85%">
<!-- <el-table-column
v-for="item in tableColumn"
:key="item.prop"
:prop="item.prop"
:label="item.label"
></el-table-column> -->
<el-table-column
prop="name"
label="文件名"
width="240"
fixed
></el-table-column>
<el-table-column prop="type" label="类型"></el-table-column>
<el-table-column prop="size" label="大小"></el-table-column>
<el-table-column prop="state" label="状态">
<!-- 当template中有多个元素需要切换时,需要在最外层使用div将所有元素包裹住 -->
<!-- slot-scope="scope" 必须加,否则数据不是响应式的 -->
<template slot-scope="scope">
<div>
<div
v-show="
scope.row.progressPercent > 0 &&
scope.row.progressPercent < 100
"
>
<el-progress
:text-inside="true"
:stroke-width="15"
:percentage="scope.row.progressPercent"
/>
</div>
<div
v-show="scope.row.progressPercent < 1"
slot="reference"
class="name-wrapper"
>
<el-tag size="medium"> 待上传 </el-tag>
</div>
<div
v-show="scope.row.progressPercent === 100"
slot="reference"
class="name-wrapper"
>
<el-tag size="medium"> 已上传 </el-tag>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作">
<template slot-scope="scope">
<i class="el-icon-delete" @click="deleteFile(scope.row)"></i>
</template>
</el-table-column>
</el-table>
</div>
</main>
<footer>
<el-row>
<el-button class="foot-btn" size="mini">
<span>文件数量: {{ tableData.length }}</span>
</el-button>
<el-button class="foot-btn" type="success" plain size="mini">
成功数量: {{ successCount }}
</el-button>
<el-button class="foot-btn" size="mini">
<span>总大小: {{ countSize }} bype</span>
</el-button>
</el-row>
<el-row class="upload-btn">
<el-button type="primary" @click="handelUploadMoreFile"
>开始上传</el-button
>
</el-row>
</footer>
</div>
</template>
<script>
import axios from "axios"
export default {
data() {
return {
ext: undefined, // 文件后缀名
tableData: [],
tableColumn: [
{ prop: "name", label: "文件名" },
{ prop: "type", label: "类型" },
{ prop: "size", label: "大小" },
{ prop: "progressPercent", label: "状态" },
{ prop: "option", label: "操作" },
],
countSize: 0, // 文件总大小
filesNumber: 1, // 列表文件总条数
successCount: 0, // 上传成功条数
}
},
mounted() {
// 阻止事件冒泡,防止在拖拽后意外打开新标签页
document.body.ondrop = function (event) {
event.preventDefault()
event.stopPropagation()
}
},
computed: {
// countSize() {
// if (this.tableData.length > 1) {
// return [1306691, 5379214, 3496177].reduce((a, b) => {
// return a + b
// })
// } else if (this.tableData.length === 1) {
// return this.tableData[0].size
// } else {
// return 0
// }
// },
},
methods: {
// 读取多个文件
previewMoreFilesByFormData(e, drop) {
console.log(Array.isArray(this.tableData))
console.log(this.tableData)
let files
if (!drop) {
files = e.target.files
} else {
files = e
}
// 获取文件后缀名
this.ext = files[0].name.split(".")[1]
if (!files) return
var i = 0
var _this = this
var funcs = function () {
if (files[i]) {
var reader = new FileReader()
reader.onload = function (e) {
const uint8Array = new Uint8Array(e.target.result)
const str = uint8Array.reduce((prev, byte) => {
prev += String.fromCharCode(byte)
return prev
}, "")
let now = new Date()
// 由于JS执行速度很快,极大可能会得到一样的时间戳,故将timestamp加上下标
// timestamp的作用是在将来删除文件时,作为唯一id对比删除
let timestamp = now.getTime()
// 将预览的文件中数据转换到table中
_this.tableData.push({
timestamp: timestamp + i,
name: files[i].name,
type: files[i].type,
size: files[i].size,
progressPercent: 0,
dataBase64: btoa(str),
})
// progressPercent 上传进度条
i++
funcs() // onload为异步调用
}
reader.readAsArrayBuffer(files[i])
}
}
funcs()
// 计算列表中文件的总大小
this.getCountSize()
},
/** 删除上传文件
* 不能通过数组下标去删。删除再添加新文件时,下标会重复
* @param row(行数据)
*/
deleteFile(row) {
this.tableData = this.tableData.filter(
(item) => item.timestamp !== row.timestamp
)
},
// 这里有bug,原因在 tableData.push 那得到的结果是个类数组
getCountSize() {
this.countSize = 0
if (this.tableData.length > 1) {
this.countSize = this.tableData.reduce((a, b) => {
return a.size + b.size
})
} else if (this.tableData.length === 1) {
this.countSize = this.tableData[0].size
} else {
this.countSize = 0
}
},
// 上传文件
handelUploadMoreFile() {
console.log(this.tableData)
const List = []
for (let i = 0; i < this.tableData.length; i++) {
const ext = this.ext
var a = axios({
url: "http://localhost:8888/base64",
method: "post",
data: {
ext,
fileName: this.tableData[i].name,
file: this.tableData[i].dataBase64,
},
onUploadProgress: (progressEvent) => {
/** 上传进度条
* progressEvent.loaded: 已上传文件大小
* progressEvent.total: 被上传文件的总大小
*/
this.tableData[i].progressPercent =
(progressEvent.loaded / progressEvent.total) * 100
},
}).then((res) => {
// this.$message({
// message: '文件上传成功',
// type: 'success'
// })
// console.log(res)
})
}
// 合并异步上传
Promise.all(List)
.then((res) => {
console.log(1111)
})
.catch((err) => {
console.log(err)
})
},
// 处理鼠标拖放事件
handleClick(e) {
console.log(e.type)
if (e.type == "dragenter") {
// this.className = "drag_hover"
}
if (e.type == "dragleave") {
// this.className = ""
}
if (e.type == "drop") {
var files = e.dataTransfer.files
this.className = ""
if (files.length != 0) {
console.log(files)
this.previewMoreFilesByFormData(files, "drop")
}
}
if (e.type == "dragover") {
// e.dataTransfer.dragEffect = "copy"
}
},
},
}
</script>
<style lang="scss">
body,
html {
list-style: none;
padding: 0;
margin: 0;
}
.container {
width: 85%;
margin: 25px auto;
.box {
width: 85%;
height: 300px;
border-style: dashed; // border虚线
border-width: 1px;
margin-bottom: 20px;
display: flex; /* 启用 Flexbox */
justify-content: center; /* 水平居中 */
align-items: center; /* 垂直居中 */
.box-font {
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
span {
display: block;
}
}
}
main {
.main-choose-files-btn {
display: flex;
gap: 100px;
height: 44px;
}
}
footer {
margin-top: 20px;
.upload-btn {
margin-top: 10px;
}
}
}
// 对原生file控件优化
.btn,
.file {
@extend .merge-input;
}
.merge-input {
// display: block;
position: absolute;
width: 75px;
height: 35px;
color: #fff;
border-radius: 4px;
border-color: #409eff;
}
.btn {
z-index: 2;
background: #409eff; // #66b1ff 409eff
pointer-events: none; /* 让事件传递到下一层,即: btn的层级比file高,但btn能触发file的事件 */
}
.file {
z-index: 1;
}
// el-table表头样式修改
.el-table th {
font-size: 13px;
font-weight: 700;
}
.el-table .el-table__header th,
.el-table .el-table__header tr,
.el-table .el-table__header td {
background: #f5f8fd;
}
.el-icon-upload {
font-size: 35px;
}
</style>