基于Exceljs和Vue的表格导出

150 阅读5分钟

一、组件功能描述:

封装了Exceljs的表格导出功能,可以通过传入表格数据、表格列进行数据导出

image.png

二、组件参数说明:

  1. props(属性)
参数名参数类型必填默认值说明
modelValueBooleanv-model的绑定值
fileNameString数据表导出文件名称
dimensionArray[]导出表格的可选维度
tableRefObjectnull表格ref,用于获取列名
operationColumnNameStringoperation表格列中操作列prop,用于排除列,
tableDataArray[]表格数据
getTableDataFuncFunctionnull获取表格数据的方法
dataTransFuncFunctionnull数据转换方法
customBooleanfalse使用自定义表格维度数据
  1. Emits(抛出事件)
事件名事件参数说明
modelInputvalue通知父组件更新modelValue
  1. Expose(对外暴露事件) 外部组件需要通过 $refs 来进行调用的方法
事件名事件参数说明
handleInitColumnsRef 表格的ref手动使用ref 初始化可选维度
handleInitColumnsByArrayarray 表格的columns手动使用数组 初始化可选维度
setColumnsSelectedarray 手动设置columns手动设置可选列

三、组件代码:


<template>
  <div>
    <!--  外部使用建议配合v-if进行销毁  -->
    <el-dialog
      v-if="dialogShow"
      title="导出"
      :visible.sync="visible"
      :close-on-click-modal="false"
      width="40%"
      append-to-body
    >
      <el-form :model="exportForm" label-position="right" label-width="80px">
        <el-form-item label="文件名">
          <el-input v-model="exportForm.fileName" placeholder="请输入文件名" clearable style="width: 200px;" />
        </el-form-item>
        <el-form-item label="报表维度">
          <el-select v-model="exportForm.columns" multiple placeholder="请选择导出维度" style="width: 100%;">
            <el-option
              v-for="(item,index) in canSelectDimension"
              :key="index + item.prop"
              :label="item.label"
              :value="item.prop"
            />
          </el-select>
        </el-form-item>
      </el-form>
      <span slot="footer">
        <el-button @click="handleClose">关 闭</el-button>
        <el-button type="primary" :loading="loading" @click="handleConfirm">确 认</el-button>
      </span>
    </el-dialog>
    <!-- 导出动画,此处可以自己改成el的notify组件 -->
    <ExportloadPopup :show="showNotify" title="数据导出" @close="showNotify = false" />
  </div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { flattenDeep } from 'lodash'
import ExportloadPopup from '@/components/ExportloadPopup/index.vue'
import { Message } from 'element-ui'
//  定义props和emit
const props = defineProps({
  modelValue: {
    type: Boolean
  },
  fileName: { // 文件名
    type: String,
    default: '数据表'
  },
  dimension: { // 可选维度
    type: Array,
    default: () => []
  },
  tableRef: { // 表格ref,用于获取列名
    type: Object,
    default: null
  },
  operationColumnName: { // 操作列prop,用于排除列,
    type: String,
    default: 'operation'
  },
  tableData: { // 表格数据
    type: Array,
    default: () => []
  },
  getTableDataFunc: { // 获取表格数据的方法
    type: Function,
    default: null
  },
  dataTransFunc: { //  数据转换方法
    type: Function,
    default: null
  },
  custom: { // 使用自定义表格维度数据
    type: Boolean,
    default: false
  }
})
const emits = defineEmits('modelInput')
//  visible使用computed实现双向绑定
const visible = computed({
  get() {
    return props.modelValue
  },
  set(val) {
    // 通知父组件更新modelValue
    emits('modelInput', val)
  }
})
// 表单定义
const exportForm = reactive({
  fileName: props.fileName,
  columns: []
})
const canSelectDimension = ref([]) // 最后可以选中的维度

/**
* --------------------------- 列处理工具方法 ------------------------------------
* */
// 从ref 获取表格的列 自动切分 | 自动过滤操作列和序号列
const getTableColumnsFromRef = ref => {
  let arr = []
  if (ref.$children && ref.$children.length > 0) {
    ref.$children.forEach(item => {
      if (item.label !== undefined && item.prop !== undefined) {
        arr.push({
          label: item.label,
          prop: item.prop
        })
      }
    })
  }
  arr = splitColumns(arr)
  arr = filterColumns(arr)
  console.log(arr, '通过ref处理完成')
  return arr

}
// 列过滤 去除操作列,#号列
const  filterColumns = arr => {
  return arr.filter(item => item.prop.indexOf('#') === -1 && item.prop.indexOf(props.operationColumnName) === -1)
}
// 拆分带有 | 的联合列
const  splitColumns = arr => {
  const temp = arr.map(item => {
    // prop内存在 |
    if (item.prop.indexOf('|') !== -1) {
      const propList = item.prop.split('|')
      const labelList = item.label.split('|')
      return [
        {
          label: labelList[0],
          prop: propList[0]
        },
        {
          label: labelList[1],
          prop: propList[1]
        }
      ]
    } else {
      return item
    }
  })
  // 平整数组的项,让所有项都不包含数组
  return flattenDeep(temp)
}

