基于xlsx实现通用的前端导出方案

前言

在最近开发报表中碰到了需求需要前端导出,原因是表格中的表头大多数都是动态生成的,后端那边觉得前端做起来更简单,我想着没做过这块的也想学习下, 也没跟他杠啥。上gitHub找了下插件对比下发现前端实现起来也不复杂,性能其实也不差,就统一实现了9张报表的导出,里面内容涉及到:

  • 实现createDynamicColumnByDate通过用户选择的日期动态生成表格的tableHead
  • 根据tableHead表头的层级关系计算出excel的表头的行数动态组建excel的行数
  • 将过滤条件也单独生成一个excel表格
  • 实现通用formatJson来处理列表中特殊的行数据
  • 实现「通用」的汇总方法,表格和导出都能实用该函数来动态生成一行汇总

效果图💗

文章会用一个新建的项目模板实现这个功能方便大家查看源码,这里面稍微复杂一点的就是对数据结构的处理,文章中会将每个关键的方法以备注加描述的方式给出,文章末尾也会将单独实现的源码地址给到,如果觉得这篇文章对你有帮助的话欢迎大家点个赞👻

excel的数据结构

最后生成excel需要数据结构

组件化开发页面

通过配置快速生成页面

下面通过两个组件来快速生成这个页面👇

page

template

配置项

这里挑两个配置项讲下

其实除了这些简单的配置,还有很多高级的玩法,表格同样支持可编辑,包括不限于:

  • 配置Element-ui的表单项名称单元格生成表单组件(可编辑表格)
  • 也可以配置插槽名称或者插槽render方法来自定义单元格的内容
  • 通过配置rules对象校验表格
  • 配置字典项
  • 。。。

总得来说这个封装的还是很灵活的,本文就不过多讲解。感兴趣的朋友可以在文末找到文章项目的demo,本人所有积累封装的组件都放进去了,主要还是互相学习下组件封装的技巧性思维,再次觉得这篇文章对你有帮助的话欢迎大家点个赞,感谢👻

报表的实现

为了降低大家的上手成本, 文章的demo是用mock的接口实现的, 所以即使不传任何参数照样会给到列表数据, 后续的条件过滤可以前端实现「这里只是为了项目demo更易上手」

报表的数据结构

导出

流程图💗

过一下流程图,后面每个关键节点的实现方法都会详细解析给出代码

列表的实现

config.js

一般我都会在当前page文件夹中建个config.js将和业务毫不相关的配置项写在这个js文件里面,如果page中需要响应式的通过mixin混入进去,如果不需要响应式直接导出去就行

// 表头配置项,上面也都解析过
const initTableHead = [
  {
    label: '开始日期',
    prop: 'rentStartDate',
    query: {
      type: 'date',
      dateType: 'month',
      valueFormat: 'yyyy-MM',
      itemClass: 'filter-item-custom',
    },
    hidden: true
  },
  {
    label: '费项',
    prop: 'feeName',
    minWidth: '160',
    align: 'center',
    query: {
      type: 'select',
      data: [],
      key: 'feeId',
    },
  },
  ...
]

// 关键金额字段
const amountFields = [
  'receivableTaxIncluded',
  'receivableExcludingTax',
]

const config = {
  data() {
    return {
      tableHead: initTableHead,
      // 不需要响应式[但我page页面的computed中需要用]
      amountFields: Object.freeze(amountFields); 
    }
  }
}

export {
  config,
}
 

对于我们不需要添加响应式的数据可以通过:导入、Object.freeze冻结对象、在created中初始化对象。达到不添加getter和setter的目的,很好的避免了响应式的滥用

foramterList

computed中通过foramterList方法对列表数据做统一处理(统一保留两位小数,null值给0)

import {
  config as configMixin,
} from './config/indexConfig'

