vue+elementui導入Excel文件(基於vue-element-admin中的uploadExcel組件)
需求說明
實現Excel文件的上傳,將文件傳給后端完成數據的批量導入。並在前端進行解析,將上傳的Excel回顯到表格中。
備注:本功能僅實現了簡單excel表格數據的導入,限制一次只能上傳一個excel。
接下來就讓我們開動吧!
第一步:構建頁面
我這邊就拿我正在做的一個項目舉例,其中有一個功能是物品信息的管理。需要實現批量新增(即excel導入)。
首先我們在已有的頁面上新增一個按鈕,用以激活dialog。我這邊將導入excel的部分放到dialog中。
<el-button
class="filter-item"
size="mini"
type="primary"
icon="el-icon-upload"
@click="dialogVisible = true"
>批量導入</el-button>
<el-dialog title="導入表單" :visible.sync="dialogVisible">
<!-- 導入excel,一會會將這里補充 -->
</el-dialog>
data(){
return{
dialogVisible: false, //dialog是否可見
}
}
如下圖所示,已經新增了一個批量導入的按鈕。當我們點擊時dialog會變為可見,即彈出dialog。
第二步:利用現成的組件
因為我這邊用到的是el-admin的開源項目進行二次開發,其中涉及到的excel上傳功能是來自於vue-element-admin開源項目的uploadExcel組件,所以我們直接去看封裝好的組件,進行修改。
在vue-element-admin的在線文檔中,我們找到關於Excel的文檔說明。如下圖所示:
官方Demo(不涉及提交給后台的動作)
我們點擊在線代碼去查看示例代碼。方便起見,我直接搬運過來了。
<template>
<div class="app-container">
<upload-excel-component :on-success="handleSuccess" :before-upload="beforeUpload" />
<el-table :data="tableData" border highlight-current-row style="width: 100%;margin-top:20px;">
<el-table-column v-for="item of tableHeader" :key="item" :prop="item" :label="item" />
</el-table>
</div>
</template>
<script>
import UploadExcelComponent from '@/components/UploadExcel/index.vue'
export default {
name: 'UploadExcel',
components: { UploadExcelComponent },
data() {
return {
tableData: [],
tableHeader: []
}
},
methods: {
beforeUpload(file) {
const isLt1M = file.size / 1024 / 1024 < 1
if (isLt1M) {
return true
}
this.$message({
message: 'Please do not upload files larger than 1m in size.',
type: 'warning'
})
return false
},
handleSuccess({ results, header }) {
this.tableData = results
this.tableHeader = header
}
}
}
</script>
核心:UploadExcelComponent組件
可以看到,其中有一個UploadExcelComponent
組件,我們繼續去尋找該組件的代碼。如下:
<template>
<div>
<input ref="excel-upload-input" class="excel-upload-input" type="file" accept=".xlsx, .xls" @change="handleClick">
<div class="drop" @drop="handleDrop" @dragover="handleDragover" @dragenter="handleDragover">
Drop excel file here or
<el-button :loading="loading" style="margin-left:16px;" size="mini" type="primary" @click="handleUpload">
Browse
</el-button>
</div>
</div>
</template>
<script>
import XLSX from 'xlsx'
export default {
props: {
beforeUpload: Function, // eslint-disable-line
onSuccess: Function// eslint-disable-line
},
data() {
return {
loading: false,
excelData: {
header: null,
results: null
}
}
},
methods: {
generateData({ header, results }) {
this.excelData.header = header
this.excelData.results = results
this.onSuccess && this.onSuccess(this.excelData)
},
handleDrop(e) {
e.stopPropagation()
e.preventDefault()
if (this.loading) return
const files = e.dataTransfer.files
if (files.length !== 1) {
this.$message.error('Only support uploading one file!')
return
}
const rawFile = files[0] // only use files[0]
if (!this.isExcel(rawFile)) {
this.$message.error('Only supports upload .xlsx, .xls, .csv suffix files')
return false
}
this.upload(rawFile)
e.stopPropagation()
e.preventDefault()
},
handleDragover(e) {
e.stopPropagation()
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
},
handleUpload() {
this.$refs['excel-upload-input'].click()
},
handleClick(e) {
const files = e.target.files
const rawFile = files[0] // only use files[0]
if (!rawFile) return
this.upload(rawFile)
},
upload(rawFile) {
this.$refs['excel-upload-input'].value = null // fix can't select the same excel
if (!this.beforeUpload) {
this.readerData(rawFile)
return
}
const before = this.beforeUpload(rawFile)
if (before) {
this.readerData(rawFile)
}
},
readerData(rawFile) {
this.loading = true
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = e => {
const data = e.target.result
const workbook = XLSX.read(data, { type: 'array' })
const firstSheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[firstSheetName]
const header = this.getHeaderRow(worksheet)
const results = XLSX.utils.sheet_to_json(worksheet)
this.generateData({ header, results })
this.loading = false
resolve()
}
reader.readAsArrayBuffer(rawFile)
})
},
getHeaderRow(sheet) {
const headers = []
const range = XLSX.utils.decode_range(sheet['!ref'])
let C
const R = range.s.r
/* start in the first row */
for (C = range.s.c; C <= range.e.c; ++C) { /* walk every column in the range */
const cell = sheet[XLSX.utils.encode_cell({ c: C, r: R })]
/* find the cell in the first row */
let hdr = 'UNKNOWN ' + C // <-- replace with your desired default
if (cell && cell.t) hdr = XLSX.utils.format_cell(cell)
headers.push(hdr)
}
return headers
},
isExcel(file) {
return /\.(xlsx|xls|csv)$/.test(file.name)
}
}
}
</script>
<style scoped>
.excel-upload-input{
display: none;
z-index: -9999;
}
.drop{
border: 2px dashed #bbb;
width: 600px;
height: 160px;
line-height: 160px;
margin: 0 auto;
font-size: 24px;
border-radius: 5px;
text-align: center;
color: #bbb;
position: relative;
}
</style>
這個組件是用來解析我們所上傳的excel的,具體大家可以自行研究,主要是將excel中的內容解析成header和results,我們主要用解析后的信息回顯給table。
在demo的基礎上修改
了解了這些,我們就可以在dialog中完善相應的代碼了。這邊可以根據模板做修改。
<el-dialog title="導入表單" :visible.sync="dialogVisible">
<div class="app-container">
<div>
<el-button
class="filter-item"
size="mini"
type="primary"
icon="el-icon-download"
@click="downloadExceltoLocalFile()"
>下載模板</el-button>
</div>
<upload-excel-component
:on-success="handleSuccess"
:before-upload="beforeUpload"
/>
<el-table
max-height="300"
:data="tableData"
border
highlight-current-row
style="width: 100%; margin-top: 20px"
>
<el-table-column
v-for="item of tableHeader"
:key="item"
:prop="item"
:label="item"
>
</template>
</el-table-column>
</el-table>
<el-divider />
<el-button
style="float: right"
type="primary"
:loading="loading"
@click="submitExcel()"
>確認並導入</el-button>
</div>
</el-dialog>
-
這里我新增了一個下載模板的按鈕,同樣是調用后端接口去獲得模板excel,然后點擊進行下載。如果大家有需要我下次詳細的講怎么實現。
-
同時這邊跟demo一樣,用了uploadExcelComponent組件。怎么引入應該不用多說了吧。該組件提供了兩個回調函數,在官方文檔里都有描述。
before-upload: 在上傳前所做的動作
on-success: 解析成功后觸發的回調,我們在這里實現對表格中表頭和內容的賦值
-
最后是一個確認的按鈕,加入點擊事件。
第三步:實現各個方法
- 下載模板按鈕這邊有一個
downloadExceltoLocalFile()
事件。
downloadExceltoLocalFile() {
crudMaterial
.downloadExcel()
.then((res) => {
downloadFile(res, '物料模板', 'xlsx')
})
.catch((err) => {
this.$message.error = err.message
})
},
你會發現缺了一個downloadFile的方法,該方法這個框架已經提供了,我們只需要import { downloadFile } from '@/utils/index'
即可,具體的內容自行查閱相關資料或評論。其中
crudMaterial
.downloadExcel()
這個是我自己調后台的API接口,請自行修改。
- 組件的回調函數
// before-upload
beforeUpload(file) {
this.files = file
console.log(this.files)
const extension = file.name.substring(file.name.lastIndexOf('.') + 1)
const isLt5M = file.size / 1024 / 1024 < 5
if (extension !== 'xlsx' && extension !== 'xls') {
this.$message({
message: '只能上傳Excel(即后綴是.xlsx或者.xls)的文件.',
type: 'warning'
})
return false
}
if (isLt5M) {
return true
}
this.$message({
message: '請不要上傳大於5MB的文件.',
type: 'warning'
})
return false
},
我這邊只是一些簡單的對后綴和大小的判斷,大家根據實際自行修改。
handleSuccess({ header, results }) {
this.tableData = results
this.tableHeader = header
},
該回調即對提交的excel文件進行解析,同時給表格賦值。
- 點擊提交的方法
submitExcel() {
// 裝載成formdata數據
const formdata = new FormData()
formdata.append('file', this.files, this.files.name)
this.loading = true
this.doImport(formdata)
},
async doImport(data) {
try {
const res = await crudMaterial.importMaterial(data)
console.log('importMaterial', res)
this.loading = false
this.$message.success('導入成功')
} catch (err) {
console.log('importMaterial', err)
this.loading = false
this.$message.error('導入失敗')
}
},
因為我們是要將excel傳給后台服務器,所以我們需要新建一個formdata的變量,將formdata包裝好數據傳給后台,否則會出錯。
doImport
則是請求的方法,閱讀應該沒有難度。其中API接口如下:
至此,應該就差不多了。我們運行看一下效果。
點擊瀏覽或者拖拽都可以實現文件的上傳。
可以看到,上傳的excel能成功回顯,我們再點擊確認進行提交。
提示導入成功。功能基本實現。
完整代碼
api接口
import request from '@/utils/request'
export function downloadExcel() {
return request({
url: 'api/baseinfoMaterial/downloadExcel',
method: 'get',
responseType: 'blob'
})
}
export function importMaterial(data) {
return request({
url: 'api/baseinfoMaterial/importExcel',
method: 'post',
data
})
}
export default { downloadExcel, importMaterial }
index.vue 是這個頁面所有的內容,按需取用
<template>
<div class="app-container">
<!--工具欄-->
<div class="head-container">
<div v-if="crud.props.searchToggle">
<!-- 搜索 -->
<label class="el-form-item-label">物料類別</label>
<el-select
v-model="query.modelName"
clearable
filterable
placeholder="請選擇"
style="width: 185px"
class="filter-item"
@change="crud.toQuery"
>
<el-option
v-for="item in dict.material_category"
:key="item.id"
:label="item.label"
:value="item.value"
/>
</el-select>
<label class="el-form-item-label">物料名稱</label>
<el-input
v-model="query.materialName"
clearable
size="small"
placeholder="輸入物料名稱搜索"
style="width: 185px"
class="filter-item"
@keyup.enter.native="crud.toQuery"
/>
<label class="el-form-item-label">供應商</label>
<el-input
v-model="query.supplier"
clearable
placeholder="供應商"
style="width: 185px"
class="filter-item"
@keyup.enter.native="crud.toQuery"
/>
<label class="el-form-item-label">狀態</label>
<el-select
v-model="query.enabled"
clearable
size="small"
placeholder="請選擇"
class="filter-item"
style="width: 90px"
@change="crud.toQuery"
>
<el-option
v-for="item in enabledTypeOptions"
:key="item.key"
:label="item.display_name"
:value="item.key"
/>
</el-select>
<label class="el-form-item-label">創建時間</label>
<date-range-picker v-model="query.createTime" class="date-item" />
<rrOperation :crud="crud" />
</div>
<!--如果想在工具欄加入更多按鈕,可以使用插槽方式, slot = 'left' or 'right'-->
<crudOperation :permission="permission" />
<el-button
class="filter-item"
size="mini"
type="primary"
icon="el-icon-upload"
@click="dialogVisible = true"
>批量導入</el-button
>
<el-dialog title="導入表單" :visible.sync="dialogVisible">
<div class="app-container">
<div>
<el-button
class="filter-item"
size="mini"
type="primary"
icon="el-icon-download"
@click="downloadExceltoLocalFile()"
>下載模板</el-button
>
</div>
<upload-excel-component
:on-success="handleSuccess"
:before-upload="beforeUpload"
/>
<el-table
max-height="300"
:data="tableData"
border
highlight-current-row
style="width: 100%; margin-top: 20px"
>
<el-table-column
v-for="item of tableHeader"
:key="item"
:prop="item"
:label="item"
>
<template slot-scope="scope">
<template>
{{
scope.row[scope.column.property] === true
? '啟用'
: scope.row[scope.column.property] === false
? '禁用'
: scope.row[scope.column.property]
}}
</template>
</template>
</el-table-column>
</el-table>
<el-divider />
<el-button
style="float: right"
type="primary"
:loading="loading"
@click="submitExcel()"
>確認並導入</el-button
>
</div>
</el-dialog>
</div>
<!--表單組件-->
<el-dialog
:close-on-click-modal="false"
:before-close="crud.cancelCU"
:visible.sync="crud.status.cu > 0"
:title="crud.status.title"
width="500px"
>
<el-form
ref="form"
:model="form"
:rules="rules"
size="small"
label-width="80px"
>
<el-form-item label="物料名稱" prop="materialName">
<el-autocomplete
v-model="form.materialName"
style="width: 370px"
:fetch-suggestions="querySearchMaterials"
placeholder="請輸入物料名稱"
:trigger-on-focus="false"
>
<template slot-scope="{ item }">
<div>{{ (item.value = item.materialName) }}</div>
</template>
</el-autocomplete>
</el-form-item>
<el-form-item label="類別名稱">
<el-select
v-model="form.modelName"
filterable
placeholder="請選擇所屬類別"
>
<el-option
v-for="item in dict.material_category"
:key="item.id"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="物料編號" prop="materialNum">
<el-input
v-model="form.materialNum"
style="width: 370px"
placeholder="請輸入物料編號"
/>
</el-form-item>
<el-form-item label="物料型號" prop="materialType">
<el-input
v-model="form.materialType"
style="width: 370px"
placeholder="請輸入物料型號"
/>
</el-form-item>
<el-form-item label="品牌" prop="brand">
<el-input
v-model="form.brand"
style="width: 370px"
placeholder="請輸入品牌"
/>
</el-form-item>
<el-form-item label="單位" prop="unit">
<el-input
v-model="form.unit"
style="width: 370px"
placeholder="請輸入單位"
/>
</el-form-item>
<el-form-item label="計價方式" prop="pricingMode">
<el-input v-model="form.pricingMode" style="width: 370px" disabled />
</el-form-item>
<el-form-item label="參考價格" prop="referencePrice">
<el-input
v-model="form.referencePrice"
style="width: 370px"
placeholder="請輸入參考價格"
>
<template slot="append">元</template>
</el-input>
</el-form-item>
<el-form-item label="供貨商" prop="supplier">
<el-input
v-model="form.supplier"
style="width: 370px"
placeholder="請輸入供貨商"
/>
</el-form-item>
<el-form-item label="狀態" prop="enabled">
<el-radio
v-for="item in dict.material_status"
:key="item.id"
v-model="form.enabled"
:label="item.value === 'true'"
>{{ item.label }}</el-radio
>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="text" @click="crud.cancelCU">取消</el-button>
<el-button
:loading="crud.status.cu === 2"
type="primary"
@click="crud.submitCU"
>確認</el-button
>
</div>
</el-dialog>
<!--表格渲染-->
<el-table
ref="table"
v-loading="crud.loading"
lazy
:data="crud.data"
size="small"
style="width: 100%"
@selection-change="crud.selectionChangeHandler"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="modelName" label="類別名稱">
<template slot-scope="scope">
{{ dict.label.material_category[scope.row.modelName] }}
</template>
</el-table-column>
<el-table-column prop="materialNum" label="物料編號" />
<el-table-column prop="materialName" label="物料名稱" />
<el-table-column prop="materialType" label="物料型號" />
<el-table-column prop="brand" label="品 牌" />
<el-table-column prop="unit" label="單位" />
<el-table-column prop="pricingMode" label="計價方式" />
<el-table-column prop="referencePrice" label="參考價格" />
<el-table-column prop="supplier" label="供貨商" />
<el-table-column prop="enabled" label="狀態" align="center">
<template slot-scope="scope">
<a-switch
v-model="scope.row.enabled"
checked-children="啟用"
un-checked-children="禁用"
:disabled="scope.row.id === 1"
@change="changeEnabled(scope.row, scope.row.enabled)"
/>
</template>
</el-table-column>
<el-table-column prop="createTime" label="創建時間" />
<el-table-column
v-if="checkPer(['admin', 'material:edit', 'material:del'])"
label="操作"
width="150px"
align="center"
>
<template slot-scope="scope">
<udOperation :data="scope.row" :permission="permission" />
</template>
</el-table-column>
</el-table>
<!--分頁組件-->
<pagination />
</div>
</template>
<script>
import crudMaterial from '@/api/record/material'
import CRUD, { presenter, header, form, crud } from '@crud/crud'
import rrOperation from '@crud/RR.operation'
import crudOperation from '@crud/CRUD.operation'
import udOperation from '@crud/UD.operation'
import pagination from '@crud/Pagination'
import DateRangePicker from '@/components/DateRangePicker'
import UploadExcelComponent from '@/components/UploadExcel/index.vue'
import { downloadFile } from '@/utils/index'
const defaultForm = {
id: '',
materialNum: null,
materialName: null,
materialType: null,
unit: null,
pricingMode: '加權平均法',
referencePrice: null,
supplier: null,
createTime: null,
enabled: true,
modelName: null,
brand: null
}
export default {
name: 'Material',
components: {
pagination,
crudOperation,
rrOperation,
udOperation,
DateRangePicker,
UploadExcelComponent
},
mixins: [presenter(), header(), form(defaultForm), crud()],
dicts: ['material_category', 'material_status'],
cruds() {
return CRUD({
title: '物料管理',
url: 'api/baseinfoMaterial',
sort: 'id,desc',
crudMethod: { ...crudMaterial }
})
},
data() {
/* 金額格式校驗 */
var validatePrice = (rule, value, callback) => {
const reg = /(^[1-9]\d*(\.\d{1,2})?$)|(^0(\.\d{1,2})?$)/
if (value === '' || value === undefined || value == null) {
callback()
} else {
if (!reg.test(value) && value !== '') {
callback(new Error('請輸入正確的價格!整數或保留兩位小數'))
} else {
callback()
}
}
}
return {
tableData: [],
tableHeader: [],
dialogVisible: false,
loading: false,
files: [],
enabledTypeOptions: [
{ key: 'true', display_name: '正常' },
{ key: 'false', display_name: '禁用' }
],
permission: {
add: ['admin', 'material:add'],
edit: ['admin', 'material:edit'],
del: ['admin', 'material:del']
},
rules: {
materialName: [
{
required: true,
message: '請輸入物料名稱',
trigger: 'blur'
}
],
materialNum: [
{ required: true, message: '請輸入物料編號', trigger: 'blur' }
],
materialType: [
{ required: true, message: '請輸入物料型號', trigger: 'blur' }
],
brand: [{ required: true, message: '請輸入品牌', trigger: 'blur' }],
unit: [{ required: true, message: '請輸入單位', trigger: 'blur' }],
referencePrice: [
{ required: true, message: '請輸入參考價格', trigger: 'blur' },
{
validator: validatePrice,
trigger: 'blur'
}
]
},
queryTypeOptions: [
{ key: 'materialName', display_name: '物料名稱' },
{ key: 'materialType', display_name: '物料型號' },
{ key: 'supplier', display_name: '供貨商' },
{ key: 'modelName', display_name: '類別名稱' }
]
}
},
mounted() {
this.loadAll()
},
methods: {
// 導入
submitExcel() {
// 裝載成formdata數據
const formdata = new FormData()
formdata.append('file', this.files, this.files.name)
this.loading = true
this.doImport(formdata)
},
async doImport(data) {
try {
const res = await crudMaterial.importMaterial(data)
console.log('importMaterial', res)
this.loading = false
this.$message.success('導入成功')
} catch (err) {
console.log('importMaterial', err)
this.loading = false
this.$message.error('導入失敗')
}
},
// 導入功能
beforeUpload(file) {
this.files = file
console.log(this.files)
const extension = file.name.substring(file.name.lastIndexOf('.') + 1)
const isLt5M = file.size / 1024 / 1024 < 5
if (extension !== 'xlsx' && extension !== 'xls') {
this.$message({
message: '只能上傳Excel(即后綴是.xlsx或者.xls)的文件.',
type: 'warning'
})
return false
}
if (isLt5M) {
return true
}
this.$message({
message: '請不要上傳大於5MB的文件.',
type: 'warning'
})
return false
},
transExcel(results) {
const mapInfo = {
類別名稱: 'modelName',
物料編號: 'materialNum',
物料名稱: 'materialName',
物料型號: 'materialType',
單位: 'unit',
計價方式: 'pricingMode',
參考價格: 'referencePrice',
供貨商: 'supplier',
狀態: 'enabled',
品牌: 'brand'
}
return results.map((zhObj) => {
const enObj = {}
const zhKeys = Object.keys(zhObj)
zhKeys.forEach((zhKey) => {
const enKey = mapInfo[zhKey]
enObj[enKey] = zhObj[zhKey]
})
return enObj
})
},
handleSuccess({ header, results }) {
this.tableData = results
this.tableHeader = header
},
// 改變狀態
changeEnabled(data, val) {
this.$confirm(
'此操作將 "' +
this.dict.label.material_status[val] +
'" ' +
data.materialName +
'倉庫, 是否繼續?',
'提示',
{
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
}
)
.then(() => {
// eslint-disable-next-line no-undef
crudMaterial
.edit(data)
.then(() => {
// eslint-disable-next-line no-undef
this.crud.notify(
this.dict.label.material_status[val] + '成功',
'success'
)
})
.catch((err) => {
data.enabled = !data.enabled
console.log(err.data.message)
})
})
.catch(() => {
data.enabled = !data.enabled
})
},
loadAll() {
crudMaterial.getAllMaterial().then((res) => {
this.materials = res.content
})
},
downloadExceltoLocalFile() {
crudMaterial
.downloadExcel()
.then((res) => {
downloadFile(res, '物料模板', 'xlsx')
})
.catch((err) => {
this.$message.error = err.message
})
},
querySearchMaterials(queryString, cb) {
var materials = this.materials
var results = queryString
? materials.filter(this.createFilter(queryString))
: materials
cb(results)
},
// 查詢輸入字符是否存在
createFilter(queryString) {
return (form) => {
return (
form.materialName.toLowerCase().indexOf(queryString.toLowerCase()) !==
-1
)
}
},
// 鈎子:在獲取表格數據之前執行,false 則代表不獲取數據
[CRUD.HOOK.beforeRefresh]() {
return true
}
}
}
</script>
<style scoped></style>
PS:注意一個細節
該圖是后端controller層提供的接口,這邊需要額外注意一點。這邊的參數file
需要和前面formdata中的第一個參數對應。否則會出現400錯誤。
總結
這次的開發僅僅是在單個頁面上進行編寫,還沒有封裝成一個通用的組件,同時由於本人能力有限,可能會存在一些疏漏,出現BUG。還望大家能夠指出。
參考資料
vue-element-admin開源項目,文檔地址
el-admin開源項目,文檔地址
element-ui, 文檔地址
element-ui/vue-element-admin上傳excel等文件到服務器,文章地址