前端导出Excel之动态多级表头

Aaron
English

前阵子居家办公接到一个需求前端导出Excel,我兴致勃勃的的和产品说这个东西没什么问题,接着产品就详细的把他的需求一字不差的和我讲了遍,当时我想收回我刚才说过的话。

大致需求如下:

页面中需要根据不同的查询条件展示不同的表头信息和表格数据,导出Excel的时候也需要根据当前表头信息导出数据。表头可能是一级,可能是二级,可能是...N级。我当时想拿大刀砍产品。

我和后端商量了一下这个东西可不可以他们做,为了性能最后决定前后端都得做(谁也跑不了...),数据量大的时候后端导出,数据量小的时候前端导出。

背景故事大概就是这样,因为笔者原来做过Excel导出,但是也只是简单应用而是,涉及到二级表头都很少,更别说N级了。为了能够实现这个需求我重新看了一下原来使用的框架以及其他方向的调研,于是经过笔者不懈的努力,忙碌了两个小时的笔者,最后觉得打开淘宝...错了,重来。最终实现了两版,出现的效果是一样的,但是两者仍存在差别。

导出Excel多级表头 1.0

最开始调研的时候使用的是xlsx这个工具包,因为这个包相对来说比较是熟悉,上手比较快。当时在最开始要优先考虑。

安装依赖:

npm install xlsx -S
or
yarn add xlsx -S

创建文件DownExcel.js在文件中引入xlsx,废话不多说创建一个class,程序设计前期思考是这样的,在class初始化的时候,需要接收一个tableHead即需要导出数据的表头,下载文件的时候需要调用down方法,在down方法中需要接收data和需要导出的文件名。

class DownExcel {

  constructor({ header = [] }) {
    this.tableHeader = header;
  }
  
  down(fileName, tableData = []){
      
  }
    
}

最棘手其实不是表格数据的填写,而是如何让导出的Excel支持N级动态表头。由于xlsx框架在导出excel的时候支持Csv形式的,最后将Csv转换成Sheet之后再导出文件,那么也就是说我需要在整理Csv数据之前,就应该知道了表头的合并信息。但是xlsx框架的合并信息是单独存储的,大概内容如下:

const marge = [{
    s: { r: 0, c: 1 },
    e: { r: 0, c: 2 }
}];

上述内容中,s代表开始的节点位置,e代表的是结束的节点位置,r代表的是行,c代表的是列。通过分析数据可以看出。分别确认了两个坐标点,依照两个坐标点进行单元格合并。那么如果想要支持多级表头数据那么就需要支持marge信息是什么,如果可以根据表头信息来生成岂不就行了吗?

页面表格中是存有表头信息的,导出Excel需要根据页面表头来生成一样的。表头数据信息大概是这个样子的:

const tableHeader = [{
  field: "a111",
  title: "a1"
},{
  field: "a333",
  title: "a3",
  children: [{
    field: "b111",
    title: "b1",
  },{
    field: "b222",
    title: "b2",
    children: [{
      field: "c1",
      title: "c111",
      children: [{
        file: "d444",
        title: "d4"
      }]
    }]
  }]
}];

以上就是表头的数据为嵌套数据格式,只要是下级存在children那么该级就需要进行合并,如果没有的话就需要合并到表头的底部。

创建方法resetMergeHeaderInfo

class DownExcel {

  down(fileName, tableData = []){
    this.resetMergeHeaderInfo();
  }

 // 表头数据                tableHeader
 // 表头深度                maxLevel
 // 合并表头临时存储信息    outMarge
 // 最终结果                result
  resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
      
  }    

}

在方法中其中有一个参数是maxLevel,这个参数是当前表头信息的最大深度?也就是当前表头信息一共嵌套了多少层。有了这个参数,就能知道最外层的数据,如果没有children的时候单元格的合并范围。那么也就需要确认哪些是最外层,哪些是内层的,哪些是有children,哪些是没有children。大致上在表头中出现的情况也就这几种了。

首先确认外层数据,如果想实现嵌套数据,比不少会用到递归,需要对外层书进行标记。

class DownExcel {