export default {
  name: 'A', // page(只要是page都会定义个name)
  mixins: [configMixin], // 将config对象的数据混入进来
  computed: {
    pageList({ list = [], amountFields }) {
      if (!list.length) {
        return []
      } else {
        return this.formaterList(list, amountFields)
      }
    }
  },
  methods: {
   formaterList(list, amountFields) {
     // list数据可以从->「报表的数据结构」看
      return list.map(row => {
        row.costTableEntrys.forEach(i => {
          amountFields.forEach(key => {
            // 后端返回的空值只有null(undefied是js中独有的空)
            i[key] = Number(i[key]).toFixed(2)
          })
        })
        return row
      })
    },
  }
}


 

上面formaterList方法并没有写任何的条件语句也能实现对每一行的costTableEntrys分录中金额字段做初始化处理,所以很多时候的条件判断可以通过「逻辑抽象」达到同样的目的

createDynamicHead

将静态表头和用户选择的时间范围(日期动态表头)组合tableHead

config.js

// 表头配置项,上面也都解析过
const initTableHead = [...]

// 关键金额字段
const amountFields = [...]

// 月份
const monthHash = [
  '01',
  '02',
  '03',
  '04',
  '05',
  '06',
  '07',
  '08',
  '09',
  '10',
  '11',
  '12',
]

const config = {
  data() {
    return {
    	...
    }
  }
}

export {
  config,
  monthHash
}
 

page

