功能: 多文件同步上传、拖动上传、实时上传进度条、上传前的删除文件、原生file控件的美化
搁置的功能: 取消上传(上传过程中取消,即取消网络请求abort)、上传文件夹、大文件切片、以及很多限制条件未处理(重复上传、文件格式。。。)
bug: 文件总大小(。。。竟然从data选项上获取的数组是类数组)
Node服务器的前置准备:
新建文件夹: file_upload_serve初始化npm: npm init -y安装工具: npm add express multernodemon工具: npm install nodemon -gaxios: npm install axios -sNode运行版本: 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
/** @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.bodyconst 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("链接成功")})
客户端
<!--* @Description: * @Last Date: Do not edit
-->
<template><div class="container"><header><divclass="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="选择文件" /><inputtype="file"class="file"@change="previewMoreFilesByFormData"multiple/></div><div class="file-box"><input type="button" class="btn" value="选择文件夹" /><inputtype="file"class="file"@change="previewMoreFilesByFormData"multiple/></div></div><div><el-table :data="tableData" stripe style="width: 85%"><!-- <el-table-columnv-for="item in tableColumn":key="item.prop":prop="item.prop":label="item.label"></el-table-column> --><el-table-columnprop="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><divv-show="scope.row.progressPercent > 0 &&scope.row.progressPercent < 100"><el-progress:text-inside="true":stroke-width="15":percentage="scope.row.progressPercent"/></div><divv-show="scope.row.progressPercent < 1"slot="reference"class="name-wrapper"><el-tag size="medium"> 待上传 </el-tag></div><divv-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 filesif (!drop) {files = e.target.files} else {files = e}// 获取文件后缀名this.ext = files[0].name.split(".")[1]if (!files) returnvar i = 0var _this = thisvar 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 = 0if (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.extvar 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.filesthis.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 409effpointer-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>