likes
comments
collection
share

掌控大数据:Vue.js中的数据导出艺术组件功能介绍 1.动态任务队列管理:可以动态添加任务到队列中,并按顺序处理每个任

作者站长头像
站长
· 阅读数 22

组件功能介绍

这个 Vue 组件旨在简化和优化数据导出到 Excel 文件的过程。它特别适用于需要处理大量数据并将其导出为 Excel 文件的场景。以下是该组件的主要功能:

  • 1.动态任务队列管理:可以动态添加任务到队列中,并按顺序处理每个任务。无论是单个任务还是多个任务,都能轻松管理。

  • 2.进度监控:每个任务的进度可以实时监控,并通过进度条显示。用户可以直观地看到每个任务的进度。

  • 3.任务取消功能:在任务执行过程中,可以随时取消正在进行的任务。用户不必担心误操作,可以随时中止任务。

  • 4.自定义工作表配置:支持通过回调函数自定义每个工作表的配置,如表头、列宽等。灵活的配置让导出的 Excel 文件更符合需求。

  • 5.多工作表导出:支持将数据导出到多个工作表中,每个工作表可以有不同的配置。适用于需要将数据分组导出的场景。

  • 6.错误处理和重试机制:支持请求重试和错误处理,确保数据的完整性和准确性。即使在网络不稳定的情况下,也能保证数据导出成功。

组件实现初衷

在日常开发中,我们经常需要将大量数据导出为 Excel 文件。然而,处理大数据量的导出任务往往会遇到性能瓶颈和用户体验问题。这个组件的设计初衷是为了解决以下问题:

  • 1.接口超时问题:当数据量较大时,导出任务可能会因为接口超时而失败。

  • 2.进度监控问题:在导出过程中,需要实时显示任务的进度,而普通的http接口是无法满足的。

  • 3.任务取消问题:在导出过程中,如果用户需要中止任务,就需要手动处理。

  • 4.用户等待体验:在导出过程中,大量数据的导出可能会花费很长时间,用户需要等待,而没有交互效果。

针对以上问题,这个组件提供了解决方案:

  • 1.提升用户体验:通过进度条和通知弹窗,用户可以实时了解导出任务的进度,避免长时间等待的不确定性。

  • 2.简化开发流程:提供一个开箱即用的解决方案,开发者只需简单配置即可实现复杂的数据导出功能。

  • 3.提高性能:通过批量请求和并发控制,优化数据导出的性能,确保在处理大数据量时依然流畅。

  • 4.增强灵活性:支持自定义工作表配置和多工作表导出,满足不同场景下的数据导出需求。

  • 5.提供错误处理和重试机制:在请求失败时,可以自动进行重试,确保数据导出成功。

  • 6.任务取消功能:在任务执行过程中,可以随时取消正在进行的任务。用户不必担心误操作,可以随时中止任务。

组件的核心结构