 // 表头数据                tableHeader
 // 表头深度                maxLevel
 // 合并表头临时存储信息    outMarge
 // 最终结果                result
  resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
    this.tagHeadIn();
  }    
  
  //  标记最外层数据
  tagHeadIn(){
    const { tableHeader } = this;
    tableHeader.forEach((el) => {
      el.isOut = true;
      return el;
    })
  }

}

先处理外层数据,刚才也说过了,需要知道最大的嵌套层级是多大,所以这里需要方法获取到所需要的这个参数。

class DownExcel {

  down(){
    const { tableHeader } = this;
    let maxLevel = this.maxLevel(tableHeader);
    this.resetMergeHeaderInfo(tableHeader,maxLevel);
  }
  
  resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
    this.tagHeadIn();
  }
    
 tagMaxLevel(tableHeader){
    const maxLevel = this.maxLevel(tableHeader, false);
    tableHeader.forEach((el) => {
      if(!el.children){
        el.maxLen = maxLevel;
      }
      else{
        this.tagMaxLevel(el.children);
        el.maxLen = maxLevel;
      }
    });
  }
    
}

在标记层级的的时候,在每一层都标注了,他下面的最大层级,这样就不需要每次都需要遍历去去获取最大深度。以为子级也是有嵌套,如果没有嵌套越需要知道当前单元格向下合并几个,否则会导致合并不能统一。

接下来就是需要处理最外层的表格信息了:

class DownExcel {

  resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
    this.tagHeadIn();
    for(let i = 0; i < tableHeader.length; i++){
        let item = tableHeader[i];
         //  纵向跨度
         const { maxLen } = item;
        //  开始节点信息
        let s = {};
        //  结束节点信息
        let e = {};
        //  如果没有子级
        if(!item.children){
            //  如果是最外层元素
            if(item.isOut){
                //  当前列开始位置
                outMarge.startCell += 1;
                //  全局列开始位置
                outMarge.basisCell += 1;
                //  开始行
                s.r = 0;
                //  结束行
                e.r = maxLevel;
                //  开始列
                s.c = outMarge.startCell;
                //  结束列
                e.c = outMarge.startCell;
                result.push({ s, e, item });
            }else{  //  不是外层元素
                
            }
        }
        //  如果有子级
        if(item.children){
            //  如果是最外层元素
            if(item.isOut){
                
            }else{  //  不是外层元素
                
            }
        }
    }
  }
}

根据已知的信息对外层没有子级的单元格信息已经获取到了,为什么要记录两个列开始信息?如果A单元格下面有两个子级,一个没有children(A1)另一个有children(A2)确定完信息之后,开始列就会发生变化自增1A2渲染的时候子级有多少个需要在全局记录的行信息上加上,这样进入到B单元格循环的时候才会从对应的地方开始进行下一次合并信息记录。

当有子级的时候就会有一个问题,那么就是当前单元格的横向合并多少个单元格?那么就可以根据当前单元格的children所有没有children的子级,就是当前单元格的横向跨度。

class DownExcel {

  //  获取当前下面所有子级
  //  即:表头横向跨度单元格数量
  getLastChild (arr, result = []){
    for(let i = 0,item; item = arr[i++];){
      if(!item.children){
        result.push(item);
      }else{
        result = this.getLastChild(item.children, result);
      }
    }
    return result;
  }

}

知道了横向跨度之后可以处理外部有子级的单元格合并信息的收集:

class DownExcel {

  resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
    this.tagHeadIn();
    for(let i = 0; i < tableHeader.length; i++){
        let item = tableHeader[i];
         //  纵向跨度
         const { maxLen } = item;
         //  横向跨度
        let lastChild = this.getLastChild(item.children || []);
        //  开始节点信息
        let s = {};
        //  结束节点信息
        let e = {};
        //  如果没有子级
        if(!item.children){
            //  如果是最外层元素
            if(item.isOut){
                //  .....
            }else{  //  不是外层元素
                
            }
        }
        //  如果有子级
        if(item.children){
            //  如果是最外层元素
            if(item.isOut){
              // 开始行
              s.r = 0;
              // 结束行
              e.r = 0;
              // 局部开始列自增
              outMarge.startCell += 1;
              // 开始列
              s.c = outMarge.startCell;
              // 开始列加上横向跨度
              outMarge.startCell += lastChild.length - 1;
              // 结束列
              e.c = outMarge.startCell;
              result.push({ s, e, item });
            }else{  //  不是外层元素
                
            }
        }
    }
  }
}

