Luckysheet开发Excel协同编辑

Luckysheet是一款开源的Excel编辑器,同时也支持协同编辑,官方案例是用java写的,现在我要用node.js实现一版简易的协同编辑功能。

先看效果:

7月7日 (1)

源码在这里:

文档协同编辑系统

一、客户端

从github fork Luckysheet源码,打包,然后引入到项目中

<!-- luck sheet -->
<link rel='stylesheet' href='./lucksheet/plugins/css/pluginsCss.css' />
<link rel='stylesheet' href='./lucksheet/plugins/plugins.css' />
<link rel='stylesheet' href='./lucksheet/css/luckysheet.css' />
<link rel='stylesheet' href='./lucksheet/assets/iconfont/iconfont.css' />
<script src="./lucksheet/plugins/js/plugin.js"></script>
<script src="./lucksheet/luckysheet.umd.js"></script>

初始化

var options = {
  container: 'excelWrapper', //luckysheet为容器id
  allowUpdate: true,
  updateUrl: `${this.serverUrl}?fileId=${this.fileId}&userId=${this.userId}&userName=${this.userName}`,
  loadUrl: `${this.serverUrl}/api/get-json/${this.fileId}`,
}
luckysheet.create(options)

根据官方文档描述,在初始化时,allowUpdate、updateUrl、loadUrl三个都传值,才可以开启Luckysheet的协同编辑功能,所以初始化时我分别赋值了webSocket地址和请求地址,updateUrl是协同消息的webSocket地址,loadUrl是加载数据的地址。

webSocket地址中传参fileId、userId、userName,这些在后台中会用到,作用是区分ws消息推送给哪些用户和编辑的是否是同一个文件。

监听表格更新

Luckysheet提供了一些钩子函数,在这里,我用到了updated 来监听表格的更新,用sheetActivate来监听sheet页的切换。因为当一个用户新增sheet页时,另一个用户虽然显示有新的sheet页,可是如果不点击进去,是无法实时更新到其他用户编辑内容的。所以需要监听切换sheet页后,需要获取存储在服务端的当前最新的数据,并刷新当前打开的sheet页。

var options = {
  container: 'excelWrapper', //luckysheet为容器id
  allowUpdate: true,
  updateUrl: `${this.serverUrl}?fileId=${this.fileId}&userId=${this.userId}&userName=${this.userName}`,
  loadUrl: `${this.serverUrl}/api/get-json/${this.fileId}`,
  hook: {
    updated () {
      self_.upDateSheets()
    },
    sheetActivate() {
      axios.post(`${self_.serverUrl}/api/get-json/${self_.fileId}`).then(res => {
        luckysheet.updataSheet({ data: res.data, success: () => {}})
      })
    }
  }
}
luckysheet.create(options)

至此,协同编辑前端核心代码就是这样

表格导入

用官方提供的插件luckyexcel就可以实现

LuckyExcel.transformExcelToLucky(file, (exportJson, luckysheetfile) => {
  var options = {
    container: 'excelWrapper', //luckysheet为容器id
    data: exportJson.sheets,
    title: exportJson.info.name,
    userInfo: exportJson.info.name.creator,
  }
  luckysheet.create(options)
})

表格导出

使用第三方插件exceljs和file-saver可实现

import FileSaver from "file-saver";
const Excel = require("exceljs");
 
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的情况
 
    if (elem.rangeType === "range") {
      const border = borderConvert(elem.borderType, elem.style, elem.color);
      const rang = elem.range[0];
      // console.log('range', rang)
      const row = rang.row;
      const 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") {
      const { col_index, row_index } = elem.value;
 
      const borderData = Object.assign({}, elem.value);
      delete borderData.col_index;
      delete borderData.row_index;
 
      const border = addborderToCell(borderData, row_index, col_index);
      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
  });
};
 
/**
 * 转换日期为Excel序列号
 * @param {string|Date} dateValue - 日期值
 * @returns {number} Excel序列号
 */
const convertToExcelDate = (dateValue) => {
  if (!dateValue) return null;
 
  let date;
  if (dateValue instanceof Date) {
    date = dateValue;
  } else if (typeof dateValue === "string") {
    // 处理日期字符串,例如 "2025/1/20"
    date = new Date(dateValue);
  } else {
    return dateValue; // 如果不是日期则返回原值
  }
 
  if (isNaN(date.getTime())) {
    return dateValue; // 如果转换失败则返回原值
  }
 
  // Excel的起始日期是1900年1月1日
  const start = new Date(1900, 0, 1);
  const diff = date - start;
  const oneDay = 1000 * 60 * 60 * 24;
  const excelDate = diff / oneDay + 1;
 
  return excelDate;
};
 