组件主要由批量请求函数、通用数据导出excel并下载函数和实现下载队列管理组件构成:

  • 1.batchRequest:方法用于批量请求数据,并支持并发控制、请求重试、进度更新等功能。该方法特别适用于需要从服务器批量获取大量数据的场景。

    • batchRequest 方法接受三个参数:

      • requestFn:用于批量请求每次触发时请求的函数,(params: { page: number; pageSize: number; [key: string]: any }) => Promise<any[]>

      • totalDataCount:需要导出的总数据量,用于计算批次,通常数据总量是需要调用后台接口获取,number

      • options:批量请求的置对象,包含并发控制、请求重试等参数(非必填)。

        • pageSize(可选):每次查询的数据条数,默认:100,number
        • maxConcurrentRequests(可选):并发请求数,默认:5,number
        • extraParams(可选):接口请求除了页码之外的请求参数,{ [key: string]: any }
        • retries(可选):请求重试次数,默认:1,number
        • delay(可选):重试的延迟时间,默认:1000,number
        • initialPage(可选):接口查询的起始页码,默认:0,number
        • onProgress(可选):进度更新回调函数,(progress: number) => void
        • onRequestSuccess(可选):单次请求成功回调函数,(page: number, data: any[]) => void
    • batchRequest 方法返回一个对象,包含以下属性:

      • error:错误信息,any
      • startBatchRequest:调用此函数开始请求,返回最终的总数据,() => Promise<any[]>
      • cancelBatchRequest:取消当前正在进行的请求,() => void
  • 2.exportToExcel:方法用于将数据导出为 Excel 文件。它支持多工作表导出、自动列宽、自定义表头、合并单元格等功能。该方法特别适用于需要处理大数据集并将其导出为 Excel 文件的场景。(依赖于xlsxfile-saver这两个库)

  • exportToExcel 方法接受一个配置对象,包含以下参数:

    • sheets:多工作表的配置,每个工作表包含数据和导出选项,Array<{ data: any[], options: ExportToExcelOptions }>

      • ExportToExcelOptions 接口:

        • sheetName(可选):工作表名称,默认:Sheet1string
        • headers(可选):自定义表头,string[]
        • headerKeys(可选):表头对应的字段键名,用于对齐数据,string[]
        • autoWidth(可选):是否自动列宽,默认:trueboolean
        • columnWidths(可选):自定义列宽,number[]
        • merges(可选):自定义合并单元格,MergeRange[]
        • onProgress(可选):进度更新回调函数,(progress: number) => void
    • filename(可选):导出文件名,默认值为当前日期,string

    • pageSize(可选):每次处理的数据条目数,默认:1000,number

    • onProgress(可选):总进度更新回调函数,(progress: number) => void

  • 3.BatchExportExcel.vue:实现下载队列管理组件,用于动态添加任务到队列中,并按顺序处理每个任务。同时支持配置默认进度弹窗是否展示和使用v-model传递队列状态,让使用者可以自定义进度弹窗的展示和关闭。

    • BatchExportExcel.vue 组件接受以下 props:

      • exportInfo:导出任务的实时信息,通过 v-model 绑定,Record<string, Omit<IExportList, 'key'>>

      • showNotify(可选):是否显示通知弹窗,默认:trueboolean

      • showFilename(可选):是否显示文件名,默认:falseboolean

      • handlerWorksheetConfig(可选):自定义工作表配置的回调函数,(data: any[]) => IWorksheetConfig[]

      • BatchExportExcel.vue 组件提供以下事件:

        • finish:导出任务取消或者结束时的回调,返回对应的key(key: string | number) => void
        • update:exportInfo:用于v-model的触发事件。
      • BatchExportExcel.vue 组件提供以下方法:

        • addTask:添加任务到队列中,组件的触发方式(task: IExportList) => void
          • ITask 接口:

            • exportToExcelOptions:Excel 导出配置,Omit<MultiSheetExportOptions, 'onProgress'>
            • batchRequest:批量请求函数,(params: { page: number; pageSize: number; [key: string]: any }) => Promise<any[]>
            • total:获取数据总条数的函数,() => number | Promise<number>
            • batchRequestOptions:批量请求的配置选项,BatchRequestOptions
            • key:任务的唯一标识,string | number

组件源码

batchRequest.ts

export interface BatchRequestOptions {
  pageSize?: number // 每次查询的数据条数
  maxConcurrentRequests?: number // 最大并发请求数
  extraParams?: { [key: string]: any }// 扩展参数
  retries?: number // 请求重试次数
  delay?: number // 重试的延迟时间
  initialPage?: number // 起始页码
  onProgress?: (progress: number) => void // 进度回调函数
  onRequestSuccess?: (page: number, data: any[]) => void // 单次请求成功回调函数
}

interface BatchRequestResult {
  error: any // 错误信息
  startBatchRequest: () => Promise<any[]> // 开始批量请求
  cancelBatchRequest: () => void // 取消批量请求
}