因为横向开始位置是需要记录的,所以要加上当前的横向跨度,避免下次循环的开始位置出现错误。

class DownExcel {

  resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
    this.tagHeadIn();
    for(let i = 0; i < tableHeader.length; i++){
        let item = tableHeader[i];
         //  纵向跨度
         const { maxLen } = item;
         //  横向跨度
        let lastChild = this.getLastChild(item.children || []);
        //  开始节点信息
        let s = {};
        //  结束节点信息
        let e = {};
        //  如果没有子级
        if(!item.children){
            //  如果是最外层元素
            if(item.isOut){
                //  .....
            }else{  //  不是外层元素
              // 开始行 
              let r = maxLevel - (outMarge.basisRow + maxLen);
              r = Math.max(r, 0);
              s.c = outMarge.basisCell;
              e.c = outMarge.basisCell;
              s.r = outMarge.basisRow;
              e.r = r + outMarge.basisRow + maxLen;
              result.push({ s, e, item });
              //  开始行数据 + 1
              outMarge.basisCell += 1;
            }
        }
        //  如果有子级
        if(item.children){
            //  如果是最外层元素
            if(item.isOut){
              //    ... 
            }else{  //  不是外层元素
                
            }
        }
    }
  }
}

处理完外部数据就可以处理内部了,处理内部数据其实和处理外部数据是差不多的。在处理子级的时候,如果单元格下面两个如果A单元格下面有两个子级,一个没有children(A1)另一个有children(A2),这个时候A1需要向下合并到最底部,用最大深度 - (开始位置 + 合并高度)得到差值,即当前单元个的结束位置。

class DownExcel {

  resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
    this.tagHeadIn();
    for(let i = 0; i < tableHeader.length; i++){
        let item = tableHeader[i];
         //  纵向跨度
         const { maxLen } = item;
         //  横向跨度
        let lastChild = this.getLastChild(item.children || []);
        //  开始节点信息
        let s = {};
        //  结束节点信息
        let e = {};
        //  如果没有子级
        if(!item.children){
            //  如果是最外层元素
            if(item.isOut){
                //  .....
            }else{  //  不是外层元素
               //   .....
            }
        }
        //  如果有子级
        if(item.children){
            //  如果是最外层元素
            if(item.isOut){
              //    ... 
            }else{  //  不是外层元素
                s.c = outMarge.basisCell;
                e.c = outMarge.basisCell + lastChild.length - 1;
                s.r = outMarge.basisRow;
                e.r = outMarge.basisRow;
                result.push({ s, e, item });
            }
        }
    }
  }
}

这里处理逻辑和外层有子级的处理逻辑是类似的。

class DownExcel {

  resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
    this.tagHeadIn();
    for(let i = 0; i < tableHeader.length; i++){
        let item = tableHeader[i];
         //  纵向跨度
         const { maxLen } = item;
         //  横向跨度
        let lastChild = this.getLastChild(item.children || []);
        //  开始节点信息
        let s = {};
        //  结束节点信息
        let e = {};
        //  如果没有子级
        if(!item.children){
            //  如果是最外层元素
            if(item.isOut){
                //  .....
            }else{  //  不是外层元素
               //   .....
            }
        }
        //  如果有子级
        if(item.children){
            //  如果是最外层元素
            if(item.isOut){
              //    ... 
            }else{  //  不是外层元素
              // ....
            }
        }
        outMarge.basisRow += 1;
        this.resetMergeHeaderInfo(item.children, maxLevel, outMarge, result);
    }
    outMarge.basisRow -= 1;
    return result;
  }
}

处理完收集信息,需要把所有子级的信息收集一下,需要把本地的列数自增一下,保证所和合并的时候合并坐标值是随着遍历和递归是同步进行的。每次递归完需要减一,也就是递归一次需要加一次。

这样针对合并信息收集就完成了,得到的数据和xlsx框架所需要的是一致的。

class DownExcel {