/**
 * 判断是否为纯数字
 * @param {string} value - 要判断的值
 * @returns {boolean} 是否为纯数字
 */
const isNumeric = (value) => {
  if (typeof value !== "string") return false;
  return !isNaN(value) && !isNaN(parseFloat(value));
}
 
/**
 * 设置样式和值
 */
const setStyleAndValue = (cellArr, worksheet) => {
  if (!Array.isArray(cellArr)) return;
 
  cellArr.forEach((row, rowid) => {
    row.forEach((cell, columnid) => {
      if (!cell) return;
 
      const fill = fillConvert(cell.bg);
 
      const font = fontConvert(
        cell.ff,
        cell.fc,
        cell.bl,
        cell.it,
        cell.fs,
        cell.cl,
        cell.ul
      );
      const alignment = alignmentConvert(cell.vt, cell.ht, cell.tb, cell.tr);
 
      let value = "";
      if (cell.f) {
        // 尝试新的公式处理方式
        const formulaStr = cell.f.trim();
        value = { 
          formula: formulaStr.startsWith('=') ? formulaStr.substring(1) : formulaStr,
          result: cell.v,
          // date1904: false  // 添加这个属性试试
        }
      } else if (!cell.v && cell.ct && cell.ct.s) {
        value = cell.ct.s.reduce((acc, curr) => acc + curr.v, "");
      } else {
        // 处理时间格式
        if (
          cell.ct &&
          cell.ct.fa &&
          (cell.ct.fa.includes("m") ||
            cell.ct.fa.includes("d") ||
            cell.ct.fa.includes("y"))
        ) {
          // 如果是日期字符串,转换为Excel序列号
          const excelDate = convertToExcelDate(cell.v);
          if (excelDate !== null) {
            value = excelDate;
            worksheet.getCell(rowid + 1, columnid + 1).numFmt = 'yyyy-mm-dd';
          } else {
            value = cell.v;
          }
        } else {
          value = cell.v;
        }
      }
 
      if (value !== null) {
        // 只在 value 不为 null 时设置单元格值
        const letter = createCellPos(columnid);
        const target = worksheet.getCell(letter + (rowid + 1));
 
        if (Object.keys(fill).length > 0) {
          target.fill = fill;
        }
        target.font = font;
        target.alignment = alignment;
        if (value !== undefined && value !== null) {
      
          // 判断是否是数字 设置为数字格式
          target.value = isNumeric(value) ? value * 1 : value;
          // target.value = value
        }
      }
    });
  });
};
 
var setImages = function (imagesArr, worksheet, workbook) {
  if (typeof imagesArr !== "object") return;
  for (const 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('#', '')
  const 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;
    },
  };
  // 出现Bug,导入的时候ff为luckyToExcel的val
 
  let colorValue = fc;
  if (fc.indexOf('rgb') !== -1) {
    // 处理 rgb 格式
    const rgb = fc.match(/\d+/g);
    if (rgb && rgb.length >= 3) {
      const [r, g, b] = rgb;
      colorValue = ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
    }
  } else {
    // 处理十六进制格式
    colorValue = fc.replace("#", "");
  }
 
  const font = {
    name: typeof ff === "number" ? luckyToExcel[ff] : ff,
    family: 1,
    size: fs,
    color: { argb: 'FF' + colorValue },
    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,
    },
  };
 
  const 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",
    },
  };
  const template = {
    style: luckyToExcel.style[style],
    color: { argb: color.replace("#", "") },
  };
  const 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) {
  const 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) {
  const ordA = "A".charCodeAt(0);
 
  const ordZ = "Z".charCodeAt(0);
  const len = ordZ - ordA + 1;
  let s = "";
  while (n >= 0) {
    s = String.fromCharCode((n % len) + ordA) + s;
 
    n = Math.floor(n / len) - 1;
  }
  return s;
}
 
/**
 * 列宽
 * @param columnWidth
 * @param worksheet
 */
var setColumnWidth = function (columnWidth, worksheet) {
  for (let key in columnWidth) {
    worksheet.getColumn(parseInt(key) + 1).width = columnWidth[key] / 7.5;
  }
};
 
/**
 * 行高
 * @param rowHeight
 * @param worksheet
 * @param excelType
 */
var setRowHeight = function (rowHeight, worksheet) {
  for (let key in rowHeight) {
    worksheet.getRow(parseInt(key) + 1).height = rowHeight[key] * 0.75;
  }
};
 