export function batchRequest(
  requestFn: (params: { page: number; pageSize: number; [key: string]: any }) => Promise<any[]>,
  totalDataCount: number, // 接收动态的 totalDataCount
  options?: BatchRequestOptions,
): BatchRequestResult {
  let data: any[] = [] // 存储请求数据
  let progress = 0 // 请求进度
  let error = null // 错误信息
  let isCancelled = false // 标记是否取消

  const retries = options.retries || 1
  const delay = options.delay || 1000
  const initialPage = options.initialPage || 0 // 起始页码

  // 重试机制
  const retryRequest = async (fn: () => Promise<any>, retries: number, delay: number) => {
    for (let attempt = 0; attempt < retries; attempt++) {
      try {
        return await fn()
      }
      catch (err) {
        if (attempt === retries - 1)
          throw err
        await new Promise(resolve => setTimeout(resolve, delay))
      }
    }
  }

  // 取消批量请求函数
  const cancelBatchRequest = () => {
    isCancelled = true
    console.log('批量请求已取消。')
  }

  // 开始批量请求的函数
  const startBatchRequest = async () => {
    console.log('开始批量请求...', options)
    const { pageSize = 100, maxConcurrentRequests = 5, extraParams = {} } = options
    const totalPages = Math.ceil(totalDataCount / pageSize)
    let currentPage = initialPage // 使用自定义的起始页
    const allData: any[] = new Array(totalPages) // 创建一个指定大小的数组来存储每页数据
    const queue: Promise<void>[] = []
    let completedRequests = 0

    error = null // 清除之前的错误状态
    progress = 0 // 重置进度
    isCancelled = false // 重置取消状态

    const requestBatch = async () => {
      while (currentPage < totalPages + initialPage) {
        if (isCancelled)
          return

        const page = currentPage++
        try {
          const params = { page, pageSize, ...extraParams }
          const pageData = await retryRequest(() => requestFn(params), retries, delay)

          allData[page - initialPage] = pageData
          completedRequests++

          // 调用单次请求成功后的回调
          options.onRequestSuccess?.(page, pageData)

          // 更新总进度
          progress = Math.min((completedRequests / totalPages) * 100, 100)
          options.onProgress?.(progress)
        }
        catch (err) {
          error = err
          console.error(`请求第 ${page} 页数据失败:`, err)
        }
      }
    }

    // 初始化并发请求
    for (let i = 0; i < maxConcurrentRequests; i++) {
      queue.push(requestBatch())
    }

    // 等待所有请求完成
    await Promise.all(queue)

    // 如果没有被取消,合并数据
    if (!isCancelled) {
      data = allData.flat()
    }
    return data
  }

  return {
    error,
    startBatchRequest,
    cancelBatchRequest, // 返回取消请求函数
  }
}

exportToExcel.ts

import { utils, write } from 'xlsx'
import { saveAs } from 'file-saver'

// 定义表格合并单元格的类型
interface MergeRange {
    s: { r: number; c: number } // 起始位置,行和列
    e: { r: number; c: number } // 结束位置,行和列
}

// 定义导出选项的接口
export interface ExportToExcelOptions {
    sheetName?: string // 工作表名称
    headers?: string[] // 自定义表头
    headerKeys?: string[] // 表头对应的字段键名,用于对齐数据
    autoWidth?: boolean // 是否自动列宽
    columnWidths?: number[] // 自定义列宽
    merges?: MergeRange[] // 自定义合并单元格
    onProgress?: (progress: number) => void // 进度更新回调函数
}

// 定义多工作表的配置
export interface MultiSheetExportOptions {
    sheets: Array<{
        data: any[] // 数据直接传入
        options: ExportToExcelOptions // 导出选项
    }>
    filename?: string // 导出的文件名
    pageSize?: number // 每次处理的数据条目数,默认 1000
    onProgress?: (progress: number) => void // 总进度更新回调
}

/**
 * 导出多个工作表数据到 Excel 文件,支持大数据集优化
 * @param {MultiSheetExportOptions} multiSheetOptions - 多工作表的配置
 */
export async function exportToExcel(
  multiSheetOptions: MultiSheetExportOptions,
): Promise<void> {
  const { sheets, filename = new Date().toLocaleDateString().replaceAll('/', '-'), pageSize = 1000, onProgress } = multiSheetOptions

  try {
    // 统一的进度更新函数
    const updateProgress = (percentage: number) => {
      if (onProgress)
        onProgress(percentage)
    }

    updateProgress(10) // 开始导出进度

    // 创建一个新的工作簿
    const workbook = utils.book_new()

    // 处理每个工作表
    for (let i = 0; i < sheets.length; i++) {
      const { data, options } = sheets[i]
      const {
        sheetName = `Sheet${i + 1}`, // 默认使用 Sheet1, Sheet2...
        headers = [],
        headerKeys = [],
        autoWidth = true,
        columnWidths = [],
        merges = [],
      } = options

      const finalData: any[] = []

      // 计算总页数
      const totalPages = Math.ceil(data.length / pageSize)

      // 分批次处理数据,减少内存压力
      for (let page = 0; page < totalPages; page++) {
        const start = page * pageSize
        const end = Math.min(start + pageSize, data.length) // 每次最多处理 pageSize 条数据
        const dataChunk = data.slice(start, end) // 直接从数据中切片

        const mappedChunk = dataChunk.map((row) => {
          const reorderedRow: Record<string, any> = {}
          headerKeys.forEach((key) => {
            reorderedRow[key] = row[key]
          })
          return reorderedRow
        })

        finalData.push(...mappedChunk)

        // 更新进度
        const chunkProgress = (page + 1) / totalPages * 80 / sheets.length
        updateProgress(10 + chunkProgress)
      }

      // 插入表头
      if (headers.length > 0) {
        finalData.unshift(headers.reduce((obj, header, index) => {
          obj[headerKeys[index]] = header
          return obj
        }, {}))
      }

      // 转换数据为工作表
      const worksheet = utils.json_to_sheet(finalData, { skipHeader: headers.length > 0 })

      // 自动列宽处理
      if (autoWidth) {
        worksheet['!cols'] = calculateColumnWidths(finalData)
      }

      // 自定义列宽
      if (columnWidths.length > 0) {
        worksheet['!cols'] = columnWidths.map(width => ({ wpx: width }))
      }

      // 合并单元格
      if (merges.length > 0) {
        worksheet['!merges'] = merges
      }

      // 添加工作表到工作簿
      utils.book_append_sheet(workbook, worksheet, sheetName)

      // 完成该工作表的进度更新
      updateProgress(90 * (i + 1) / sheets.length)
    }

    // 导出为二进制文件
    const excelBuffer = write(workbook, { bookType: 'xlsx', type: 'array' })

    updateProgress(95) // 模拟进度到95%

    // 使用 file-saver 保存文件
    const blob = new Blob([excelBuffer], { type: 'application/octet-stream' })
    saveAs(blob, `${filename}.xlsx`)

    updateProgress(100) // 完成导出
  }
  catch (error) {
    console.error('导出 Excel 时发生错误: ', error)
    if (onProgress)
      onProgress(-1) // 进度更新为 -1 表示出错
  }
}

