Vue通过file控件上传文件到Node服务器

功能: 多文件同步上传、拖动上传、实时上传进度条、上传前的删除文件、原生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>
相关推荐
碎像11 分钟前
Vue 中的透传,插槽,依赖注入
前端·javascript·vue.js
想你的风吹到了瑞士15 分钟前
vue如何实现组件切换
前端·javascript·vue.js
只想静静的1 小时前
vue 自定义指令( 全局自定义指令 | 局部自定义指令 )
前端·javascript·vue.js
guokanglun1 小时前
Vue模块化开发的理解
前端·javascript·vue.js
孑么2 小时前
Vue前端框架开发 npm安装Vite或CLI
前端·javascript·vue.js·typescript·前端框架·npm·node.js
孑么2 小时前
GDPU Vue前端框架开发 单文件组件
前端·javascript·vue.js·前端框架·npm·node.js·ecmascript
我总是词不达意3 小时前
uniapp vue3小程序报错Cannot read property ‘__route__‘ of undefined
前端·javascript·vue.js·小程序·uni-app
代码搬运媛3 小时前
vue 项目自动生成组件说明文档 styleguidist
前端·javascript·vue.js
bingbingyihao3 小时前
接口压力测试、性能测试工具
vue.js·测试工具·压力测试
GISer_Jing3 小时前
前端Javascript、Vue、CSS等场景面试题目(二)
前端·javascript·vue.js