1 關鍵技術與難點
本文記錄一次在前臺利用sheetjs
,將js數據轉化爲excel表格的實踐經歷.
其中涉及到的一些難點包含:
- 多行合併列的處理;(均爲表頭,不含表體)
- 表格數據的設計與處理;
2 前置說明
注:如果不是自定義表格設計,而是固定列表格設計,則可忽略2.1的數據庫設計,直接從2.2的實例數據參考即可.
2.1 數據庫設計
此處主要介紹表格數據的column
(列)和data
(表體數據)的設計,
僅介紹與表格設計相關的字段,忽略項目邏輯相關的字段.實際開發字段需要酌情增加.
column
數據庫表設計
字段名 | 說明 |
---|---|
code | 列編碼 |
name | 列名稱/title |
parent_code | 父列編碼,用以記錄列的父子間關係 |
data_type | 數據類型,如string ,integer ,decimal ,none (僅用於無數據的父列),date ,select (選擇),boolean (布爾),calc (公式),也可根據自身需求增加 |
select_source | 選擇來源,僅當數據類型爲select 或calc 時有用 |
sort | 排序 |
以上介紹的,是最基礎的一些字段,實際還可以考慮增加如min_width
(最小寬度),bold_font
(是否粗體)這樣的字段.
對於data_type
字段,還有一些附加的設計內容,本文涉及不多,以後如果有需求,也許會另開一篇博客介紹.
實例數據(json格式):
[
{
code:'xiangmu',
name:'項目',
parentCode:'',
dataType:'none',
selectSource:''null'',
sort:1
},
{
code:'xiangmumingcheng',
name:'項目名稱',
parentCode:'xiangmu',
dataType:'string',
selectSource:'',
sort:2
},
{
code:'caigoupinmu',
name:'採購品目',
parentCode:'xiangmu',
dataType:'select',
selectSource:'CGPM',
sort:3
},
{
code:'danjia',
name:'單價',
parentCode:'',
dataType:'decimal',
selectSource:'',
sort:4
},
...
]
data
數據庫表設計
字段名 | 說明 |
---|---|
column_code | 列編碼 |
row_code | 行編碼,我將之設計爲uuid格式,這樣前臺可直接生成. |
value | 值,以字符串方式保存所有值 |
show_content | 展示內容,適用於像select 或boolean 這樣的列 |
同樣爲最基礎字段.有些可以擴展.
實例數據(json格式):
[
{
columnCode:'xiangmumingcheng'
rowCode:'9cd97861-ebab-4002-8cc3-c12ceaa92cab',
value:'**業務經費',
showContent:''
},
{
columnCode:'caigoupinmu',
rowCode:'9cd97861-ebab-4002-8cc3-c12ceaa92cab',
value:'BGSB',
showContent:'辦公設備'
},
{
columnCode:'danjia',
rowCode:'9cd97861-ebab-4002-8cc3-c12ceaa92cab',
value:'13.5',
showContent:''
},
]
2.2 前臺數據體現
簡單來說,就是將column
和data
轉化爲對象集合的格式.
對於data
中的showContent
,採用前綴_show_
加編碼作爲key.
轉換方法本身比較簡單,且非本文重點,就不再贅述了.
實例數據(json格式):
[
{
xiangmumingcheng:'**業務經費',
caigoupinmu:'BGSB',
_show_caigoupinmu:'辦公設備',
danjia:'13.5'
}
]
2.3 excel導出的標準方法
根據中文api,sheetjs
將js數據轉換爲表格的方式,有:
aoa_to_sheet
把轉換JS數據數組的數組爲工作表。json_to_sheet
把JS對象數組轉換爲工作表。table_to_sheet
把DOM TABLE元素轉換爲工作表。
本文采用aoa_to_sheet
作爲核心方法.
項目內已經寫好了直調方法:
/* 需要npm中導入sheetjs */
import XLSX from 'xlsx';
/**
* 將數組轉換爲excel
* @param key 列key數組,按照index確認
* @param data 數據數組
* @param title 表頭數組,如果爲合併表頭模式(merge不爲空)時,title爲二維數組
* @param filename 文件名&sheet名
* @param autoWidth 自動寬度
* @param merge 合併表頭數據(實際上excel內的所有單元格都可合併)
*/
export const export_array_to_excel = ({key, data, title, filename, autoWidth,merge}) => {
const wb = XLSX.utils.book_new();
const arr = json_to_array(key, data);
if(merge){
arr.unshift(...title)
}else{
arr.unshift(title);
}
const ws = XLSX.utils.aoa_to_sheet(arr);
if(autoWidth){
auto_width(ws, arr);
}
//合併單元格,適用於有父行的內容
//格式可參考https://zhuanlan.zhihu.com/p/141328581
if(merge){
ws['!merges']=merge;
}
XLSX.utils.book_append_sheet(wb, ws, filename);
XLSX.writeFile(wb, filename + '.xlsx');
}
//輔助:自動寬度
function auto_width(ws, data){
/*set worksheet max width per col*/
const colWidth = data.map(row => row.map(val => {
/*if null/undefined*/
if (val == null) {
return {'wch': 10};
}
/*if chinese*/
else if (val.toString().charCodeAt(0) > 255) {
return {'wch': val.toString().length * 2};
} else {
return {'wch': val.toString().length};
}
}))
/*start in the first row*/
let result = colWidth[0];
for (let i = 1; i < colWidth.length; i++) {
for (let j = 0; j < colWidth[i].length; j++) {
if (result[j]['wch'] < colWidth[i][j]['wch']) {
result[j]['wch'] = colWidth[i][j]['wch'];
}
}
}
ws['!cols'] = result;
}
3 核心代碼
參數方面,arrTableData
參考自2.2的實例數據.arrColumn
則參考2.1的列的實例數據.
如果不是自定義列,則arrColumn
就需要手動寫死了.
import { isEmpty } from '@/common/about-string'
import { getShowContentKey } from '@/common/about-table'
/**
* 導出默認表格
* @param arrTableData
* @param arrColumn
* @param filename
*/
export const exportDefaultTable = (arrTableData, arrColumn, { filename = '' } = { filename: '' }) => {
// 1.數據預處理
let mapColumn = new Map()
// console.log('1');
// 默認父類均在子列前
let arrAncestorCode = []
let intRowIndex = 0
let intColIndex = -1
let intMaxRowIndex = 0
let intMaxColIndex = 0
let mapColumnIndex = new Map()
for (let c of arrColumn) {
mapColumn.set(c.code, c)
if (isEmpty(c.parentCode)) {
intRowIndex = 0
intColIndex += 1
arrAncestorCode = [c.code]
} else {
let intParentCodeIndex = arrAncestorCode.findIndex(s => s === c.parentCode)
if (intParentCodeIndex < arrAncestorCode.length - 1) {
// 左移
intRowIndex = intParentCodeIndex + 1
intColIndex += 1
arrAncestorCode.splice(intRowIndex, arrAncestorCode.length - intParentCodeIndex, c.code)
} else {
// 下移
intRowIndex += 1
arrAncestorCode.push(c.code)
}
}
if (intMaxRowIndex < intRowIndex) {
intMaxRowIndex = intRowIndex
}
mapColumnIndex.set(c.code, { sr: intRowIndex, sc: intColIndex })
}
intMaxRowIndex += 1
intMaxColIndex = intColIndex + 1
// console.log('2',mapColumnIndex);
// 反填,根據行和列記錄columnCode
let mapStartIndex = new Map()
for (let s of mapColumnIndex.keys()) {
let { sr, sc } = mapColumnIndex.get(s)
if (typeof mapStartIndex.get(sc) === 'undefined') {
mapStartIndex.set(sc, new Map())
}
mapStartIndex.get(sc).set(sr, s)
}
// 遍歷列和行,父子表頭站位
for (let ic = 0; ic < intMaxColIndex; ic++) {
for (let ir = 0; ir < intMaxRowIndex; ir++) {
if (typeof mapStartIndex.get(ic) !== 'undefined') {
if (typeof mapStartIndex.get(ic).get(ir) !== 'undefined') {
// do nothing
} else {
let strLastMark = mapStartIndex.get(ic).get(ir - 1)
if (typeof strLastMark !== 'undefined') {
mapStartIndex.get(ic).set(ir, strLastMark)
} else {
strLastMark = mapStartIndex.get(ic - 1).get(ir)
mapStartIndex.get(ic).set(ir, strLastMark)
}
}
}
}
}
// console.log('3',mapStartIndex);
// 再反填,根據名稱推測站位父子(不一定是按照標準順序的)
let mapIndexArr = new Map()
for (let [ic, mapIndexEach] of mapStartIndex.entries()) {
for (let [ir, s] of mapIndexEach.entries()) {
if (typeof mapIndexArr.get(s) === 'undefined') {
mapIndexArr.set(s, [])
}
mapIndexArr.get(s).push({ c: ic, r: ir })
}
}
// console.log('4',mapIndexArr);
// 列出需要合併的列
let arrColumnMerge = []
for (let [s, arrPosition] of mapIndexArr.entries()) {
if (arrPosition.length > 1) {
let ms = arrPosition[0]
let me = arrPosition[arrPosition.length - 1]
arrColumnMerge.push({
s: ms,
e: me
})
}
}
// console.log('5',arrColumnMerge,intMaxRowIndex,intMaxColIndex);
// 構建key和title
let arrKey = []
let arrTitle = []
for (let ir = 0; ir < intMaxRowIndex; ir++) {
arrTitle.push([])
for (let ic = 0; ic < intMaxColIndex; ic++) {
let strMark = mapStartIndex.get(ic).get(ir)
arrTitle[ir].push(mapColumn.get(strMark).name)
if (ir === intMaxRowIndex - 1) {
arrKey.push(strMark)
}
}
}
// console.log('6',arrTitle);
// 2.表格數據整理導出版
let arrTableDataExport = []
for (let [i, mRow] of arrTableData.entries()) {
let mNew = { ...mRow }
for (let k of arrKey) {
let mColumn = mapColumn.get(k)
if (typeof mColumn !== 'undefined') {
switch (mColumn.dataType) {
case 'integer':
case 'decimal':
case 'calc':
if (isEmpty(mNew[k])) {
mNew[k] = 0
} else {
mNew[k] = Number(mNew[k])
}
break
case 'select':
case 'boolean':
mNew[k] = mNew[getShowContentKey(k)]
break
default:
break
}
}
}
arrTableDataExport.push(mNew)
}
// console.log('7',arrTableData,arrTableDataExport);
// 2.excel構建
const mTable = {
title: arrTitle,
key: arrKey,
data: arrTableDataExport,
autoWidth: true,
merge: arrColumnMerge,
filename
}
//此處即是調用最核心的方法
export_array_to_excel(mTable)
}
經過測試,該方法是生效的.
儘管在合併表頭方面,代碼有些過長.以後有時間的話,我再考慮如何精簡吧.
以下附加兩個輔助方法:
/**
* 是否爲空(主要適用於字符串)
* @param str
* @returns {boolean}
*/
export const isEmpty = str => {
return typeof str === 'undefined' || str === null || str === '' || str === '--'
}
const PREFIX_SHOW = '_show_'
/**
* 獲取展示內容key
* @param key
* @returns {string}
*/
export const getShowContentKey = key => {
return PREFIX_SHOW + key
}
4 參考說明
5 其他
本文中的表格的表頭列,本質上還是屬於'樹狀數據'.
按照以往的經驗,樹數據的設計加上level
,既可避免出現互爲父子的矛盾,也可使一些計算環節更加簡單.
以後若是有時間,可以考慮加上這個字段,甚至直接增加一個祖先後代關聯表,查看是否可以減少一些計算量.
end