/**
 * 计算列宽
 * @param {Array<object>} data - 表格数据
 * @returns {Array<object>} 列宽配置
 */
function calculateColumnWidths(data: any[]): { wch: number }[] {
  const objectMaxLength: number[] = []

  data.forEach((row) => {
    Object.keys(row).forEach((key, index) => {
      const value = row[key] || ''
      const length = value.toString().length

      // 考虑不同字符的显示宽度,中文字符一般较宽,设定系数为 2
      const isChinese = /[\u4E00-\u9FA5]/.test(value)
      const charWidth = isChinese ? length * 2 : length

      // 设置最小列宽,避免某些列太窄无法完全展示内容
      const minWidth = 10
      objectMaxLength[index] = Math.max(minWidth, objectMaxLength[index] || 0, charWidth)
    })
  })

  // 调整列宽系数为 1.2,使显示效果更好
  return objectMaxLength.map(width => ({ wch: Math.ceil(width * 1.2) }))
}

typing.ts

import type { BatchRequestOptions } from '@galaxy-fe/utils/index'
import type { ExportToExcelOptions, MultiSheetExportOptions } from '@galaxy-fe/utils/exportToExcel'

export interface ITask {
  exportToExcelOptions: Omit<MultiSheetExportOptions, 'onProgress'>
  batchRequest: (params: { page: number; pageSize: number;[key: string]: any }) => Promise<any[]>
  total: () => number | Promise<number>
  batchRequestOptions: BatchRequestOptions
  key: string | number
}

export interface IExportList {
  key: string | number
  data: any[]
  isLoading: boolean
  progress: number
  isCanceled: boolean
  cancelTask: ((index: number) => void) | null
}

export interface IWorksheetConfig { data: any[]; options: ExportToExcelOptions }

export interface IProps {
  exportInfo?: Record<string, Omit<IExportList, 'key'>>
  showNotify?: boolean
  showFilename?: boolean
  handlerWorksheetConfig?: (data: any[]) => IWorksheetConfig[]
}

BatchExportExcel.vue

<script lang="ts" setup name="BatchExportExcel">
import { defineEmits, defineProps, ref, watch, withDefaults } from 'vue'
import { Close, VideoPause } from '@element-plus/icons-vue'
import { batchRequest } from '@galaxy-fe/utils'
import { exportToExcel } from '@galaxy-fe/utils/exportToExcel'
import type {
  IExportList,
  IProps,
  ITask,
  IWorksheetConfig,
} from './typing'

const props = withDefaults(defineProps<IProps>(), {
  showNotify: true,
  showFilename: false,
  exportInfo: () => ({}),
})
const emits = defineEmits(['update:exportInfo', 'finish'])
const model = ref<Record<string, Omit<IExportList, 'key'>>>({})

watch(() => model.value, (val) => {
  emits('update:exportInfo', val)
}, { deep: true })

const showNotification = ref(false)