  down(fileName, tableData = []){
    const { tableHeader, outMarge } = this;
    let maxLevel = this.maxLevel(tableHeader);
    const mergeInfo = this.resetMergeHeaderInfo(tableHeader, maxLevel, outMarge);
    const lastChild = this.getLastChild(tableHeader);
    const headCsv = this.getHeadCsv(tableHeader, lastChild, maxLevel, mergeInfo);
    const dataCsv = this.getDataCsv(tableData, lastChild);
    const allCsv = this.margeCsv(headCsv, dataCsv);
  }
  
  
  //  将数据转换成Csv格式
  getHeadCsv(tableHeader, lastChild, maxLevel, mergeInfo){
    let csvArr = [];
    let csv = "";
    for(let i = 0; i < (maxLevel + 1); i++){
      let item = [];
      for(let j = 0; j < lastChild.length; j++){
        item.push(null);
      }
      csvArr.push(item);
    }
    for(let i = 0; i < mergeInfo.length; i++){
      let info = mergeInfo[i];
      const { s, item } = info;
      const { c, r } = s;
      const { title } = item;
      csvArr[r][c] = title;
      console.log(mergeInfo);
    }
    csvArr = csvArr.map((el) => {
      return el.join("^");
    });
    return csvArr.join("~");
  }
  
  //  获取data的Csv
  getDataCsv(data, lastChild){
    let result = [];
    for(let j = 0, ele; ele = data[j++];){
      let value = [];
      for(let i = 0, item; item = lastChild[i++];){
        value.push(ele[item.field] || "-");
      };
      result.push(value);
    }
    result = result.map((el) => {
      return el.join("^");
    });
    return result.join("~");
  }
  
  //  合并Csv
  margeCsv(headCsv, dataCsv){
    return `${headCsv}~${dataCsv}`;
  }
  
}

合并信息处理完了之后需要把当前的表头数据和列表数据处理成csv格式,方便导出excel,首先考虑的是表头合并信息部分数据需要用空数据代替,不能把所有的数据直接填写进去,否则在导出的时候会发生数据位置错乱的问题。

class DownExcel {

  down(fileName, tableData = []){
    const { tableHeader, outMarge } = this;
    let maxLevel = this.maxLevel(tableHeader);
    const mergeInfo = this.resetMergeHeaderInfo(tableHeader, maxLevel, outMarge);
    const lastChild = this.getLastChild(tableHeader);
    const headCsv = this.getHeadCsv(tableHeader, lastChild, maxLevel, mergeInfo);
    const dataCsv = this.getDataCsv(tableData, lastChild);
    const allCsv = this.margeCsv(headCsv, dataCsv);
    
    const cscSeet = this.csv2sheet(allCsv);
    console.log(cscSeet);
    let blob = this.sheet2blob(cscSeet);
    this.openDownloadDialog(blob,`${fileName}.xlsx`);
  }
  
  //  将 csv转换成sheet数据
  csv2sheet(csv) {
    csv = csv.split('~');
    //  缓存
    let arr = [];
    //  剪切未数组
    csv.forEach((el) => {
      //  剪切数据并添加答题arr
      arr.push(el.split("^"));
    });
    //  调用方法
    return XLSX.utils.aoa_to_sheet(arr);
  }
  
  //  sheet转blob文件
  sheet2blob(sheet, sheetName) {
    //  导出文件类型
    sheetName = sheetName || 'sheet1';
    var workbook = {
      SheetNames: [sheetName],
      Sheets: {}
    };
    workbook.Sheets[sheetName] = sheet;
    var wopts = {
      bookType: "xlsx",
      bookSST: false,
      type: 'binary'
    };
    var wbout = XLSX.write(workbook, wopts);
    var blob = new Blob([s2ab(wbout)], {type:"application/octet-stream"});
    // 字符串转ArrayBuffer
    function s2ab(s) {
      var buf = new ArrayBuffer(s.length);
      var view = new Uint8Array(buf);
      for (var i=0; i!=s.length; ++i) view[i] = s.charCodeAt(i) & 0xFF;
      return buf;
    }
    return blob;
  }
  
