Luckysheet 的使用以及踩坑

最近在项目中遇到了需要实现一个类似于excel功能的需求,同时需要实现excel的导入导出以及打印功能。接下来介绍一下使用luckysheet的过程以及过程中遇到的一些问题以及解决方案。

1. 使用

1.1 引入

lucky是一个比较老的项目,所以引入方式建议使用全局方式引入。虽然也有npm包,但是在使用的时候并不能如愿。

CDN引入

类似于jQuery一样,lucky需要使用全局引入的方式进行引入,如果使用的是框架,例如vue,可以在publicindex.html中进行一引入。这样会产生一个全局的luckysheet对象。

<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/plugins/css/pluginsCss.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/plugins/plugins.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/css/luckysheet.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/assets/iconfont/iconfont.css' />
<script src="https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/plugins/js/plugin.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/luckysheet.umd.js"></script>

本地引入

在一些内部网络环境或者使用CDN不理想的情况下,可以使用本地资源包的方式引入

<link rel="stylesheet" href="static/luckysheet/dists/plugins/css/pluginsCss.css" />
<link rel="stylesheet" href="static/luckysheet/dists/plugins/plugins.css" />
<link rel="stylesheet" href="static/luckysheet/dists/css/luckysheet.css" />
<link rel="stylesheet" href="static/luckysheet/dists/assets/iconfont/iconfont.css" />
<script src="static/luckysheet/dists/plugins/js/plugin.js"></script>
<script src="static/luckysheet/dists/luckysheet.umd.js"></script>

静态资源包可以下载源码之后进行打包生成,也可以直接寻找资源下载。这里提供一个CDN下载地址https://www.jsdelivr.com/package/npm/luckysheet。当然如果有对源码修改的需求(后面会说到),就得必须使用源码打包的方式了。

1.2 使用

luckysheet需要一个容器进行表格的创建。所以需要在代码中设置一个容器

<div id="luckysheet" style="margin:0px;padding:0px;position:absolute;width:100%;height:100%;left: 0px;top: 0px;"></div>

创建表格的时候需要在配置中指定容器

 var options = {
     container: 'luckysheet' //luckysheet为容器id
}

在引入luckysheet资源文件之后,会在全局产生一个luckysheet对象,使用这个对象即可创建表格

window.luckysheet.create(this.options);

这里的option表示配置参数,具体可以参考配置文档:https://dream-num.github.io/LuckysheetDocs/zh/guide/

概念

一个完整的Luckysheet表格文件的数据格式为:luckysheetfile,一个表格文件包含若干个sheet文件,对应excel的sheet0、sheet1等。

一个Luckysheet文件的示例如下,该表格包含3个sheet:luckysheetfile = [ {sheet1设置}, {sheet2设置}, {sheet3设置} ] 相当于excel的3个sheet

image-20240710110440204

文件中的一个sheet的数据luckysheetfile[0]的结构如下:

{
	"name": "Cell", //工作表名称
	"color": "", //工作表颜色
	"index": "0", //工作表索引
	"status": "1", //激活状态
	"order": "0", //工作表的顺序
	"hide": 0,//是否隐藏
	"row": 36, //行数
	"column": 18, //列数
	"config": {
		"merge":{}, //合并单元格
		"rowlen":{}, //表格行高
		"columnlen":{}, //表格列宽
		"rowhidden":{}, //隐藏行
		"colhidden":{}, //隐藏列
		"borderInfo":{}, //边框
	},
	"celldata": [], //初始化使用的单元格数据
	"data": [], //更新和存储使用的单元格数据
	"scrollLeft": 0, //左右滚动条位置
	"scrollTop": 315, //上下滚动条位置
	"luckysheet_select_save": [], //选中的区域
	"luckysheet_conditionformat_save": {},//条件格式
	"calcChain": [],//公式链
	"isPivotTable":false,//是否数据透视表
	"pivotTable":{},//数据透视表设置
	"filter_select": {},//筛选范围
	"filter": null,//筛选配置
	"luckysheet_alternateformat_save": [], //交替颜色
	"luckysheet_alternateformat_save_modelCustom": [], //自定义交替颜色	
	"freezen": {}, //冻结行列
	"chart": [], //图表配置
	"visibledatarow": [], //所有行的位置
	"visibledatacolumn": [], //所有列的位置
	"ch_width": 2322, //工作表区域的宽度
	"rh_height": 949, //工作表区域的高度
	"load": "1", //已加载过此sheet的标识
}

以上就是基本使用方式了,接下来为扩展实现的功能

2. 权限编辑