const taskQueue = ref<ITask[]>([]) // 动态任务队列
const taskProgress = ref<Array<number>>([]) // 每个任务的进度数组
const taskCancelFns = ref<Array<() => void>>([]) // 每个任务的取消函数
const isProcessing = ref(false) // 当前是否有任务在处理
const cancelExport = ref<boolean[]>([])

// 处理任务成功逻辑
const handleSuccess = async (task: ITask, taskIndex: number, requestData: any[], worksheetConfig: null | IWorksheetConfig[]) => {
  // 生成 Excel 文件,并更新表格导出进度
  const { sheets, ...excelConfig } = task.exportToExcelOptions
  let finalSheets = sheets
  if (worksheetConfig) {
    finalSheets = worksheetConfig
  }
  else {
    finalSheets[0].data = requestData
  }
  await exportToExcel({
    ...excelConfig,
    sheets: finalSheets,
    onProgress: (p: number) => {
      const requestProgress = taskProgress.value[taskIndex]
      const exportProgress = p / 20
      taskProgress.value[taskIndex] = Math.floor(requestProgress + exportProgress)
      model.value[taskQueue.value[taskIndex].key].progress = taskProgress.value[taskIndex]
    },
  })
  taskProgress.value[taskIndex] = 100
  model.value[taskQueue.value[taskIndex].key].progress = 100
  model.value[taskQueue.value[taskIndex].key].isLoading = false
  model.value[taskQueue.value[taskIndex].key].data = requestData
  emits('finish', taskQueue.value[taskIndex].key)
}

// 取消单个任务
const cancelTask = (index: number) => {
  cancelExport.value[index] = true // 更新任务状态为取消
  taskCancelFns.value[index]() // 调用任务的取消函数
  if (taskProgress.value[index] < 1) {
    model.value[taskQueue.value[index].key].isCanceled = true
    model.value[taskQueue.value[index].key].isLoading = false
    taskProgress.value.splice(index, 1)
    taskQueue.value.splice(index, 1)
    taskCancelFns.value.splice(index, 1)
    cancelExport.value.splice(index, 1)
    emits('finish', taskQueue.value[index].key)
  }
  if (!taskQueue.value.length) {
    taskProgress.value = []
    showNotification.value = false
  }
}

// 任务调度器:负责处理队列中的任务
const processTask = async (taskIndex: number) => {
  console.log('-------taskIndex', taskIndex)
  try {
    const task = taskQueue.value[taskIndex]
    if (!task)
      return

    const totalDataCount = await task.total() // 获取总数据量

    const { startBatchRequest, cancelBatchRequest } = batchRequest(
      task.batchRequest,
      totalDataCount,
      {
        ...task.batchRequestOptions,
        onProgress: (p: number) => {
          taskProgress.value[taskIndex] = Math.ceil(p * 0.95)
          model.value[taskQueue.value[taskIndex].key].progress = taskProgress.value[taskIndex]
        },
      },
    )

    taskCancelFns.value[taskIndex] = cancelBatchRequest // 保存任务的取消函数
    model.value[taskQueue.value[taskIndex].key].cancelTask = cancelTask

    // 开始接口请求,并更新接口请求进度
    const requestData = await startBatchRequest()

    let worksheetConfig = null
    if (props.handlerWorksheetConfig && typeof props.handlerWorksheetConfig === 'function') {
      worksheetConfig = props.handlerWorksheetConfig(requestData) // 调用自定义数据转换函数
    }

    if (taskProgress.value[taskIndex] < 100 && cancelExport.value[taskIndex]) {
      handleCancellation(taskIndex)
      return Promise.reject(Error('取消下载'))
    }
    else {
      await handleSuccess(task, taskIndex, requestData, worksheetConfig) // 处理成功逻辑
    }
  }
  catch (error) {
    console.error(`任务 ${taskIndex + 1} 执行失败:`, error)
    taskProgress.value.splice(taskIndex, 1) // 任务失败或被取消
  }
}

// 处理队列中所有任务
const processQueue = async () => {
  if (isProcessing.value) {
    return // 如果已经在处理任务,直接返回
  }
  isProcessing.value = true
  showNotification.value = true

  for (let i = 0; i < taskQueue.value.length; i++) {
    if (taskProgress.value[i] < 100) {
      await processTask(i) // 处理队列中的每个任务
    }
  }

  isProcessing.value = false

  // 所有任务完成后自动关闭通知弹窗
  nextTick(() => {
    if (!taskQueue.value.length || taskProgress.value.every(t => t === 100)) {
      showNotification.value = false
      taskQueue.value = []
      taskProgress.value = []
      taskCancelFns.value = []
      isProcessing.value = false
      cancelExport.value = []
    }
  })
}