// 初始化可选维度
const initCanSelectColumns = () => {
  if (props.custom) {
    return
  }
  if (props.tableRef !== null) {
    // 传入ref 的方式
    try {
      canSelectDimension.value = getTableColumnsFromRef(props.tableRef)
    } catch (e) {
      emitError('logic', e)
    }
  } else if (props.dimension.length > 0) {
    // 手动传入维度
    canSelectDimension.value = props.dimension
  } else {
    // 没有任何参数的时候
    canSelectDimension.value = []
    emitError('prop', 'props.dimension或props.tableRef不能同时为空')
  }
  // 默认全选
  allColumnsSelected()
}
// 设置全部列选中
const allColumnsSelected = () => {
  exportForm.columns = canSelectDimension.value.map(item => {
    return item.prop
  })
}
// 手动设置可选列
const setColumnsSelected = array => {
  exportForm.columns = array
}
// 获取选中的维度
const getSelectDimensionList = () => {
  // 使用 filter 方法筛选符合条件的项目
  return  canSelectDimension.value.filter(item => exportForm.columns.includes(item.prop))
}

// 手动使用ref 初始化可选维度
const handleInitColumns = ref => {
  // 从参数的ref 获取列配置
  canSelectDimension.value = getTableColumnsFromRef(ref)
  //  默认全选
  allColumnsSelected()
}
// 手动使用数组 初始化可选维度
const handleInitColumnsByArray = array => {
  // 从参数的ref 获取列配置
  canSelectDimension.value = array
  //  默认全选
  allColumnsSelected()
}

/**
* --------------------- 数据处理相关 -----------------------
* */
// 提交按钮加载态
const loading = ref(false)

/**
* --------------------  弹窗操作相关 -----------------------
* **/
const showNotify = ref(false)
const dialogShow = ref(true) // 弹窗隔离 v-if 用于单独控制内部弹窗的渲染 如果不隔离 则内部弹窗的渲染会受外部弹窗的影响 小提示框无法正常展示
// 点击关闭
const handleClose = () => {
  // 恢复隔离变量
  dialogShow.value = true
  // 关闭弹窗
  visible.value = false
  showNotify.value = false
  // 这里重置列的原因是 如果组件没有使用V-if 进行销毁的话,下一次点击则不是默认态,所以需要恢复默认状态
  setTimeout(() => {
    // 重置选中列
    allColumnsSelected()
  }, 200)
}
// 点击确认
const handleConfirm = async() => {
  try {
    let tableData = []
    showNotify.value = true
    dialogShow.value = false
    // 1、 数据获取
    // 如果传入的是方法 则调用方法获取数据
    if (props.getTableDataFunc && typeof props.getTableDataFunc === 'function') {
      const res = await props.getTableDataFunc()
      // console.log(res, '获取的数据')
      //  如果有数据处理方法则执行判断返回的数据是否为数组
      tableData = props.dataTransFunc && typeof props.dataTransFunc === 'function' ? props.dataTransFunc(res) : res
    } else {
      // 如果传入的是数据,则直接替换
      tableData = Array.isArray(props.tableData) && props.tableData.length > 0 ? props.tableData : []
    }
    // 2、准备excel导出
    import('@/vendor/Export2Excel').then(excel => {
      const header = []
      const filterVal = []
      const dimension = getSelectDimensionList()
      dimension.forEach(element => {
        header.push(element.label)
        filterVal.push(element.prop)
      })
      const data = formatJson(filterVal, tableData)
      excel.export_json_to_excel({
        header: header, // 标题
        data, // 文件数据
        filename: props.fileName // 文件名称
      })
      setTimeout(() => {
        visible.value = false
        showNotify.value = false
        dialogShow.value = true
        // 重置选中列
        allColumnsSelected()
      }, 300)
    })
  } catch (e) {
    emitError('logic', e)
    Message.error('导出失败,' + e)
    showNotify.value = false
    dialogShow.value = true
    // visible.value = false
  }
}
// 导出过滤
const formatJson = (filterVal, list) => {
  try {
    return list.map(v => filterVal.map(j => {
      return v[j]
    }))
  } catch (e) {
    emitError('logic', e)
  }
}
/**
* ----------------------  组件相关  -------------------------
* */
// 导出方法
defineExpose({handleInitColumns, handleInitColumnsByArray, setColumnsSelected})
// 初始化可选列
onMounted(() => {
  initCanSelectColumns()
})