需求是有些人具有编辑权限,有些人只有查看权限,这个实现起来比较简单。根据权限设置option中的allowEdit的值。然后再进行表格创建即可。具体来说就是具有权限的人员设置allowEdit为true。不具有权限的人员设置为false。当然在实现方式上也可以默认所有人不可编辑,然后使用按钮权限的方式进行控制,比如可以给有权限的人显示一个编辑按钮。没有权限的人不显示。

 <el-button type="warning" icon="icon-baocun" :loading="false" @click="openEdit" v-if="!options.allowEdit">编辑</el-button>
 <el-button type="warning" icon="icon-baocun" :loading="false" @click="saveData" v-else>保存</el-button>
// 开启编辑
openEdit() {
  this.options.allowEdit = true;
  window.luckysheet.create(this.options);
},

3.导入

本地导入关键代码,这里使用的是element的文件上传组件,使用luckyexcel 读取文件内容。

安装

npm install luckyexcel 

引入

import LuckyExcel from 'luckyexcel';

上传交互

<el-button type="primary" icon="jy-icon-daoru" :loading="false" @click="openUpDialog">本地导入</el-button>
<el-dialog title="提示" :visible.sync="dialogVisible" width="30%" class="dialog">
  <el-upload class="upload-demo" action="" :on-change="fileChange" drag accept=".xlsx" style="width: 100%" :auto-upload="false">
    <i class="el-icon-upload"></i>
    <div class="el-upload__text">
      将文件拖到此处,或
      <em>点击上传</em>
    </div>
    <div class="el-upload__tip">只能上传xlsx文件,且不超过500kb</div>
  </el-upload>
  <span slot="footer" class="dialog-footer">
    <el-button type="primary" @click="doImport">确 定</el-button>
    <el-button @click="cancelImport()">取 消</el-button>
  </span>
</el-dialog>

需要将自动上传关闭,然后监听文件变化,

// 打开上传更换弹窗
openUpDialog() {
  this.dialogVisible = true;
},
// 上传文件变化
fileChange(file) {
  this.file = file.raw;
},
// 执行上传确认操作
doImport() {
  if (!this.file) return;
  this.uploadExcel();
  this.dialogVisible = false;
},
// 取消上传
cancelImport() {
  this.file = null;
  this.dialogVisible = false;
},

在确认上传之后获取到上传的文件的内容使用luckyexcel内置apitransformExcelToLucky 转化为JSON数据,然后再进行表格的创建。

// 本地导入
uploadExcel() {
  LuckyExcel.transformExcelToLucky(this.file, (exportJson) => {
    if (exportJson.sheets == null || exportJson.sheets.length == 0) {
      alert('当前仅支持xlsx文件导入!');
      return;
    }
    console.log(exportJson);
    window.luckysheet.destroy();
    this.options.data = exportJson.sheets;
    window.luckysheet.create(this.options);
  });
},

4.导出

导出使用的是exceljs 以及 file-saver 实现的。自行安装这俩个包,然后创建一个export.js 。

import Excel from 'exceljs';
import FileSaver from 'file-saver';

export var exportExcel = function (luckysheet, value) {
  // 1.创建工作簿,可以为工作簿添加属性
  const workbook = new Excel.Workbook();
  // 2.创建表格,第二个参数可以配置创建什么样的工作表
  luckysheet.forEach(function (table) {
    if (table.data.length === 0) return true;
    const worksheet = workbook.addWorksheet(table.name);
    const merge = (table.config && table.config.merge) || {};
    const borderInfo = (table.config && table.config.borderInfo) || {};
    // 3.设置单元格合并,设置单元格边框,设置单元格样式,设置值,导出图片
    setStyleAndValue(table.data, worksheet);
    setMerge(merge, worksheet);
    setBorder(borderInfo, worksheet);
    setImages(table.images, worksheet, workbook);
    return true;
  });

  // 4.写入 buffer
  const buffer = workbook.xlsx.writeBuffer().then((data) => {
    const blob = new Blob([data], {
      type: 'application/vnd.ms-excel;charset=utf-8',
    });
    console.log('导出成功!');
    FileSaver.saveAs(blob, `${value}.xlsx`);
  });
  return buffer;
};

var setMerge = function (luckyMerge = {}, worksheet) {
  const mergearr = Object.values(luckyMerge);
  mergearr.forEach(function (elem) {
    // elem格式:{r: 0, c: 0, rs: 1, cs: 2}
    // 按开始行,开始列,结束行,结束列合并(相当于 K10:M12)
    worksheet.mergeCells(elem.r + 1, elem.c + 1, elem.r + elem.rs, elem.c + elem.cs);
  });
};