// 处理任务取消逻辑
function handleCancellation(taskIndex: number) {
  model.value[taskQueue.value[taskIndex].key].isCanceled = true
  model.value[taskQueue.value[taskIndex].key].isLoading = false
  taskQueue.value.splice(taskIndex, 1)
  taskCancelFns.value.splice(taskIndex, 1)
  cancelExport.value.splice(taskIndex, 1)
  isProcessing.value = false
  emits('finish', taskQueue.value[taskIndex].key)
  processQueue()
}

// 添加任务到队列
const addTask = (task: ITask) => {
  taskQueue.value.push(task) // 将任务添加到队列
  taskProgress.value.push(0) // 添加任务的进度条
  cancelExport.value.push(false) // 添加任务的进度条
  taskCancelFns.value.push(() => { }) // 占位取消函数
  model.value[task.key] = {
    data: [],
    isLoading: true,
    progress: 0,
    isCanceled: false,
    cancelTask: null,
  }
  processQueue() // 每次添加任务后尝试处理队列
}

defineExpose({
  addTask,
})
</script>

<template>
  <teleport to="body">
    <transition name="notification-fade">
      <div v-if="props.showNotify && showNotification" class="notification">
        <div class="notification__title">
          数据导出中:
        </div>
        <div v-for="(task, index) in taskQueue" :key="index" class="notification__content">
          <div v-show="showFilename" class="filename">
            {{ task.exportToExcelOptions.filename }}:
          </div>
          <div class="notification__progress-wrapper">
            <div class="notification__progress">
              <el-progress class="progress" :text-inside="true" :stroke-width="12" striped :percentage="taskProgress[index]" />
            </div>
            <el-button class="cancel_btn" :icon="VideoPause" type="danger" plain link @click="cancelTask(index)" />
          </div>
        </div>
        <el-button class="notification__close-btn" :icon="Close" @click="showNotification = false" />
      </div>
    </transition>
  </teleport>
</template>