  // 导出excel
  openDownloadDialog(url, saveName){
    if(typeof url == 'object' && url instanceof Blob){
      url = URL.createObjectURL(url); // 创建blob地址
    }
    var aLink = document.createElement('a');
    aLink.href = url;
    aLink.download = saveName || '';
    var event;
    if(window.MouseEvent) event = new MouseEvent('click');
    else
    {
      event = document.createEvent('MouseEvents');
      event.initMouseEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
    }
    aLink.dispatchEvent(event);
  }
  
}

Csv转换成sheet数据,并把合并信息赋值给sheet就可以就可以通过调用框架的方法转换成blob对象完成最后的数据导出。

完整代码:

import * as XLSX from "xlsx";

export default class DownExcel {

  constructor({ header = [] }) {
    this.tableHeader = header;
    this.outMarge = {
      startCell: -1,
      basisRow: 0,
      basisCell: 0,
      maxRow: 0
    };
  }

  down(fileName, tableData = []){
    const { tableHeader, outMarge } = this;
    let maxLevel = this.maxLevel(tableHeader);
    const mergeInfo = this.resetMergeHeaderInfo(tableHeader, maxLevel, outMarge);
    const lastChild = this.getLastChild(tableHeader);
    const headCsv = this.getHeadCsv(tableHeader, lastChild, maxLevel, mergeInfo);
    const dataCsv = this.getDataCsv(tableData, lastChild);
    const allCsv = this.margeCsv(headCsv, dataCsv);
    const cscSeet = this.csv2sheet(allCsv);
    cscSeet['!merges'] = mergeInfo;
    console.log(cscSeet);
    let blob = this.sheet2blob(cscSeet);
    this.openDownloadDialog(blob,`${fileName}.xlsx`);
  }

  //  sheet转blob文件
  sheet2blob(sheet, sheetName) {
    //  导出文件类型
    sheetName = sheetName || 'sheet1';
    var workbook = {
      SheetNames: [sheetName],
      Sheets: {}
    };
    workbook.Sheets[sheetName] = sheet;
    var wopts = {
      bookType: "xlsx",
      bookSST: false,
      type: 'binary'
    };
    var wbout = XLSX.write(workbook, wopts);
    var blob = new Blob([s2ab(wbout)], {type:"application/octet-stream"});
    // 字符串转ArrayBuffer
    function s2ab(s) {
      var buf = new ArrayBuffer(s.length);
      var view = new Uint8Array(buf);
      for (var i=0; i!=s.length; ++i) view[i] = s.charCodeAt(i) & 0xFF;
      return buf;
    }
    return blob;
  }

  //  导出Excel
  openDownloadDialog(url, saveName){
    if(typeof url == 'object' && url instanceof Blob){
      url = URL.createObjectURL(url); // 创建blob地址
    }
    var aLink = document.createElement('a');
    aLink.href = url;
    aLink.download = saveName || '';
    var event;
    if(window.MouseEvent) event = new MouseEvent('click');
    else
    {
      event = document.createEvent('MouseEvents');
      event.initMouseEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
    }
    aLink.dispatchEvent(event);
  }

  //  获取data的Csv
  getDataCsv(data, lastChild){
    let result = [];
    for(let j = 0, ele; ele = data[j++];){
      let value = [];
      for(let i = 0, item; item = lastChild[i++];){
        value.push(ele[item.field] || "-");
      };
      result.push(value);
    }
    result = result.map((el) => {
      return el.join("^");
    });
    return result.join("~");
  }

  //  将数据转换成Csv格式
  getHeadCsv(tableHeader, lastChild, maxLevel, mergeInfo){
    let csvArr = [];
    let csv = "";
    for(let i = 0; i < (maxLevel + 1); i++){
      let item = [];
      for(let j = 0; j < lastChild.length; j++){
        item.push(null);
      }
      csvArr.push(item);
    }
    for(let i = 0; i < mergeInfo.length; i++){
      let info = mergeInfo[i];
      const { s, item } = info;
      const { c, r } = s;
      const { title } = item;
      csvArr[r][c] = title;
      console.log(mergeInfo);
    }
    csvArr = csvArr.map((el) => {
      return el.join("^");
    });
    return csvArr.join("~");
  }

  //  合并Csv
  margeCsv(headCsv, dataCsv){
    return `${headCsv}~${dataCsv}`;
  }