var setBorder = function (luckyBorderInfo, worksheet) {
  if (!Array.isArray(luckyBorderInfo)) return;
  // console.log('luckyBorderInfo', luckyBorderInfo)
  luckyBorderInfo.forEach(function (elem) {
    // 现在只兼容到borderType 为range的情况
    // console.log('ele', elem)
    if (elem.rangeType === 'range') {
      let border = borderConvert(elem.borderType, elem.style, elem.color);
      let rang = elem.range[0];
      // console.log('range', rang)
      let row = rang.row;
      let column = rang.column;
      for (let i = row[0] + 1; i < row[1] + 2; i++) {
        for (let y = column[0] + 1; y < column[1] + 2; y++) {
          worksheet.getCell(i, y).border = border;
        }
      }
    }
    if (elem.rangeType === 'cell') {
      // col_index: 2
      // row_index: 1
      // b: {
      //   color: '#d0d4e3'
      //   style: 1
      // }
      const { col_index, row_index } = elem.value;
      const borderData = Object.assign({}, elem.value);
      delete borderData.col_index;
      delete borderData.row_index;
      let border = addborderToCell(borderData, row_index, col_index);
      // console.log('bordre', border, borderData)
      worksheet.getCell(row_index + 1, col_index + 1).border = border;
    }
    // console.log(rang.column_focus + 1, rang.row_focus + 1)
    // worksheet.getCell(rang.row_focus + 1, rang.column_focus + 1).border = border
  });
};
var setStyleAndValue = function (cellArr, worksheet) {
  if (!Array.isArray(cellArr)) return;
  cellArr.forEach(function (row, rowid) {
    row.every(function (cell, columnid) {
      if (!cell) return true;
      let fill = fillConvert(cell.bg);

      let font = fontConvert(cell.ff, cell.fc, cell.bl, cell.it, cell.fs, cell.cl, cell.ul);
      let alignment = alignmentConvert(cell.vt, cell.ht, cell.tb, cell.tr);
      let value = '';

      if (cell.f) {
        value = { formula: cell.f, result: cell.v };
      } else if (!cell.v && cell.ct && cell.ct.s) {
        // xls转为xlsx之后,内部存在不同的格式,都会进到富文本里,即值不存在与cell.v,而是存在于cell.ct.s之后
        // value = cell.ct.s[0].v
        cell.ct.s.forEach((arr) => {
          value += arr.v;
        });
      } else {
        value = cell.v;
      }
      //  style 填入到_value中可以实现填充色
      let letter = createCellPos(columnid);
      let target = worksheet.getCell(letter + (rowid + 1));
      // console.log('1233', letter + (rowid + 1))
      for (const key in fill) {
        target.fill = fill;
        break;
      }
      target.font = font;
      target.alignment = alignment;
      target.value = value;

      return true;
    });
  });
};

var setImages = function (imagesArr, worksheet, workbook) {
  if (typeof imagesArr != 'object') return;
  for (let key in imagesArr) {
    // console.log(imagesArr[key]);
    // 通过 base64  将图像添加到工作簿
    const myBase64Image = imagesArr[key].src;
    //开始行 开始列 结束行 结束列
    const start = { col: imagesArr[key].fromCol, row: imagesArr[key].fromRow };
    const end = { col: imagesArr[key].toCol, row: imagesArr[key].toRow };
    const imageId = workbook.addImage({
      base64: myBase64Image,
      extension: 'png',
    });
    worksheet.addImage(imageId, {
      tl: start,
      br: end,
      editAs: 'oneCell',
    });
  }
};

var fillConvert = function (bg) {
  if (!bg) {
    return {};
  }
  // const bgc = bg.replace('#', '')
  let fill = {
    type: 'pattern',
    pattern: 'solid',
    fgColor: { argb: bg.replace('#', '') },
  };
  return fill;
};

var fontConvert = function (ff = 0, fc = '#000000', bl = 0, it = 0, fs = 10, cl = 0, ul = 0) {
  // luckysheet:ff(样式), fc(颜色), bl(粗体), it(斜体), fs(大小), cl(删除线), ul(下划线)
  const luckyToExcel = {
    0: '微软雅黑',
    1: '宋体(Song)',
    2: '黑体(ST Heiti)',
    3: '楷体(ST Kaiti)',
    4: '仿宋(ST FangSong)',
    5: '新宋体(ST Song)',
    6: '华文新魏',
    7: '华文行楷',
    8: '华文隶书',
    9: 'Arial',
    10: 'Times New Roman ',
    11: 'Tahoma ',
    12: 'Verdana',
    num2bl: function (num) {
      return num === 0 ? false : true;
    },
  };
  // 出现Bug,导入的时候ff为luckyToExcel的val

  let font = {
    name: typeof ff === 'number' ? luckyToExcel[ff] : ff,
    family: 1,
    size: fs,
    color: { argb: fc.replace('#', '') },
    bold: luckyToExcel.num2bl(bl),
    italic: luckyToExcel.num2bl(it),
    underline: luckyToExcel.num2bl(ul),
    strike: luckyToExcel.num2bl(cl),
  };

  return font;
};