<style lang="scss" scoped>
.notification {
  position: fixed;
  top: 10px;
  right: 10px;
  width: 300px;
  padding: 16px;
  background-color: #fff;
  border: 1px solid #d8d5d5;
  border-radius: 8px;
  box-shadow: 0 2px 12px rgb(0 0 0 / 10%);
  transition: opacity 0.3s ease, transform 0.3s ease;

  .notification__title {
    margin-bottom: 8px;
    font-weight: bold;
  }

  .notification__content {
    width: 100%;
    font-size: 14px;

    .filename {
      width: 100%;
      height: 32px;
      overflow: hidden;
      line-height: 32px;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    .notification__progress-wrapper {
      display: flex;
      align-items: center;
      width: 100%;
      padding: 0;
      margin-left: 12px;
    }

    .notification__progress {
      display: flex;
      flex: 1;
      align-items: center;

      .progress {
        flex: 1;
      }
    }

    .cancel_btn {
      padding: 0;
      margin-left: 12px;
    }
  }

  .notification__close-btn {
    position: absolute;
    top: 8px;
    right: 8px;
    padding: 0;
    font-size: 16px;
    cursor: pointer;
    background: none;
    border: none;
  }
}

.notification-fade-enter-active,
.notification-fade-leave-active {
  transition: opacity 0.3s ease, transform 0.3s ease;
}

.notification-fade-enter-from,
.notification-fade-leave-to {
  opacity: 0;
  transform: translateY(-20px);
}
</style>

组件使用示例

<script setup lang="ts">
import { Download } from '@element-plus/icons-vue'
import { GaBatchExportExcel, GaSearchForm, GaSearchItem } from '../../src/index'
import type { ITask, IWorksheetConfig } from '../../src/BatchExportExcel/src/typing'

const useCompRef = <T extends abstract new (...args: any) => any>(_comp: T) => {
  return ref<InstanceType<T>>()
}
const batchExportExcelRef = useCompRef(GaBatchExportExcel)

// 组件导出数据的实时数据,通过exportTaskOptions中定义的key来归类
const exportInfo = ref({})

// 是否正在导出数据
const isExport = ref(false)

function objectToQueryParams(obj: { [key: string]: any }) {
  const keys = Object.keys(obj)
  const queryParams = keys.map((key) => {
    const value = obj[key]
    if (Array.isArray(value)) {
      return value.map(v => `${encodeURIComponent(key)}[]=${encodeURIComponent(v)}`).join('&')
    }
    return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
  })
  return queryParams.join('&')
}

interface GetKidsbayAdminGetUserCheckListQuery {
  /** 用户昵称  example:  */
  nickname?: string
  /** 用户手机号  example:  */
  phone?: string
  /** 测评开始时间  example:  */
  start_time?: string
  /** 测评结束时间  example:  */
  end_time?: string
  /** 页码 example: 1 */
  page: number
  /** 每页数量 example: 10 */
  page_size: number
}

/**
 * 获取数据(对应我们项目中封装的useWrv或者axios请求)
 *
 * @param params 查询参数
 * @returns 返回用户检查列表数据
 */
const getData = async (params: GetKidsbayAdminGetUserCheckListQuery) => {
  const requestHeaders = new Headers()
  requestHeaders.append('Content-Type', 'application/json')
  requestHeaders.append('token', 'XXXX')
  const requestOptions = {
    method: 'GET',
    headers: requestHeaders,
    mode: 'cors' as RequestMode,
    cache: 'no-cache' as RequestCache,
  }
  const paramsStr = objectToQueryParams(params)
  const response = await fetch(`https://XXXXX.com/admin/get-user-check-list?${paramsStr}`, requestOptions)
  const data = await response.json()
  return data.data
}

const model = ref({
  nickname: undefined,
  phone: undefined,
  time: undefined,
})

/**
 * 批量请求数据单次调用的回调,这里可以对数据进行转换处理,返回转换后的数据项数组
 *
 * @param params 请求参数对象,包含页码、每页数量等属性
 * @param params.page 页码
 * @param params.pageSize 每页数量
 * @param params.[key: string] 其他参数
 * @returns 返回请求结果,若请求成功,返回包含转换后的数据项的数组;若请求失败,返回 Promise.reject(error)
 */
const batchRequest = async (params: { page: number; pageSize: number;[key: string]: any }) => {
  try {
    const { pageSize, ...arg } = params
    const allParams = {
      ...arg,
      page_size: pageSize,
    }
    const { list } = await getData(allParams)
    const data = list.map((item: Record<string, any>) => {
      item.status_name = item.status === 1 ? '已完成' : '未完成'
      return item
    })
    return Promise.resolve(data)
  }
  catch (error) {
    return Promise.reject(error)
  }
}

/**
 * 获取需要导出的数据的总条数
 */
const dataTotal = async () => {
  try {
    const allParams = {
      nickname: model.value.nickname || '',
      phone: model.value.phone || '',
      start_time: model.value.time?.[0] || '',
      end_time: model.value.time?.[1] || '',
      page: 1,
      page_size: 1,
    }
    const { total } = await getData(allParams)
    return Promise.resolve(total)
  }
  catch (error) {
    return Promise.resolve(0)
  }
}

const handlerWorksheetConfig = ref<((data: any[]) => IWorksheetConfig[]) | undefined>(undefined)

const startExportData = (multiple = false) => {
  isExport.value = true
  const sheetsOptions = {
    headers: ['测评时间', '测评用户', '用户手机号', '测评内容', '宝宝昵称', '宝宝月龄', '测评时长', '测评状态', '综合得分', '粗大动作', '精细动作', '认知发展', '语言交流', '社会情感'],
    headerKeys: ['check_time', 'nickname', 'phone', 'name', 'baby_name', 'baby_month_age', 'estimated_duration', 'status_name', 'score', 'domain_score_2', 'domain_score_1', 'domain_score_5', 'domain_score_4', 'domain_score_3'],
    autoWidth: true,
    sheetName: '表1',
  }
  if (multiple) {
    /**
     * 下面是模拟多工作表的情况,模拟需要将导出的数据根据手机号分组,每一个手机号的数据放到一个工作表中,并把手机号用作工作表名称
    */
    handlerWorksheetConfig.value = (list: any[]) => {
      console.log('----list', list)
      const grouped = list.reduce((acc, item) => {
        const phone = item.phone

        // 如果这个 phone 还没有分组,则创建一个
        if (!acc[phone]) {
          acc[phone] = {
            data: [], // 存放相同 phone 的数据
            options: {
              ...sheetsOptions,
              sheetName: phone, // sheetName 为 phone
            },
          }
        }

        // 将当前项加入对应的 phone 分组中
        acc[phone].data.push(item)
        return acc
      }, {})
      console.log('----Object.values(grouped)', Object.values(grouped))
      // 返回一个数组形式的结果
      return Object.values(grouped)
    }
  }
  const exportToExcelOptions = {
    filename: 'kidsbay用户测评列表',
    sheets: [
      {
        data: [],
        options: sheetsOptions,
      },
    ],
  }
  const exportTaskOptions: ITask = {
    batchRequest,
    total: dataTotal,
    batchRequestOptions: {
      pageSize: 500,
      maxConcurrentRequests: 5,
      initialPage: 1,
      retries: 1,
      extraParams: {
        nickname: model.value.nickname || '',
        phone: model.value.phone || '',
        start_time: model.value.time?.[0] || '',
        end_time: model.value.time?.[1] || '',
      },
    },
    exportToExcelOptions,
    key: 'user',
  }
  batchExportExcelRef.value?.addTask(exportTaskOptions)
}
</script>

<template>
  <div class="batch-export-excel">
    <GaSearchForm label-width="0" :model="model">
      <template #button>
        <el-button type="primary" plain :icon="Download" :loading="isExport" @click="startExportData()">
          导出数据
        </el-button>
        <el-button type="primary" plain :icon="Download" :loading="isExport" @click="startExportData(true)">
          导出多工作表数据
        </el-button>
      </template>
      <GaSearchItem label="" prop="nickname">
        <el-input v-model="model.nickname" placeholder="请输入用户昵称" maxlength="50" clearable autocomplete="off" />
      </GaSearchItem>
      <GaSearchItem label="" prop="phone">
        <el-input v-model="model.phone" placeholder="请输入用户手机号" maxlength="50" clearable autocomplete="off" />
      </GaSearchItem>
      <GaSearchItem label="" prop="time">
        <el-date-picker
          v-model="model.time"
          type="daterange"
          :default-time="[
            new Date(2000, 1, 1, 0, 0, 0),
            new Date(2000, 2, 1, 23, 59, 59),
          ]"
          value-format="YYYY-MM-DD HH:mm:ss"
          format="YYYY-MM-DD"
          start-placeholder="测评开始时间"
          end-placeholder="测评结束时间"
        />
      </GaSearchItem>
    </GaSearchForm>

    <GaBatchExportExcel ref="batchExportExcelRef" v-model:exportInfo="exportInfo" :handler-worksheet-config="handlerWorksheetConfig" @finish="isExport = false" />
  </div>