  //  将 csv转换成sheet数据
  csv2sheet(csv) {
    csv = csv.split('~');
    //  缓存
    let arr = [];
    //  剪切未数组
    csv.forEach((el) => {
      //  剪切数据并添加答题arr
      arr.push(el.split("^"));
    });
    //  调用方法
    return XLSX.utils.aoa_to_sheet(arr);
  }

  resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
    this.tagHeadIn();
    this.tagMaxLevel(tableHeader);
    for(let i = 0; i < tableHeader.length; i++){
      let item = tableHeader[i];
      //  纵向跨度
      const { maxLen } = item;
      //  横向跨度
      let lastChild = this.getLastChild(item.children || []);
      //  s :  开始  e : 结束
      //  c : 列(横向)  
      //  r : 行(纵向)
      let s = {};
      let e = {};
      if(!item.children){
        if(item.isOut){
          outMarge.startCell += 1;
          outMarge.basisCell += 1;
          s.r = 0;
          e.r = maxLevel;
          s.c = outMarge.startCell;
          e.c = outMarge.startCell;
          result.push({ s, e, item });
        }
        else{
          let r = maxLevel - (outMarge.basisRow + maxLen);
          r = Math.max(r, 0);
          s.c = outMarge.basisCell;
          e.c = outMarge.basisCell;
          s.r = outMarge.basisRow;
          e.r = r + outMarge.basisRow + maxLen;
          result.push({ s, e, item });
          outMarge.basisCell += 1;
        }
      };
      if(item.children){
        if(item.isOut){
          s.r = 0;
          e.r = 0;
          outMarge.startCell += 1;
          s.c = outMarge.startCell;
          outMarge.startCell += lastChild.length - 1;
          e.c = outMarge.startCell;
          result.push({ s, e, item });
        }else{
          s.c = outMarge.basisCell;
          e.c = outMarge.basisCell + lastChild.length - 1;
          s.r = outMarge.basisRow;
          e.r = outMarge.basisRow;
          result.push({ s, e, item });
        }
        outMarge.basisRow += 1;
        this.resetMergeHeaderInfo(item.children, maxLevel, outMarge, result);
      };
    };
    outMarge.basisRow -= 1;
    return result;
  }

  tagHeadIn(){
    const { tableHeader } = this;
    tableHeader.forEach((el) => {
      el.isOut = true;
      return el;
    })
  }

  //  标记最大层级
  tagMaxLevel(tableHeader){
    const maxLevel = this.maxLevel(tableHeader, false);
    tableHeader.forEach((el) => {
      if(!el.children){
        el.maxLen = maxLevel;
      }
      else{
        this.tagMaxLevel(el.children);
        el.maxLen = maxLevel;
      }
    });
  }

  //  获取最大层级
  //  只包含子级最大层级(不包含本级)
  maxLevel(arr, isSetFloor = true){
    let floor = -1;
    let max = -1;
    function each (data, floor) {
      data.forEach(e => {
        max = Math.max(floor, max);
        isSetFloor && (e.floor = (floor + 1));
        if (e.children) {
          each(e.children, floor + 1)
        }
      })
    }
    each(arr,0)
    return max;
  }

  //  获取当前下面所有子级
  //  即:表头横向跨度单元格数量
  getLastChild (arr, result = []){
    for(let i = 0,item; item = arr[i++];){
      if(!item.children){
        result.push(item);
      }else{
        result = this.getLastChild(item.children, result);
      }
    }
    return result;
  }

};

使用:

new DownExcel({
  header:  [{
    field: "c1",
    title: "c111",
    children: [{
      field: "c2",
      title: "c222"
    },{
      field: "c3",
      title: "c333"
    },{
      field: "c4",
      title: "c444"
    }]
  }]
}).down(`${+new Date()}`,[{
  a111: "LLL",
  c1: "HHH",
  c2: "AAA",
  a444: "III"
}]);

感谢大家花费很长时间来阅读这篇文章,若文章中有错误灰常感谢大家提出指正,我会尽快做出修改的。如果大家喜欢的话接下来会更新一下,导出带入样式并且支持多级表头。

阅读 656

Easy life, happy elimination of bugs.

3.9k 声望
6.1k 粉丝
0 条评论

Easy life, happy elimination of bugs.

3.9k 声望
6.1k 粉丝
文章目录
宣传栏