var alignmentConvert = function (vt = 'default', ht = 'default', tb = 'default', tr = 'default') {
  // luckysheet:vt(垂直), ht(水平), tb(换行), tr(旋转)
  const luckyToExcel = {
    vertical: {
      0: 'middle',
      1: 'top',
      2: 'bottom',
      default: 'top',
    },
    horizontal: {
      0: 'center',
      1: 'left',
      2: 'right',
      default: 'left',
    },
    wrapText: {
      0: false,
      1: false,
      2: true,
      default: false,
    },
    textRotation: {
      0: 0,
      1: 45,
      2: -45,
      3: 'vertical',
      4: 90,
      5: -90,
      default: 0,
    },
  };

  let alignment = {
    vertical: luckyToExcel.vertical[vt],
    horizontal: luckyToExcel.horizontal[ht],
    wrapText: luckyToExcel.wrapText[tb],
    textRotation: luckyToExcel.textRotation[tr],
  };
  return alignment;
};

var borderConvert = function (borderType, style = 1, color = '#000') {
  // 对应luckysheet的config中borderinfo的的参数
  if (!borderType) {
    return {};
  }
  const luckyToExcel = {
    type: {
      'border-all': 'all',
      'border-top': 'top',
      'border-right': 'right',
      'border-bottom': 'bottom',
      'border-left': 'left',
    },
    style: {
      0: 'none',
      1: 'thin',
      2: 'hair',
      3: 'dotted',
      4: 'dashDot', // 'Dashed',
      5: 'dashDot',
      6: 'dashDotDot',
      7: 'double',
      8: 'medium',
      9: 'mediumDashed',
      10: 'mediumDashDot',
      11: 'mediumDashDotDot',
      12: 'slantDashDot',
      13: 'thick',
    },
  };
  let template = {
    style: luckyToExcel.style[style],
    color: { argb: color.replace('#', '') },
  };
  let border = {};
  if (luckyToExcel.type[borderType] === 'all') {
    border['top'] = template;
    border['right'] = template;
    border['bottom'] = template;
    border['left'] = template;
  } else {
    border[luckyToExcel.type[borderType]] = template;
  }
  // console.log('border', border)
  return border;
};

function addborderToCell(borders, row_index, col_index) {
  let border = {};
  const luckyExcel = {
    type: {
      l: 'left',
      r: 'right',
      b: 'bottom',
      t: 'top',
    },
    style: {
      0: 'none',
      1: 'thin',
      2: 'hair',
      3: 'dotted',
      4: 'dashDot', // 'Dashed',
      5: 'dashDot',
      6: 'dashDotDot',
      7: 'double',
      8: 'medium',
      9: 'mediumDashed',
      10: 'mediumDashDot',
      11: 'mediumDashDotDot',
      12: 'slantDashDot',
      13: 'thick',
    },
  };
  // console.log('borders', borders)
  for (const bor in borders) {
    // console.log(bor)
    if (borders[bor].color.indexOf('rgb') === -1) {
      border[luckyExcel.type[bor]] = {
        style: luckyExcel.style[borders[bor].style],
        color: { argb: borders[bor].color.replace('#', '') },
      };
    } else {
      border[luckyExcel.type[bor]] = {
        style: luckyExcel.style[borders[bor].style],
        color: { argb: borders[bor].color },
      };
    }
  }

  return border;
}

function createCellPos(n) {
  let ordA = 'A'.charCodeAt(0);

  let ordZ = 'Z'.charCodeAt(0);
  let len = ordZ - ordA + 1;
  let s = '';
  while (n >= 0) {
    s = String.fromCharCode((n % len) + ordA) + s;

    n = Math.floor(n / len) - 1;
  }
  return s;
}

引入函数对象

import { exportExcel } from '@/util/export';

点击导出按钮执行导出函数

downloadExcel() {
	var time = dayjs(this.date).format('YYYY-MM-DD');
	var type = this.type == 'day' ? '日报' : '旬报';
	exportExcel(luckysheet.getAllSheets(), time + '_' + type);
},