</template>

总结

这个 Vue 组件是为了优化大量数据导出到 Excel 的过程而设计的。它通过动态任务队列管理、进度监控、任务取消、自定义工作表配置、多工作表导出以及错误处理和重试机制等功能,提高了大数据量导出的性能和用户体验。组件提供了详细的源码和使用示例,方便开发者在项目中集成和使用。

使用场景

这个 Vue 组件的使用场景主要包括但不限于以下几个方面:

  • 1.企业报表导出:企业中经常需要将报表数据导出为 Excel 文件,以便于后续的分析和存档。

  • 2.数据备份:需要定期将数据库中的数据导出备份,以防数据丢失或损坏。

  • 3.客户信息管理:在客户关系管理系统中,可能需要将客户信息导出为 Excel 文件,以便进行整理或分享。

  • 4.订单处理系统:电商平台或销售系统中,订单数据的导出是常见的需求,方便进行物流管理或财务核算。

  • 5.数据分析:在进行数据分析时,可能需要将数据从系统导出到 Excel,利用 Excel 的图表和分析工具进行进一步处理。

  • 6.教育和学术研究:学校或研究机构可能需要导出研究数据或学生成绩,以便于进行统计分析或撰写报告。

  • 7.政府和公共部门:政府机构可能需要导出公民信息、投票结果或其他公共数据,以便于进行管理和发布。

  • 8.医疗健康记录:医疗机构可能需要导出病人的健康记录或检查结果,以便于进行跨机构的会诊或研究。

  • 9.项目管理工具:在项目管理软件中,可能需要导出项目进度、资源分配或时间表等信息。

  • 10.人力资源管理:人力资源部门可能需要导出员工信息、工资单或出勤记录等。

  • 11.库存管理:库存管理系统中,库存数据的导出可以帮助进行库存盘点或供应商结算。

  • 12.自定义报表生成:在需要根据用户自定义条件生成报表的场景下,这个组件可以导出用户指定格式的数据。

  • 13.数据迁移:在系统升级或更换时,需要将数据从一个系统迁移到另一个系统,导出为 Excel 文件是一种常见的中间步骤。

  • 14.审计和合规性检查:为了满足审计或合规性要求,可能需要导出系统数据以供审查。

这个组件由于其灵活性和强大的功能,非常适合需要处理大量数据导出场景的应用程序。

转载自:https://juejin.cn/post/7418233427420823603
评论
请登录