export default {
name: 'A', // page
... // 前面的写过的就不贴了
watch: {
// list一定是个Array,所以只要watch Array.length就行
// 并不需要去deep每项
'list.length': {
handler(len) {
if (!len) {
// 没有数据(还原表头)
this.tableHead = initTableHead
return
}
const {
rentStartDate, // 开始日期(2022-04)
rentEndDate // 结束日期(2021-04)
} = this.$refs.dynamicSearch.searchQuery
this.tableHead = this.createDynamicHead({
year: startDate.substr(0, 4), // 开始日期选择的年份
startDate: rentStartDate,
endDate: rentEndDate,
})
}
}
},
methods: {
// 根据查询的数据获取现有数据的日期
createDynamicHead({ year, startDate, endDate }) {
// 获得开始日期和结束相差月份(2022-04 - 2021-04) = 12
const diffMonthNum = differenceInMonths(startDate, endDate)
// 根据条件匹配出所选月份范围
const selectMonthScope = []
// 开始月份的索引(04 -> 3)
let startIndex = monthHash.indexOf(startDate.substr(-2))
let n = diffMonthNum + 1
while (n--) {
const m = monthHash[startIndex]
selectMonthScope.push({
label: `${year}-${m}`,
prop: `${year}-${m}`,
isDynamic: true, // 动态标识
})
// 跨年的情况
if (m === '12') {
year = Number(year) + 1
startIndex = 0
} else {
startIndex++
}
}
// 生成日期表头
return [...initTableHead, ...createDateColumnAction(selectMonthScope)]
}
}

differenceInMonths方法就是的计算相差的月份,过于简单这里就不解析了

config.js

// 根据monthToHash中所选月份动态生成动态列
const createDateColumn = (dynamicColumns) => {
return dynamicColumns.map(col => {
return Object.assign(col, {
minWidth: '160',
align: 'center',
children: [
{
label: '含税金额',
prop: `receivableTaxIncluded`,
minWidth: '160',
align: 'center',
// 「关键」-> 动态生成的列的children都叫这个key名
// 后续formater列值的时候通过parentKey确定children中的这两个字段是
// 属于那个日期下的数据
parentKey: col.prop, 
},
{
label: '不含税金额',
minWidth: '160',
prop: `receivableExcludingTax`,
align: 'center',
parentKey: col.prop, 
},
]
})
})
}
export {
config,
monthHash,
createDateColumn as createDateColumnAction,
}

现在的效果

现在动态添加列的两种金额类型数据都是在每行的costTableEntrys中, 所以需要用到表格的formater配置项

const createDateColumn = (dynamicColumns) => {
return dynamicColumns.map(col => {
return Object.assign(col, {
...
children: [
{
...
parentKey: col.prop, 
+	  formatter: formatterThousand,
},
..
]
})
})
}

formatterThousand

const formatterThousand = ({ row, column, col }) => {
// col -> 是tableHead中定义的列, [row,column]是el-table-column插槽抛出的数据
if (row && row.costTableEntrys && column && col) {
const curItem = row.costTableEntrys.find(i => {
return i[key] === col['parentKey'] // 找到当前列对应的日期
}) || {}
// 从分录的行中拿到金额字段
const realValue = Number(curItem[column.property] || 0).toFixed(2)
// 支持负数千分
const v = String(realValue).replace('-', '') // 去了负号的数
return v > 999 ? formatThouPercentile(realValue) : realValue
}
}

上面的这个方法可能比较难理解, 但其实就一个作用我要正确的渲染对应日期的数据, 就干了这么一件事

「效果」

getSummaries

汇总行又比较麻烦了, 还是因为动态列数据都是放在分录里面的, 所以我们需要用到tahleHead给动态列定义的formatter方法

config.js

// 不合计的字段
const noTotalFields = [
'billNumber',
'customerName',
'unitNumber',
'rentalArea',
'startDate',
'endDate',
'actualEndDate',
'feeName',
]
export {
config,
monthHash,
createDateColumn as createDateColumnAction,
noTotalFields
}
 // 汇总
getSummaries({ columns, data }) {
const sums = ['', '汇总']
if (!data.length || !columns.length) return sums
const {
tableHead
} = this
// 获取展示的所有列
const cols = getHeaderFields(tableHead)
// 需要合计的列
const totalCols = cols.filter(c => !noTotalFields.includes(c.prop))
// 将数每行格式化成想要的数据值
const formatData = formatJson(cols, data)
// 合计计算
const totalObj = calcArrayTotal(
formatData,
totalCols,
calc.Add
)
// 如果有[序号|多选框]直接补空
const diffCol = columns.length - cols.length
totalCols.forEach(c => {
if (Reflect.has(c, 'index')) {
sums[c.index + diffCol] = formatThouPercentile(
totalObj[`${c.parentKey}${c.prop}`].toFixed(2)
)
}
})
return sums
},

formatJson方法调用格式化后的数据

calcArrayTotal计算之后获取到所有列的汇总

验证

至此报表页面好了,之所以在讲报表之前要讲这些是因为报表导出的数据结构和列表是分不开的,本文不是为了探讨的如果去使用xslx这个插件去实现导出功能,更多是如果去封装通用的导出功能,可直接复用。

导出的实现

这里需要下载插件

npm i xlsx 
npm i xlsx-style // 因为我们需要去对excel进行美化,需要下载这个

引入插件会报错,有个地方需要改下

导入xlsx-style组件报错Can‘t resolve ‘./cptable‘ in ‘xxxx\nautical-front\node_modules_xlsx

很明显这里需要通过递归去计算表头的行数和最长的那行的列数作为基数去生成excel的表头,excel的单元格和表格没有任何区别

setHeaderInfo

 // 给表头加上对应的层级
const {
tableHead, // 组合的表头
cols, // 所有的列
maxLevel, // 最大的层级(当前表头有几行)
} = setHeaderInfo(静态表头+动态表头)
const setHeaderInfo = (tableHead, level = 0, cols = []) => {
for (let i = 0, len = tableHead.length; i < len; i++) {
const col = tableHead[i]
// [隐藏的列, 不存在prop的字段] 不展示
if (col.hidden || !col.prop) {
continue
}
// 设定层级
col.level = level
if (col.children?.length) {
setHeaderInfo(col.children, level + 1, cols)
} else {
// 父级不需要放在数据列中
cols.push(Object.assign(col, { property: col.prop }))
}
}
return {
tableHead,
cols,
maxLevel: Math.max(...cols.map(c => c.level))
}
}

上面这个递归写的还是很简单的, 其实就是一个条件分支:给对应层级的列加对应层级标识, 将所有列(除去父级列)汇总起来方便后面操作

getMultiHeader

/*************  组建excel表头 ***************/
// 根据表的层级结构计算出表头的行数
const {
multiHeaders,
} = getMultiHeader({
tableHead,
multiHeaders: Array.from(Array(maxLevel + 1)).map(() => [])
})

注意⚠:数组填充「Object」类型不能使用fill

let arrList = Array(3).fill([]) //   [Array(0), Array(0), Array(0)]
arrList[0][0] = 'beige'
arrList => // [Array(1), Array(1), Array(1)] 填充的都是同一个引用地址


const getMultiHeader = (params) => {
const {
tableHead = [],
multiHeaders = [], // 存储每一行的表头
} = params
for (let i = 0, len = tableHead.length; i < len; i++) {
const col = tableHead[i]
// [隐藏的列, 不存在prop的字段] 不展示
if (col.hidden || !col.prop) {
continue
}
// 递归处理子级
if (col.children?.length) {
/* 如果有子级,父级只有一个单元格有文字,其他用空串占位 */
// col.level -当前表头的层级
// 第一行
multiHeaders[col.level].push(col.label)
let j = 1
while (j < col.children.length) {
// 留点空格占excel的单元格位置
multiHeaders[col.level].push('    ')
j++
}
// 处理第二行
!multiHeaders[col.level + 1].length
? multiHeaders[col.level + 1].push(...Array(
multiHeaders[col.level].length - col.children.length
).fill(''))
: null
getMultiHeader({
tableHead: col.children,
multiHeaders
})
continue
} else {
multiHeaders[col.level].push(col.label)
}
}
return { tableHead, multiHeaders }
}

至此所有前置工作完成,接下来就是引入写好的export2excel.js来实现导出

async exportHandle() {
// 校验参数...
// 拉取数据
import('@/utils/EXPORT2EXCEL').then((excel) => {
// 给表头加上对应的层级
const {
tableHead,
cols, // 所有的列
maxLevel, // 最大的层级,当前表头有几行
} = setHeaderInfo(
// 动态获取表头
this.createDynamicHead({
list,
year: beginDate.substr(0, 4),
beginDate,
finishDate,
})
)
/** *****************  组建excel表头 *******************/
// 根据表的层级结构计算出表头的行数
const {
multiHeaders,
} = getMultiHeader({
tableHead,
// fill用的都是同一个引用地址
multiHeaders: Array.from(Array(maxLevel + 1)).map(() => [])
})
// 将表头和字段对应上
const data = formatJson(cols, list)
/** *****************  动态生成一行汇总 *******************/
// 将汇总添加最后一行
data.push(sums)
excel.export_json_to_excel({
multiHeaders,
dynamicFilterHeads,
data,
filename: `${proName}-项目合同费用表(应收)`,
merges, // 合并表头
customRowStyleCallBack, // 自定义行的样式
})
.then(res => {
this.$notify({
title: res.message,
type: 'success',
duration: 2500
})
})
.catch((err) => {
this.$message.error('导出失败!')
console.error(err)
})
.finally(() => {
this.$store.state.isShowLoading = false
})
})
}

并不是完整的代码,就是一个示例,从上面方法中可以get个技巧就是在方法中可以通过import来异步加载文件,这种方式在某些情况比request.context()更好用。

前端

  • 源码地址🌟:github.com/it-beige/bo…
  • 历史版本⭐:d70c055

后端

  • 源码地址🌟:github.com/it-beige/bo…
  • 历史版本⭐:3b049c5

写在最后

如果文章中有那块写的不太好或有问题欢迎大家指出,我也会在后面的文章不停修改。也希望自己进步的同时能跟你们一起成长。喜欢我文章的朋友们也可以关注一下

我会很感激第一批关注我的人。此时,年轻的我和你,轻装上阵;而后,富裕的你和我,满载而归。

往期文章

【建议追更】以模块化的思想来搭建中后台项目

【前端体系】从一道面试题谈谈对EventLoop的理解 (更新了四道进阶题的解析)

【前端体系】从地基开始打造一座万丈高楼

【前端体系】正则在开发中的应用场景可不只是规则校验

「函数式编程的实用场景 | 掘金技术征文-双节特别篇」

【建议收藏】css晦涩难懂的点都在这啦

原创文章,作者:我心飞翔,如若转载,请注明出处:https://www.pipipi.net/14687.html

发表评论

登录后才能评论