这里补充一下,按钮可以使用定位的方式放在表格的头部。当然也可以使用自定义头部菜单的方式实现。我这里需要突出这几个功能,所以使用的是外置菜单的方式。

image-20240710112824653

还有一些可能不使用的元素可以直接在option的hook里面去掉。

hook: {
  workbookCreateAfter: function () {
    // 删除表格信息栏后侧的两个显示信息
    $('.luckysheet_info_detail_save').html('');
    $('.luckysheet_info_detail_update').html('');
  },
},

5. 打印

luckysheet的打印功能是唯一一个收费项目,也希望大家多多支持开源,可以一次性永久买断这个功能。我这里介绍的是一种折中的实现方式,主要的实现思路是使用luckysheet提供的选区以及截图的方式来间接实现打印功能。这里有两种实现方案。
两种都需要先在文档中创建一个用于实现打印的区域或者说容器

<el-button type="primary" icon="el-icon-printer" :loading="false" @click="printExcel" v-print="'#print_html'">打印</el-button>

<!--    打印内容区域,默认不显示,点击打印后才显示-->
<div id="print-area" style="display: none; position: absolute; z-index: 0; top: 0; width: 100%; height: 100vh; overflow: hidden">
  <div id="print_html" style="text-align: center" ref="printRef"></div>
</div>

然后使用

方案一:模拟点击全选实现所有表格的选中

// 打印实现
printExcel() {
  // 1. 实现全选
  $('#luckysheet-left-top').click();
  // 2. 生成选区的截图
  let src = luckysheet.getScreenshot();
  let $img = `<img src=${src} style="max-width: 90%;" />`;
  this.$nextTick(() => {
    document.querySelector('#print_html').innerHTML = $img;
  });
},

方案二: 使用选区然后再截图

// 打印实现
printExcel() {
  let RowColumn = this.getExcelRowColumn(); // 获取有值的行和列
  console.log(RowColumn);
  RowColumn.column[0] = 0; //因需要打印左边的边框,需重新设置
  luckysheet.setRangeShow(RowColumn); // 进行选区操作
  let src = luckysheet.getScreenshot(); // 生成base64图片
  let $img = `<img src=${src} style="max-width: 90%;" />`;
  this.$nextTick(() => {
    document.querySelector('#print_html').innerHTML = $img;
  });
},

这里有个关键的指令 v-print="'#print_html'" ,所使用的是vue-print-nb 打印插件

vue-print-nb 插件

安装引入插件

vue2

npm install vue-print-nb --save

vue3

npm install vue3-print-nb --save

在main.js中引入

Vue2

import Print from 'vue-print-nb'

Vue.use(Print) 

Vue3

import { createApp } from 'vue'
import App from './App.vue'
import print from 'vue3-print-nb'
const app = createApp(App)
app.use(print)
app.mount('#app')

插件使用

<button v-print="'#printMe'">打印按钮</button>
<div id="printMe">打印区域<div>

6. 踩坑

6.1 dists 文件被git过滤

这个问题应该是小概率问题。遇到这个问题是因为我使用了本地的资源包,但是这个资源包中内容放在dists文件夹中,凑巧的是 .gitignore文件中忽略了所有的dists文件,所以造成团队其他成员在打包之后缺失了这个组件的文件。

image-20240710142234366

解决方案:删除dists这一层文件夹或者去除git过滤dists

6.2 qiankun微应用丢失图标

image-20240710142634760

image-20240710142705726

当使用的项目是qiankun微应用的时候,会造成部分图标丢失的问题,这是因为乾坤会将微应用的css抽取出来做成内联的style样式。但是在luckysheet内部具有相对路径的引入iconfont文件。这样就会造成字体文件丢失。

image-20240710143124435

解决办法: 将这个文件中的所有字体引入换成绝对路径

image-20240710143249569

6.3 打印最左侧与上侧边框丢失

image-20240710143657934

实现方案一:在表格的最左侧添加一列,并将列的宽度设置的足够小。在打印时实现视觉上没有就可以使用第二列的边框作为最左侧的边框,上边框同理

image-20240710143911310

实现方案二:在源码中本身其实具有添加左边与上边的边框的代码,但是由于使用的颜色与边框颜色不同所以显示不正常。对代码进行修改之后再次打包。

源代码中的内容:

image-20240710144211603

修改为

image-20240710160317383

然后再进行打包即可:下载地址: https://gitee.com/cxymds/files/blob/master/luckysheet.zip

posted @ 2024-07-10 16:41  Cxymds  阅读(48)  评论(0编辑  收藏  举报