export var exportExcel = async function (luckysheet, value, isDown = false) {
  const workbook = new Excel.Workbook();
 
  luckysheet.forEach(function (table) {
    // 深拷贝数据以避免修改原始数据
    const tableData = JSON.parse(JSON.stringify(table.data));
    
    // 移除预处理公式的代码,让 Excel 自己处理公式格式
    if (tableData.length === 0) return true;
    
    const worksheet = workbook.addWorksheet(table.name);
    const borderInfo = (table.config && table.config.borderInfo) || {};
    const columnWidth = (table.config && table.config.columnlen) || {}; //列宽
    const rowHeight = (table.config && table.config.rowlen) || {}; //行高
 
    // 3. 设置单元格合并, 边框, 样式, 值, 图片
    setColumnWidth(columnWidth, worksheet);
    //行高设置50导出后在ms-excel中打开显示25,在wps-excel中打开显示50这个bug不会修复
    setRowHeight(rowHeight, worksheet);
 
    setMerge((table.config && table.config.merge) || {}, worksheet);
    setBorder(borderInfo, worksheet);
    setStyleAndValue(tableData, worksheet);
    setImages(table.images, worksheet, workbook);
 
    return true;
  });
 
  let data = await workbook.xlsx.writeBuffer();
 
  const blob = new Blob([data], {
    type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8",
  });
 
  // 创建File对象
  const file = new File([blob], `${value}`, {
    type: blob.type,
    lastModified: new Date().getTime(),
  });
 
  if (isDown) return file;
 
  console.log("导出成功!");
  FileSaver.saveAs(blob, `${value}`);
 
  return workbook.xlsx.writeBuffer();
};

二、服务端

使用express进行开发

还记得前边写的,Luckysheet初始化时,updateUrl、loadUrl需要传参吗?

所以在服务端我们最主要就是开发两个接口:1、获取表格数据 2、websocket接口

在这里着重讲解websocket接口

获取表格数据接口

定义一个接口,可以返回Luckysheet所需格式即可

websocket接口

Luckysheet在协同编辑时,向后台推送的数据是经过pako压缩的,所以首先需要定义一个解压数据的方法

const pako = require('pako')
function unzip (str) {
  let chartData = str.toString().split("").map((i) => i.charCodeAt(0));
  let binData = new Uint8Array(chartData);
  let data = '';
  try {
    data = pako.inflate(binData);
  } catch (error) {}
  return decodeURIComponent(
    String.fromCharCode.apply(null, new Uint16Array(data))
  )
}

通过查看Luckysheet源码(假如你看过源码),我们知道了推送ws消息Luckysheet需要如下格式的数据,现在我们定一个返回数据结构体的方法

function callbackdata(user, data, type) {
  return JSON.stringify({
    createTime: moment().format("YYYY-MM-DD HH:mm:ss"),
    data,
    id: user.id,
    returnMessage: "success",
    status: 0,
    type,
    username: user.name,
  });
}

客户端在使用Luckysheet编辑表格时,会往服务端推送一些带有指令的数据

  1. 当 t===“mv” 时,表示用户光标
  2. 当 t==="v" 时,表示更新单元格
  3. 当 t==="sha"时,表示新增sheet页
  4. 还有很多,不再赘述。。

在这里最重要的就是 mv和v,简单做功能的话,我们只要处理他们两个就行。

当服务端通过ws获取到客户端推送的指令后,我们需要给推送给客户端的数据的type中赋值相应的编码即可。

还是通过Luckysheet源码可知:type === ‘3’时,Luckysheet会处理光标。当 type === ‘2’时,会处理数据更新。

所以我们再定义个指令响应的函数,为数据结构函数callbackdata赋值,并将数据推送给客户端:

function wshandle(user, data) {
  this.send(callbackdata(user, data, JSON.parse(data).t === "mv" ? 3 : 2));
}

下面是服务端建立websocket时处理消息广播的代码,比较简单,就不多讲了:

const wss = new WebSocket.Server({ server });
wss.on('connection', (ws, req) => {
  const userId = new URL(req.url, 'http://xxx.com').searchParams.get('userId');
  const userName = new URL(req.url, 'http://xxx.com').searchParams.get('userName');
  const fileId = new URL(req.url, 'http://xxx.com').searchParams.get('fileId');
  ws.wid = userId;
  ws.wname = userName;
  ws.fileid = fileId;
  ws.on('message', (data) => {
    if (data !== 'rub') {
      // 广播消息
      wss.clients.forEach((conn) => {
        if (conn.fileid !== ws.fileid) return; // 如果与我当前操作文件不一致,则不发送消息给你
        if (conn.wid === ws.wid) return; // 如果我操作的是自己,则不发送消息给你
        // 使得 this 指向当前连接对象 ,并且保证,操作对象始终是当前用户
        try {
          wshandle.call(conn, { id: ws.wid, name: ws.wname }, unzip(data));
        } catch (error) {}
      });
    }
  });
  ws.on('close', () => {
    console.log('WebSocket连接已关闭');
  });
});

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值