// 错误抛出
const errorMapping = {
  'prop': 'prop属性错误',
  'func': '方法调用错误',
  'logic': '组件内部逻辑错误',
  'other': '其他错误'
}
const emitError = (errType = 'other', errorText) => {
  Promise.reject(`组件:exportExcel; 错误类型:${errorMapping[errType]}; 错误提示:${errorText}`)
}
</script>
<script>
export default {
  model: {
    prop: 'modelValue',
    event: 'modelInput'
  }
}
</script>

<style scoped lang="scss">

</style>

四、组件使用例子:

<export-excel
  v-if="exportExcelVisible"
  v-model="exportExcelVisible"
  :dimension="exportExcelColumns"
  file-name="日志追踪数据"
  :get-table-data-func="getExportTableList"
/>

// 导出
const exportExcelVisible = ref(false)
const exportExcelColumns = tableColumns
// 获取导出的数据
const getExportTableList = async() => {
  const data = {
    ...queryParams.value,
    account_date_range: dateRangeObject(queryParams.value.account_date_range),
    event_date_range: dateRangeObject(queryParams.value.event_date_range),
    install_date_range: dateRangeObject(queryParams.value.install_date_range),
    page: 1,
    page_size: 99999 // 默认上限 99999  10w数量
  }
  try {
    const res = await service.post('/api/operation/data/link/list', data)
    return transTableData(res.data.list)
  } catch (e) {
    return []
  }
}



// tableColumns -》
export const tableColumns = [
  {prop: 'action_type', label: '媒体'},
  {prop: 'event_time', label: '时间'},
  {prop: 'device_type', label: '系统'},
  {prop: 'device_type_ip', label: 'IP'}
]

// transTableData -》
// 转化导出的数据
const transTableData = dataList => {
  return dataList.map(item => {
    return  {
      ...item
    }
  })
}

五、其他注意事项:

  1. 组件使用v-model 传入Boolean 值进行状态控制,当值为 true的时候 会展示导出弹窗,文件名 输入框 可以更改导出文件名, 报表维度 选择器 会根据传入的表格列信息 将列进行默认全选也可手动更改需要导出的列;

  2. 使用的时候可以选择两种方式处理表格列信息:

    1. 使用表格ref,如果使用表格ref 则需要传入tableRef props,组件会自动根据传入的tableRef 数据遍历所有的列数据
    2. 使用自定义列数据,可以通过传入列数据到 dimension props 控制可选列信息,但是需要传入的数据中包含 label 和 prop 两个键
    3. 两种方式选择一种即可,如果同时存在则优先使用Ref
  3. 组件初始化流程:

    1. 组件在mounted 的时候 会执行 initCanSelectColumns 方法 通过ref 或者 传入的列数据 手动初始化可选维度的选择项,如果希望手动传入可选维度并初始化,则可以将 custom props 传入 false,并且通过 暴露的三个方法进行手动处理 handleInitColumns, handleInitColumnsByArray, setColumnsSelected

      1. handleInitColumns // 手动使用ref 初始化可选维度
      2. handleInitColumnsByArray // 手动使用数组 初始化可选维度
      3. setColumnsSelected // 手动设置可选列
    2. 在 initCanSelectColumns 方法中,

      1. 优先判断 table Ref 进行处理 调用 getTableColumnsFromRef 方法进行列的细致处理,这一步会取出所有的子列,组合为新数组,同时自动切分列名props中带有 | (由于部分列中是组合列数据,所以可以通过 | 进行分割),且自动过滤操作列和序号列
      2. // 从ref 获取表格的列 自动切分 | 自动过滤操作列和序号列
        const getTableColumnsFromRef = ref => {
          let arr = []
          if (ref.$children && ref.$children.length > 0) {
            ref.$children.forEach(item => {
              if (item.label !== undefined && item.prop !== undefined) {
                arr.push({
                  label: item.label,
                  prop: item.prop
                })
              }
            })
          }
          arr = splitColumns(arr)
          arr = filterColumns(arr)
          console.log(arr, '通过ref处理完成')
          return arr
        }
        
        // 列过滤 去除操作列,#号列
        const  filterColumns = arr => {
          return arr.filter(item => item.prop.indexOf('#') === -1 && item.prop.indexOf(props.operationColumnName) === -1)
        }
        
        // 拆分带有 | 的联合列
        const  splitColumns = arr => {
          const temp = arr.map(item => {
            // prop内存在 |
            if (item.prop.indexOf('|') !== -1) {
              const propList = item.prop.split('|')
              const labelList = item.label.split('|')
              return [
                {
                  label: labelList[0],
                  prop: propList[0]
                },
                {
                  label: labelList[1],
                  prop: propList[1]
                }
              ]
            } else {
              return item
            }
          })
          // 平整数组的项,让所有项都不包含数组
          return flattenDeep(temp)
        }
        
      3. 如果table Ref 为空则处理 dimension props 即传入的colums数组,此时会直接使用手动传入的数组,不做处理,所以外部组件传入的时候务必确认数组是正常格式的Columns 至少包含label 和 prop 项
      4. 如果table Ref 或 dimension 数据 都为空,控制台 会抛出 props.dimension或props.tableRef不能同时为空 的 prop错误
      5. 如果正常处理完参数,则会调用 allColumnsSelected 方法 将所有的维度默认全选
  4. 关于组件模板结构的说明

    1. 组件模板结构如下 Div > ( el-dialog + ExportloadPopup )
    2. 外部组件v-model 同步到visible 变量 且通过v-if 联合控制,dialogShow 变量控制弹窗是展示,showNotify 控制 ExportloadPopup 组件的展示
    3. 由于在点击导出确认按钮后,需要展示 ExportloadPopup 小弹窗,所以不能将小弹窗 和 dialog做父子关系,又由于需要ExportloadPopup 组件,dialog父组件无法销毁 否则会导致小弹出也一起销毁,所以外层使用div包裹,在完成下载事件后 才通过将 visible 改为false 将整个导出父组件进行销毁。
  5. 导出函数说明

    1. 源代码
       // 点击确认
       const handleConfirm = async() => {
         try {
           let tableData = []
           showNotify.value = true
           dialogShow.value = false
           // 1、 数据获取
           // 如果传入的是方法 则调用方法获取数据
           if (props.getTableDataFunc && typeof props.getTableDataFunc === 'function') {
             const res = await props.getTableDataFunc()
             // console.log(res, '获取的数据')
             //  如果有数据处理方法则执行判断返回的数据是否为数组
             tableData = props.dataTransFunc && typeof props.dataTransFunc === 'function' ? props.dataTransFunc(res) : res
           } else {
             // 如果传入的是数据,则直接替换
             tableData = Array.isArray(props.tableData) && props.tableData.length > 0 ? props.tableData : []
           }
           // 2、准备excel导出
           import('@/vendor/Export2Excel').then(excel => {
             const header = []
             const filterVal = []
             const dimension = getSelectDimensionList()
             dimension.forEach(element => {
               header.push(element.label)
               filterVal.push(element.prop)
             })
             const data = formatJson(filterVal, tableData)
             excel.export_json_to_excel({
               header: header, // 标题
               data, // 文件数据
               filename: props.fileName // 文件名称
             })
             setTimeout(() => {
               visible.value = false
               showNotify.value = false
               dialogShow.value = true
               // 重置选中列
               allColumnsSelected()
             }, 300)
           })
         } catch (e) {
           emitError('logic', e)
           Message.error('导出失败,' + e)
           showNotify.value = false
           dialogShow.value = true
           // visible.value = false
         }
       }
       ```
    
    1.  在数据获取中 有两种方式:
    
       1.  优先使用外部组件传入的方法获取数据,且如果存在处理方法 会将返回的数据 进一步调用处理方法处理
       1.  如果传入的是数据,则直接替换
    
    1.  使用Export2Excel 库导出文件
    
    1.  在导出成功后 将可选列重置选中,弹窗、小提示、父组件延迟300ms后完全销毁(避免由于导出过快 组件立刻销毁出现闪烁);如果存在异常则直接捕获抛出
    
    
  6. 错误说明,错误类型映射如下,通过 emitError 方法统一调用抛出

 // 错误抛出
const errorMapping = {
  'prop': 'prop属性错误',
  'func': '方法调用错误',
  'logic': '组件内部逻辑错误',
  'other': '其他错误'
}

const emitError = (errType = 'other', errorText) => {
  Promise.reject(`组件:exportExcel; 错误类型:${errorMapping[errType]}; 错误提示:${errorText}`)
}