feat: integrate luckysheet inline and fullscreen

This commit is contained in:
liaibo
2025-11-23 17:22:35 +08:00
parent c8e479aeab
commit 994dd4e67c
529 changed files with 403413 additions and 201 deletions
+2
View File
@@ -0,0 +1,2 @@
# 可能要忽略 deepseek AI 模块
/wwwroot
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
{
"name": "luckysheet-crdt-server",
"version": "0.0.0",
"private": true,
"description": "luckysheet-crdt-server",
"scripts": {
"serve": "tsc && node build/main.js",
"build": "tsc && node scripts/build.js",
"dev": "nodemon -w src/ -e ts --exec \"ts-node src/main.ts\"",
"db": "tsc && node build/Sequelize/synchronization.js"
},
"dependencies": {
"dayjs": "^1.11.13",
"express": "^4.21.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.11.5",
"ollama": "^0.5.13",
"pako": "^2.1.0",
"sequelize": "^6.37.5",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.1",
"@types/pako": "^2.0.3",
"@types/ws": "^8.5.13",
"nodemon": "^3.1.9",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,264 @@
/**
* @author https://gitee.com/wfeng0/luckysheet-crdt
* @description Luckysheet CRDT 协同全功能实现 - Server 服务端打包脚本
* @license Apache 2.0
* @copyright Copyright (c) 2025 https://gitee.com/wfeng0/luckysheet-crdt
* @time 2025年04月25日
*/
const fs = require("fs");
const path = require("path");
const packageJson = require("../package.json");
const rootPath = path.resolve(__dirname, "../");
(async () => {
console.log("1️⃣ Starting build wwwroot directory...");
// 判断当前目录下是否存在 wwwroot 目录
if (fs.existsSync(`${rootPath}/wwwroot`)) {
// 如果存在的话,删除,再创建,确保文件夹内容是最新的
fs.rmSync(`${rootPath}/wwwroot`, { recursive: true });
}
fs.mkdirSync(`${rootPath}/wwwroot`);
console.log("2️⃣ Starting build package.json...");
// 进行后续操作
// 1. 复制 package.json 文件到 wwwroot 目录下(不需要 xxx-lock 文件,服务器上都重新拉依赖)
// 重修修订 scripts 命令
delete packageJson.scripts.build;
delete packageJson.scripts.dev;
delete packageJson.scripts.serve;
// 重修修订 db 命令,取消 tsc 打包
packageJson.scripts.db = "node build/Sequelize/synchronization.js";
packageJson.scripts.start = "node build/main.js";
fs.writeFileSync(`${rootPath}/wwwroot/package.json`, JSON.stringify(JSON.parse(JSON.stringify(packageJson))));
// 2. 复制 build 文件夹到 wwwroot 目录下
fs.cpSync(`${rootPath}/build`, `${rootPath}/wwwroot/build`, {
recursive: true,
});
console.log("3️⃣ Starting build public directory...");
// 3. 复制 public/dist 文件夹到 wwwroot 目录下
fs.cpSync(`${rootPath}/public/dist`, `${rootPath}/wwwroot/public/dist`, {
recursive: true,
});
console.log("4️⃣ Starting build license...");
// 4. 创建打包版本信息文件
const license = `
/**
* @author pushu
* @description Luckysheet CRDT 协同全功能实现 - Server 服务端开源协议
* @license Apache 2.0
* @copyright Copyright (c) 2025 https://gitee.com/wfeng0/luckysheet-crdt
* @buildTime ${new Date().toLocaleString()}
* */
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.`;
// 创建 LICENSE 文件
fs.writeFileSync(`${rootPath}/wwwroot/LICENSE`, license.toString());
console.log("✅️ Successully builded.");
})();
@@ -0,0 +1,70 @@
/**
* Luckysheet Crdt Server Configuration - 统一配置对象
*/
import path from 'path';
import dayjs from 'dayjs';
/**
* HTTP 服务 与 websocket服务 共用一个端口
*/
const SERVER_PORT = 9000;
/**
* 数据库配置对象 - sequelize
* 数据库引擎 'mysql' | 'postgres' | 'sqlite' | 'mariadb' | 'mssql' | 'db2' | 'snowflake' | 'oracle'
* # 选择以下之一(请注意:目前默认为 MySQL 如果使用其他引擎,请自行下载其他的驱动程序):
* npm install --save pg pg-hstore # Postgres
* npm install --save mysql2
* npm install --save mariadb
* npm install --save sqlite3
* npm install --save tedious # Microsoft SQL Server
* npm install --save oracledb # Oracle Database
*/
const SQL_CONFIG = {
port: 3306, // 端口号 3306 3309
host: '127.0.0.1', // localhost or 127.0.0.1
database: 'luckysheet_crdt',
user: 'root',
password: 'root',
logger: true, // 开启日志
enable: true // 是否启用数据库服务
};
/**
* 日志配置对象
*/
const LOGGER_CONFIG = {
filepath: path.resolve(__dirname, '../../logs'),
filename: `luckysheet.${dayjs().format('YYYY-MM-DD')}.log`
};
/**
* 导出演示用 Worker Books Info
*/
const WORKER_BOOK_INFO = {
lang: 'zh',
title: '测试工作簿',
gridKey: 'gridkey_demo'
};
/**
* 导出文件上传 Multer 配置对象
*/
const MULTER_CONFIG = {
single: 'image', // 这个是前端 new FormData() 对象的 key 值,即上传文件的 key 值
dest: path.resolve(__dirname, '../../public/uploads')
};
// 静态资源地址 打包后 web 的入口文件
const ENTRY_URL = path.join(__dirname, '../../public/dist/entry.html');
// 统一导出配置对象
export {
ENTRY_URL,
SQL_CONFIG,
SERVER_PORT,
LOGGER_CONFIG,
MULTER_CONFIG,
WORKER_BOOK_INFO
};
@@ -0,0 +1,406 @@
import {
type ChartType,
type MergeType,
type ImagesType,
type BorderInfoType,
type CellDataItemType,
type WorkerSheetItemType,
CalcChainType,
} from "../../Interface/luckysheet";
import { DB } from "../../Sequelize";
import { getURLQuery } from "../../Utils";
import { logger } from "../../Utils/Logger";
import { Request, Response } from "express";
import { ImageService } from "../../Service/Image";
import { MergeService } from "../../Service/Merge";
import { ChartService } from "../../Service/Chart";
import { CellDataService } from "../../Service/CellData";
import { BorderInfoService } from "../../Service/Border";
import { CalcChainService } from "../../Service/CalcChain";
import { WorkerSheetService } from "../../Service/WorkerSheet";
import { HiddenAndLenService } from "../../Service/HiddenAndLen";
import { CellDataModelType } from "../../Sequelize/Models/CellData";
import { WorkerSheetModelType } from "../../Sequelize/Models/WorkerSheet";
/**
* loadSheetData loadUrl 加载数据
* 协同第一步 : 解析数据库数据,生成 workerbook data 数据
* @returns string
*/
export async function loadSheetData(req: Request, res: Response) {
try {
// 如果数据库没有连接,则直接返回空数组
if (!DB.getConnectState()) {
res.json(getEmptyData());
return;
}
const result: WorkerSheetItemType[] = [];
// 1. 解析用户 URL gridkey 参数 || WORKER_BOOK_INFO gridkey
const gridKey = getURLQuery(req.url, "gridkey");
if (!gridKey) {
res.json({ code: 400, msg: "gridKey 参数缺失" });
return;
}
// 2. 根据 gridKey 查询相关数据,拼接生成 luckysheet 初始数据,进行 luckysheet 初始化
const sheets = await WorkerSheetService.findAllByGridKey(gridKey);
if (!sheets || !sheets.length) {
res.json({ code: 400, msg: "未查询到相关数据" });
return;
}
// 一个工作簿可能有多个工作表
for (let i = 0; i < sheets!.length; i++) {
const item = sheets![i].dataValues;
const worker_sheet_id = item.worker_sheet_id;
// 初始化当前 sheet 页的基础数据
const currentSheetData = getSheetDataTemp(item);
// 3. 查询关联当前 sheet 的 celldata 数据
await parseCellData(worker_sheet_id, currentSheetData);
// 4. 查询关联当前 sheet 的 merge 数据 - 这里不仅要体现在 config 中,还要体现在 celldata.mc 中
await parseMerge(worker_sheet_id, currentSheetData);
// 5. 查新关联当前 sheet 的 border 数据
await parseConfigBorder(worker_sheet_id, currentSheetData);
// 6. 查询关联当前 sheet 的 hidden/rowlen/collen 数据
await parseHiddenAndLen(worker_sheet_id, currentSheetData);
// 7. 查询 chart 数据
await parseCharts(worker_sheet_id, currentSheetData);
// 8. 查询 image 数据
await parseImages(worker_sheet_id, currentSheetData);
// 9. 解析公式链
await parseCalcChain(worker_sheet_id, currentSheetData);
result.push(currentSheetData);
}
// 9. 返回数据
res.json(JSON.stringify(result));
} catch (error) {
logger.error(error);
res.json({ code: 500, msg: "服务异常" });
}
}
/**
* 基础数据模板
*/
function getSheetDataTemp(item: WorkerSheetModelType) {
const currentSheetData: WorkerSheetItemType = {
name: item.name,
index: <string>item.worker_sheet_id, // 注意此字段
status: <number>item.status,
order: <number>item.order,
color: item.color,
hide: Number(item.hide),
celldata: [],
config: {
merge: {}, //合并单元格
rowhidden: {}, //隐藏行
colhidden: {}, //隐藏列
borderInfo: [], //边框
rowlen: {},
columnlen: {},
},
images: {}, //图片
chart: [], //图表配置
calcChain: [], // 公式链
};
return currentSheetData;
}
/**
* 数据库服务不可用,直接返回空模板数据
*/
function getEmptyData() {
return JSON.stringify([
{
name: "Sheet1",
index: "Sheet_Index_Demo",
status: 1,
order: 0,
celldata: [
{
r: 0,
c: 0,
v: {
v: "数据库服务不可用,但不影响协同功能",
m: "数据库服务不可用,但不影响协同功能",
bg: "#ff0000",
fc: "#ffffff",
fs: 12,
ht: 0,
vt: 0,
},
},
],
},
]);
}
/**
* parseCellData 解析 cellData 数据
*/
async function parseCellData(worker_sheet_id: string, currentSheetData: WorkerSheetItemType) {
try {
const result = <CellDataItemType[]>[];
const cellDatas = await CellDataService.getCellData(worker_sheet_id);
cellDatas?.forEach((item) => {
const data = <CellDataModelType>item.dataValues;
// 解析 cellData 生成 luckysheet 初始数据
result.push({
r: data.r,
c: data.c,
v: {
ct: {
fa: <string>data.ctfa,
t: <string>data.ctt,
s: JSON.parse(data.cts || "[]"),
},
v: data.v || "",
m: data.m || "",
bg: data.bg || "#FFFFFF",
ff: data.ff || "",
fc: data.fc || "#000000",
bl: Boolean(data.bl),
it: Boolean(data.it),
fs: data.fs || 10,
cl: Boolean(data.cl),
ht: data.ht || 0,
vt: data.vt || 0,
/**
* 此处的 '' 可能会引起 源码 formula.js xssDeal 解析值时,进入 f 判断
* 源码已修改 => if (cell.f != null || (cell.f && cell.f != ""))
*/
f: data.f || null,
un: Boolean(data.un),
// 文本截断方式 tb textbeak 文本换行 0 截断、1溢出、2 自动换行
tb: Number(data.tb) || 0,
ps: data.ps ? { value: data.ps } : null,
},
});
});
currentSheetData.celldata = result;
return Promise.resolve();
} catch (error) {
logger.error(error);
}
}
/**
* parseMerge 解析合并单元格
*/
async function parseMerge(worker_sheet_id: string, currentSheetData: WorkerSheetItemType) {
try {
const result: MergeType = {};
const merges = await MergeService.findAllMerge(worker_sheet_id);
merges?.forEach((merge) => {
// 拼接 r_c 格式
const { r, c } = merge.dataValues;
result[`${r}_${c}`] = merge.dataValues;
// 配置 celldata mc 属性
const currentMergeCell = currentSheetData.celldata?.find((i) => i.r == r && i.c == c);
if (currentMergeCell) currentMergeCell.v.mc = merge.dataValues;
});
currentSheetData.config.merge = result;
return Promise.resolve();
} catch (error) {
logger.error(error);
}
}
/**
* parseConfigBorder 解析边框
*/
async function parseConfigBorder(worker_sheet_id: string, currentSheetData: WorkerSheetItemType) {
try {
const result = <BorderInfoType[]>[];
const borders = await BorderInfoService.findAllBorder(worker_sheet_id);
borders?.forEach((border) => {
const data = border.dataValues;
// 根据当前数据,生成 config.borderInfo
/**
* 这里有两个类型哈 range cell 需要根据 rangeType 进行判断
*/
const { rangeType, borderType, style, color } = data;
if (rangeType === "cell") {
result.push({
rangeType,
value: {
row_index: data.row_index,
col_index: data.col_index,
l: { style: data.l_style, color: data.l_color },
r: { style: data.r_style, color: data.r_color },
t: { style: data.t_style, color: data.t_color },
b: { style: data.b_style, color: data.b_color },
},
});
} else if (rangeType === "range") {
const baseinfo = { rangeType, borderType, style, color };
const row = <[number, number]>[data.row_start, data.row_end];
const column = <[number, number]>[data.col_start, data.col_end];
result.push({ ...baseinfo, range: [{ row, column }] });
}
});
currentSheetData.config.borderInfo = result;
return Promise.resolve();
} catch (error) {
logger.error(error);
}
}
/**
* 解析隐藏行列和行高列宽
*/
async function parseHiddenAndLen(worker_sheet_id: string, currentSheetData: WorkerSheetItemType) {
try {
const dataArray = await HiddenAndLenService.findConfig(worker_sheet_id);
dataArray?.forEach((item) => {
const data = item.dataValues;
const { config_type, config_index } = data;
const value = Number(data.config_value);
// 解析数据
if (config_type === "rowhidden") {
currentSheetData.config.rowhidden[config_index] = 0;
} else if (config_type === "colhidden") {
currentSheetData.config.colhidden[config_index] = 0;
} else if (config_type === "rowlen") {
currentSheetData.config.rowlen[config_index] = value;
} else if (config_type === "columnlen") {
currentSheetData.config.columnlen[config_index] = value;
}
});
return Promise.resolve();
} catch (error) {
logger.error(error);
}
}
/**
* parseImages 解析图片
*/
async function parseImages(worker_sheet_id: string, currentSheetData: WorkerSheetItemType) {
try {
const result = <{ [key: string]: ImagesType }>{};
const images = await ImageService.findAllImage(worker_sheet_id);
images?.forEach((image) => {
const data = image.dataValues;
result[data.image_key] = {
type: data.image_type, //1移动并调整单元格大小 2移动并且不调整单元格的大小 3不要移动单元格并调整其大小
src: data.image_src, //图片url
inCell: data.in_cell || "", // 所在单元格位置
originWidth: data.image_originWidth,
originHeight: data.image_originHeight,
default: {
width: data.image_default_width,
height: data.image_default_height,
left: data.image_default_left,
top: data.image_default_top,
},
crop: {
width: data.image_crop_width,
height: data.image_crop_height,
offsetLeft: data.image_crop_offsetLeft,
offsetTop: data.image_crop_offsetTop,
},
isFixedPos: data.image_isFixedPos,
fixedLeft: data.image_fixedLeft,
fixedTop: data.image_fixedTop,
border: {
width: data.image_border_width,
radius: data.image_border_radius,
style: data.image_border_style,
color: data.image_border_color,
},
};
});
currentSheetData.images = result;
return Promise.resolve();
} catch (error) {
logger.error(error);
}
}
/**
* parseCharts 解析图表数据
*/
async function parseCharts(worker_sheet_id: string, currentSheetData: WorkerSheetItemType) {
try {
const result: ChartType[] = [];
const charts = await ChartService.findAllChart(worker_sheet_id);
charts?.forEach((chart) => {
const data = chart.dataValues;
result.push({
chartType: data.chartType,
chart_id: data.chart_id,
width: data.width,
height: data.height,
left: data.left,
top: data.top,
sheetIndex: data.worker_sheet_id,
needRangeShow: Boolean(data.needRangeShow),
chartOptions: JSON.parse(data.chartOptions),
});
});
currentSheetData.chart = result;
return Promise.resolve();
} catch (error) {
logger.error(error);
}
}
/**
* parseCalcChain 解析公式连
*/
async function parseCalcChain(worker_sheet_id: string, currentSheetData: WorkerSheetItemType) {
try {
const result: CalcChainType[] = [];
const charts = await CalcChainService.findAll(worker_sheet_id);
charts?.forEach((chart) => {
const data = chart.dataValues;
result.push({
r: data.r,
c: data.c,
index: data.worker_sheet_id,
func: JSON.parse(data.func),
color: "w",
});
});
currentSheetData.calcChain = result;
return Promise.resolve();
} catch (error) {
logger.error(error);
}
}
@@ -0,0 +1,33 @@
import { DB } from "../../Sequelize";
import { Request, Response } from "express";
import { WorkerBookService } from "../../Service/WorkerBook";
/**
* 获取工作簿信息
* @param { string } gridKey 工作簿key
* @returns
*/
export async function getWorkerBook(req: Request, res: Response) {
const gridKey = req.body.gridKey;
if (!gridKey) {
res.status(400).json({ code: 400, msg: "gridKey 参数缺失" });
return;
}
if (!DB.getConnectState()) {
res.json({
code: 200,
msg: "数据库未连接",
data: { lang: "zh", title: "未命名工作簿" },
});
return;
}
// 根据 gridKey 请求 workerbooks 数据
const book = await WorkerBookService.findOne(gridKey, {
attributes: ["gridKey", "title", "lang"],
});
res.json({ code: 200, msg: "ok", data: book });
}
@@ -0,0 +1,44 @@
import fs from "fs";
import multer from "multer";
import { Request, Response } from "express";
import { MULTER_CONFIG } from "../../Config";
// 配置 Multer
const { dest, single } = MULTER_CONFIG;
const upload = multer({ dest }).single(single);
/**
* Luckysheet 自定义图片上传方法
*/
export async function uploadImage(req: Request, res: Response) {
upload(req, res, async () => {
const { file } = req;
// 如果没有解析到 file 对象,则直接返回 400
if (!file) {
res.status(400).json({ code: 400, msg: "请选择文件" });
return;
}
const { filename, originalname } = <Express.Multer.File>file;
/**
* 处理文件路径:
* 将原始路径 Snipaste_2024-10-10_09-46-01.png
* 转换成 85d6d8c593b7239406ce2c13099c6110.png
* 保留后缀,方便用户以静态资源访问
*/
const suffix = originalname.split(".").pop();
const oldpath = `${MULTER_CONFIG.dest}/${filename}`;
const newpath = `${MULTER_CONFIG.dest}/${filename}.${suffix}`;
// 重命名文件
fs.renameSync(oldpath, newpath);
res.json({
code: 200,
msg: "Success to upload.",
url: `/uploads/${filename}.${suffix}`,
});
});
}
@@ -0,0 +1,8 @@
import { ENTRY_URL } from '../../Config';
import { Request, Response } from 'express';
export function initPages(req: Request, res: Response) {
res.setHeader('Content-Type', 'text/html; charset=utf-8').sendFile(
ENTRY_URL
);
}
@@ -0,0 +1,12 @@
import { initPages } from "./Page/index";
import { uploadImage } from "./Luckysheet/uploadImage";
import { loadSheetData } from "./Luckysheet/LoadSheetData";
import { getWorkerBook } from "./Luckysheet/getWorkerBook";
// 统一导出控制层对象
export const Controller = {
initPages,
uploadImage,
loadSheetData,
getWorkerBook,
};
@@ -0,0 +1,115 @@
import { BorderInfoType, CellDataItemType, BASE_CELL_DATA_TYPE } from "./luckysheet";
import { WebSocket } from "ws";
interface CustomWebSocket extends WebSocket {
clientInfo: {
type: string; // 协同服务类型
userid: string;
username: string;
gridkey: string;
};
}
// 协同数据类型定义在这里 - 不同的操作类型会传递不同的 数据结构,这里都需要兼容
type CRDTDataType<T> = {
t: string; // 操作类型
i: string; // 当前sheet的index值,而不是order
r: number; // 行号
c: number; // 列号
v: T;
// RV
range?: {
row: [number, number];
column: [number, number];
};
// cg
k?: "config" | "images" | "name" | "rowhidden" | "colhidden" | "rowlen" | "columnlen" | "borderInfo" | "color";
// Chart
op?: "add" | "xy" | "wh" | "update" | "del";
// drc
rc?: string;
};
// 1. v
type V = {
v: string | number | null | undefined;
m: string | number | null | undefined;
} & BASE_CELL_DATA_TYPE;
// 2. rv
type RV = BASE_CELL_DATA_TYPE[][];
// 3. cg
type CG = {
rangeType: string;
borderType: string;
style: string;
color: string;
range: [
{
row: [number, number];
column: [number, number];
}
];
}[];
type MERGE = {
merge: {
[key: string]: { r: number; c: number; rs: number; cs: number };
};
rowlen: {
[key: string]: number;
};
};
type CHART = {
chartType: string;
chart_id: string;
width: number | string;
height: number | string;
left: number | string;
top: number | string;
needRangeShow: boolean;
chartOptions: string;
};
type SHA = {
name: string;
status: string;
order: number;
index: string;
row: number;
column: number;
celldata?: CellDataItemType[];
copyindex?: string;
config?: {
merge: {
[key: string]: { r: number; c: number; rs: number; cs: number };
};
rowlen: { [key: string]: number };
columnlen: { [key: string]: number };
rowhidden: { [key: string]: 0 };
colhidden: { [key: string]: 0 };
borderInfo: BorderInfoType[];
};
};
// 删除行列
type DRC = {
index: number;
len: number;
};
// 增加行列
type ARC = {
index: number;
len: number;
direction: string;
data: BASE_CELL_DATA_TYPE[][];
};
export { type V, type RV, type CG, type SHA, type DRC, type ARC, type CHART, type MERGE, CustomWebSocket, type CRDTDataType };
@@ -0,0 +1,251 @@
/**
* 应该重新定义 luckysheet 前台/协同的数据类型,不要直接使用 uknown/any 等类型进行定义
*/
/**
* 1. worker book
* 注意哈,这里的类型指的是可存储数据库的后台类型,并非指的是 loadUrl loadSheetUrl 等前台配置哈
*/
type WorkerBookType = {
gridKey: string; // 唯一key
title: string; // 工作簿名称
lang: string; // 语言
column?: number; // 空表格默认的列数量
row?: number; // 空表格默认的行数量
};
// 单个 sheet 页 配置
type WorkerSheetItemType = {
name: string; //工作表名称
index: number | string; //工作表索引
status: number; //激活状态
order: number; //工作表的下标
celldata: CellDataItemType[]; //初始化使用的单元格数据
config: {
merge: MergeType; //合并单元格
rowlen: RowLenType; //表格行高
columnlen: ColumnLenType; //表格列宽
rowhidden: RowHiddenType; //隐藏行
colhidden: ColHiddenType; //隐藏列
borderInfo: BorderInfoType[]; //边框
// authority: AuthorityType; //工作表保护
};
chart: ChartType[]; //图表配置
images: { [key: string]: ImagesType }; //图片
calcChain: CalcChainType[]; //公式链
/**
* 下列属性非必选
*/
color?: string; //工作表颜色
hide?: number; //是否隐藏
row?: number; //行数
column?: number; //列数
defaultRowHeight?: number; //自定义行高
defaultColWidth?: number; //自定义列宽
scrollLeft?: number; //左右滚动条位置
scrollTop?: number; //上下滚动条位置
luckysheet_select_save?: []; //选中的区域
isPivotTable?: false; //是否数据透视表
filter?: []; //筛选配置
luckysheet_alternateformat_save?: []; //交替颜色
luckysheet_alternateformat_save_modelCustom?: []; //自定义交替颜色
zoomRatio?: number; // 缩放比例
showGridLines?: number; //是否显示网格线
// dataVerification: {}; //数据验证配置
// pivotTable: {}; //数据透视表设置
// filter_select: {}; //筛选范围
// luckysheet_conditionformat_save: {}; //条件格式
// frozen: {}; //冻结行列配置
};
// 单个单元格
type CellDataItemType = {
r: number;
c: number;
v: BASE_CELL_DATA_TYPE;
};
// 基础的单元格数据类型
type BASE_CELL_DATA_TYPE = {
ct: {
//单元格值格式
fa: string; //格式名称为自动格式
t: string; //格式类型为数字类型
s?: {
ff: string;
fc: string;
fs: number;
cl: number;
un: number;
bl: number;
it: number;
v: string;
}[];
};
v: string | number; //内容的原始值为 233
m: string | number; //内容的显示值为 233
bg?: string; //背景为 "#f6b26b"
ff?: string; // 字体为 "Arial"
fc?: string; //字体颜色为 "#990000"
bl?: boolean; //字体加粗
it?: boolean; //字体斜体
fs?: number; //字体大小为 9px
cl?: boolean; //启用删除线
un?: boolean; // 启用下划线
ht?: number; //水平居中
vt?: number; //垂直居中
tr?: number; //文字旋转 -45°
tb?: number; //文本自动换行
mc?: {
// 合并单元格
r: number; //主单元格的行号
c: number; //主单元格的列号
rs: number; //合并单元格占的行数
cs: number; //合并单元格占的列数
};
ps?: {
//批注
left?: number; //批注框左边距
top?: number; //批注框上边距
width?: number; //批注框宽度
height?: number; //批注框高度
value: string; //批注内容
isshow?: boolean; //批注框为显示状态
} | null;
f?: string | null; //单元格是一个求和公式 "=SUM(233)"
};
// 合并单元格类型
type MergeType = {
[key: string]: {
r: number;
c: number;
rs: number;
cs: number;
};
};
// 行高类型
type RowLenType = {
[key: string]: number;
};
// 列宽类型
type ColumnLenType = {
[key: string]: number;
};
// 隐藏行类型
type RowHiddenType = {
[key: string]: 0;
};
// 隐藏列类型
type ColHiddenType = {
[key: string]: 0;
};
// 边框类型
type BorderInfoType = {
rangeType: string;
borderType?: string;
style?: string;
color?: string;
range?: [
{
row: [number, number];
column: [number, number];
}
];
// 或者单个单元格 - https://dream-num.github.io/LuckysheetDocs/zh/guide/sheet.html#config-borderinfo
value?: {
row_index?: number;
col_index?: number;
l?: {
style: number;
color: string;
};
t?: {
style: number;
color: string;
};
r?: {
style: number;
color: string;
};
b?: {
style: number;
color: string;
};
};
};
// 图片类型
type ImagesType = {
type: string;
src: string;
originWidth: number;
originHeight: number;
inCell: string;
default: {
width: number;
height: number;
left: number;
top: number;
};
crop: {
width: number;
height: number;
offsetLeft: number;
offsetTop: number;
};
isFixedPos: boolean;
fixedLeft: number;
fixedTop: number;
border: {
width: number;
radius: number;
style: string;
color: string;
};
};
// 统计图表类型
type ChartType = {
chartType: string;
chart_id: string;
width: string | number;
height: string | number;
left: string | number;
top: string | number;
sheetIndex: string;
needRangeShow: boolean;
chartOptions: object;
};
// 公式链操作
type CalcChainType = {
r: number; //行数
c: number; //列数
index: string; //工作表id
func: [boolean, number | string, string]; //公式信息,包含公式计算结果和公式字符串
color: "w"; //"w":采用深度优先算法 "b":普通计算
};
export {
type ChartType,
type MergeType,
type RowLenType,
type ImagesType,
type CalcChainType,
type ColumnLenType,
type RowHiddenType,
type ColHiddenType,
type BorderInfoType,
type WorkerBookType,
type CellDataItemType,
type BASE_CELL_DATA_TYPE,
type WorkerSheetItemType,
};
@@ -0,0 +1,43 @@
import path from "path";
import express from "express";
/**
* 初始化静态资源
*/
function initStaticSource(app: express.Application) {
app.use(express.static(path.resolve(__dirname, "../../public")));
app.use(express.static(path.resolve(__dirname, "../../public/dist")));
app.use(express.static(path.resolve(__dirname, "../../public/uploads")));
app.use(express.static(path.resolve(__dirname, "../../public/dist/assets")));
}
/**
* 处理跨域
*/
function initCors(app: express.Application) {
app.all("*", function (req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Content-Type");
res.header("Access-Control-Allow-Methods", "*");
res.header("Content-Type", "application/json;charset=utf-8");
next();
});
}
/**
* 处理 bodyparser
*/
function initBodyParser(app: express.Application) {
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
}
/**
* 导出注册中间件方法
* @param app
*/
export const initMeddlewear = (app: express.Application) => {
initStaticSource(app);
initCors(app);
initBodyParser(app);
};
@@ -0,0 +1,19 @@
import express from "express";
import { Controller } from "../Controller";
const routes = express.Router();
// 用于处理 html 页面,部署使用
routes.get("/", Controller.initPages);
// 用于上传图片 - 先定义 upload name 属性
routes.post("/uploadImage", Controller.uploadImage);
// 请求 workerbook 数据
routes.post("/getWorkerBook", Controller.getWorkerBook);
// 初始化 luckysheet 数据
routes.post("/loadSheetData", Controller.loadSheetData);
// 模块化的路由,直接调用 routes.use() 即可
export default routes;
@@ -0,0 +1,176 @@
/**
* config border info model 边框数据模型
*/
import { WorkerSheetModel } from "./WorkerSheet";
import { DataTypes, InferAttributes, Model, Sequelize } from "sequelize";
export class BorderInfoModel extends Model {
// 类型定义
declare config_border_id?: string;
declare worker_sheet_id: string;
declare rangeType: string; /** rangeType 合并单元格类型 range | cell */
declare borderType?: string; /** borderType 边框类型 */
declare style?: number; /** style 边框粗细 */
declare color?: string; /** color 边框颜色 */
// 范围
declare row_start?: number;
declare row_end?: number;
declare col_start?: number;
declare col_end?: number;
// 单个单元格
declare row_index?: number;
declare col_index?: number;
declare l_style?: number;
declare l_color?: string;
declare t_style?: number;
declare t_color?: string;
declare r_style?: number;
declare r_color?: string;
declare b_style?: number;
declare b_color?: string;
// 注册模型
static registerModule(sequelize: Sequelize) {
BorderInfoModel.init(
{
config_border_id: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "config_border_id 边框配置表唯一ID", // 描述
primaryKey: true,
defaultValue: DataTypes.UUIDV4, // 默认使用 uuid 作为 主键ID
},
worker_sheet_id: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "外键:关联 workersheets 的 worker_sheet_id", // 描述
references: {
model: WorkerSheetModel,
key: "worker_sheet_id",
},
},
rangeType: {
type: DataTypes.STRING,
allowNull: false,
comment: "rangeType 合并单元格类型 range | cell",
},
// 因此,下列属性需要兼容 range cell 不同类型的合并属性
borderType: {
type: DataTypes.STRING,
allowNull: true,
comment: "borderType 边框类型",
},
style: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "style 边框粗细",
},
color: {
type: DataTypes.STRING,
allowNull: true,
comment: "color 边框颜色",
},
/**
* range 行列信息数组
* "range": [{
* "row": [row_start, roe_end],
* "column": [col_start, col_end]
* }]
*/
row_start: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "row_start 行号",
},
row_end: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "row_end 行号",
},
col_start: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "col_start 列号",
},
col_end: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "col_end 列号",
},
/**
* {
* "row_index":7,
* "col_index":6,
* "l":{"style":1,"color":"#000000"},
* "r":{"style":1,"color":"#000000"},
* "t":{"style":1,"color":"#000000"},
* "b":{"style":1,"color":"#000000"}
* }
*/
row_index: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "row_index 行号",
},
col_index: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "col_index 列号",
},
l_style: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "l_style 边框粗细",
},
l_color: {
type: DataTypes.STRING,
allowNull: true,
comment: "l_color 边框颜色",
},
t_style: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "t_style 边框粗细",
},
t_color: {
type: DataTypes.STRING,
allowNull: true,
comment: "t_color 边框颜色",
},
r_style: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "r_style 边框粗细",
},
r_color: {
type: DataTypes.STRING,
allowNull: true,
comment: "r_color 边框颜色",
},
b_style: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "b_style 边框粗细",
},
b_color: {
type: DataTypes.STRING,
allowNull: true,
comment: "b_color 边框颜色",
},
},
{
sequelize, // 将模型与 Sequelize 实例关联
tableName: "borders", // 直接式提供数据库表名
}
);
}
}
// 导出类型
export type BorderInfoModelType = InferAttributes<BorderInfoModel>;
@@ -0,0 +1,70 @@
/**
* 公式链单独存储
*/
/**
* Config Merges 合并单元格数据模型
*
*/
import { WorkerSheetModel } from "./WorkerSheet";
import { DataTypes, InferAttributes, Model, Sequelize } from "sequelize";
export class CalcChainModel extends Model {
declare calcchain_id?: string;
declare worker_sheet_id: string;
declare r: number;
declare c: number;
declare func: string;
declare pos?: number;
// 注册模型
static registerModule(sequelize: Sequelize) {
CalcChainModel.init(
{
calcchain_id: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "公式连表 id", // 描述
primaryKey: true,
defaultValue: DataTypes.UUIDV4, // 默认使用 uuid 作为 主键ID
},
worker_sheet_id: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "外键:关联 worker_sheet_id, sheet index", // 描述
references: {
model: WorkerSheetModel,
key: "worker_sheet_id",
},
},
r: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "行号",
},
c: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "列号",
},
func: {
type: DataTypes.STRING,
allowNull: false,
comment: "公式",
},
pos: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "公式位置",
},
},
{
sequelize, // 将模型与 Sequelize 实例关联
tableName: "calcchains", // 直接式提供数据库表名
}
);
}
}
// 导出类型
export type CalcChainModelType = InferAttributes<CalcChainModel>;
@@ -0,0 +1,184 @@
/**
* CellData 数据模型
*/
import { WorkerSheetModel } from "./WorkerSheet";
import { DataTypes, InferAttributes, Model, Sequelize } from "sequelize";
/**
* Sequelize 模型是 ES6 类. 你可以非常轻松地添加自定义实例或类级别的方法.
*/
export class CellDataModel extends Model {
// 模型字段
declare cell_data_id?: string;
declare worker_sheet_id: string;
declare r: number;
declare c: number;
declare v?: string;
declare m?: string;
declare ctfa?: string;
declare ctt?: string;
declare cts?: string; // inline str
declare bg?: string;
declare ff?: string;
declare fc?: string;
declare fs?: number;
declare bl?: boolean;
declare it?: boolean;
declare cl?: boolean;
declare un?: boolean;
declare vt?: number;
declare ht?: number;
declare f?: string | null;
declare ps?: string | null;
declare tb?: number; // 文本换行方式
// 都需要导出一个 register 方法,用于注册模型
static registerModule(sequelize: Sequelize) {
CellDataModel.init(
{
cell_data_id: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "cell data id", // 描述
primaryKey: true,
defaultValue: DataTypes.UUIDV4, // 默认使用 uuid 作为 主键ID
},
worker_sheet_id: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "外键:关联 worksheets 的 worker_sheet_id", // 描述
references: {
model: WorkerSheetModel,
key: "worker_sheet_id",
},
},
r: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "行号",
},
c: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "列号",
},
/** ctfa + ctt ==> ct:{fa,t} 这两个字段共同构成 celldata.ct 字段的值,决定了如何显示单元格内容 */
ctfa: {
type: DataTypes.STRING,
allowNull: false,
comment: "ct:{fa} Format格式的定义串,'General'|'@'|'0'|'0.0'....",
defaultValue: "General",
},
ctt: {
type: DataTypes.STRING,
allowNull: false,
comment: "ct:{t} Type类型,'g'|'s'|'n'....",
defaultValue: "g",
},
cts: {
type: DataTypes.STRING,
allowNull: false,
comment: "ct:{s} inlineStr v 内容整体存储",
defaultValue: "",
},
bg: {
type: DataTypes.STRING,
allowNull: true,
comment: "背景色",
defaultValue: "#FFFFFF",
},
ff: {
type: DataTypes.STRING,
allowNull: true,
comment:
"0 Times New Roman、 1 Arial、2 Tahoma 、3 Verdana、4 微软雅黑、5 宋体(Song)、6 黑体(ST Heiti)、7 楷体(ST Kaiti)、 8 仿宋(ST FangSong)、9 新宋体(ST Song)、10 华文新魏、11 华文行楷、12 华文隶书",
defaultValue: "5",
},
fc: {
type: DataTypes.STRING,
allowNull: true,
comment: "字体颜色",
defaultValue: "#000000",
},
fs: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "字体大小",
defaultValue: 10,
},
bl: {
type: DataTypes.BOOLEAN,
allowNull: true,
comment: "是否加粗",
defaultValue: false,
},
it: {
type: DataTypes.BOOLEAN,
allowNull: true,
comment: "是否斜体",
defaultValue: false,
},
cl: {
type: DataTypes.BOOLEAN,
allowNull: true,
comment: "是否删除线",
defaultValue: false,
},
un: {
type: DataTypes.BOOLEAN,
allowNull: true,
comment: "是否下划线",
defaultValue: false,
},
vt: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "垂直居中 0 中间、1 上、2下",
defaultValue: 0,
},
ht: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "水平居中 0 居中、1 左、2右",
defaultValue: 0,
},
tb: {
type: DataTypes.INTEGER,
allowNull: true,
comment: "文本换行方式 0 自动换行、1 单行、2 多行",
defaultValue: 0,
},
// tr rt 不想实现了~ 大家自己拓展其他的数据类型吧
// v m 的取值逻辑由 ct{fa t} 决定
v: {
type: DataTypes.STRING,
allowNull: false,
comment: "原始值",
},
m: {
type: DataTypes.STRING,
allowNull: false,
comment: "显示值",
},
f: {
type: DataTypes.STRING,
allowNull: true,
comment: "公式",
},
ps: {
type: DataTypes.STRING,
allowNull: true,
comment: "批注",
},
},
{
sequelize, // 将模型与 Sequelize 实例关联
tableName: "celldatas", // 直接式提供数据库表名
}
);
}
}
export type CellDataModelType = InferAttributes<CellDataModel>;
@@ -0,0 +1,84 @@
/**
* 统计图
*/
import { Model, Sequelize, DataTypes, InferAttributes } from "sequelize";
import { WorkerSheetModel } from "./WorkerSheet";
export class ChartModel extends Model {
declare chart_id: string;
declare worker_sheet_id: string;
declare chartType: string;
declare width?: number | string;
declare height?: number | string;
declare left?: number | string;
declare top?: number | string;
declare needRangeShow?: boolean; // 目前不需要这个字段
declare chartOptions?: string;
static registerModule(sequelize: Sequelize) {
ChartModel.init(
{
chart_id: {
type: DataTypes.STRING,
primaryKey: true,
allowNull: false,
comment: "表ID",
},
worker_sheet_id: {
type: DataTypes.STRING,
allowNull: false,
comment: "所属工作表ID",
references: {
model: WorkerSheetModel,
key: "worker_sheet_id",
},
},
chartType: {
type: DataTypes.STRING,
allowNull: false,
comment: "图表类型",
defaultValue: "chartmix",
},
width: {
type: DataTypes.STRING,
allowNull: true,
comment: "width",
},
height: {
type: DataTypes.STRING,
allowNull: true,
comment: "height",
},
left: {
type: DataTypes.STRING,
allowNull: true,
comment: "left",
},
top: {
type: DataTypes.STRING,
allowNull: true,
comment: "top",
},
needRangeShow: {
type: DataTypes.BOOLEAN,
allowNull: true,
defaultValue: false,
comment: "needRangeShow",
},
chartOptions: {
type: DataTypes.TEXT,
allowNull: true,
comment: "chartOptions",
},
},
{
sequelize,
tableName: "charts",
}
);
}
}
// 导出类型
export type ChartModelType = InferAttributes<ChartModel>;
@@ -0,0 +1,69 @@
/**
* config row/col hiddens model 配置行列隐藏
*/
import { WorkerSheetModel } from "./WorkerSheet";
import { DataTypes, InferAttributes, Model, Sequelize } from "sequelize";
export class HiddenAndLenModel extends Model {
// 类型定义
declare config_id?: string;
declare worker_sheet_id: string;
declare config_type: string; /** 隐藏类型 row/col */
declare config_index?: string; /** 隐藏的行号/列号 */
declare config_value?: number; /** 定义的行高列宽 */
// 注册模型
static registerModule(sequelize: Sequelize) {
HiddenAndLenModel.init(
{
config_id: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "config_id", // 描述
primaryKey: true,
defaultValue: DataTypes.UUIDV4, // 默认使用 uuid 作为 主键ID
},
worker_sheet_id: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "外键:关联 workersheet 的 worker_sheet_id", // 描述
references: {
model: WorkerSheetModel,
key: "worker_sheet_id",
},
},
config_type: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "隐藏类型 rowhidden/colhidden/rowlen/collen", // 描述
},
/**
* 根据下列字段,生成 下列配置对象
* "rowhidden": {
* "30": 0,
* "31": 0
* }
*/
config_index: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "隐藏的行号/列号", // 描述
},
config_value: {
type: DataTypes.INTEGER, // 类型
allowNull: false, // 非空
comment: "隐藏的行号/列号的长度", // 描述
defaultValue: 0,
},
},
{
sequelize, // 将模型与 Sequelize 实例关联
tableName: "hiddenandlens", // 直接式提供数据库表名
}
);
}
}
// 导出类型
export type HiddenAndLenModelType = InferAttributes<HiddenAndLenModel>;
@@ -0,0 +1,171 @@
/**
* Image 图片
*/
import { WorkerSheetModel } from "./WorkerSheet";
import { Model, Sequelize, DataTypes, InferAttributes } from "sequelize";
export class ImageModel extends Model {
declare image_id?: string;
declare worker_sheet_id: string; // 外键
declare image_type: string; // type 1移动并调整单元格大小 2移动并且不调整单元格的大小 3不要移动单元格并调整其大小
declare image_src: string; // 图片地址
declare image_key: string; // 图片唯一标识
declare in_cell?: string; // 是否在单元格内
declare image_originWidth: number; // 原始宽度
declare image_originHeight: number; // 原始高度
declare image_default_width: number; // 默认宽度
declare image_default_height: number; // 默认高度
declare image_default_left: number; // 默认左边距
declare image_default_top: number; // 默认上边距
declare image_crop_width: number; // 裁剪宽度
declare image_crop_height: number; // 裁剪高度
declare image_crop_offsetLeft: number; // 裁剪左边距
declare image_crop_offsetTop: number; // 裁剪上边距
declare image_isFixedPos: boolean; // 是否固定位置
declare image_fixedLeft: number; // 固定左边距
declare image_fixedTop: number; // 固定上边距
declare image_border_width: number; // 边框宽度
declare image_border_radius: number; // 圆角
declare image_border_style: string; // 边框样式
declare image_border_color: string; // 边框颜色
static registerModule(sequelize: Sequelize) {
ImageModel.init(
{
image_id: {
type: DataTypes.STRING,
primaryKey: true,
allowNull: false,
comment: "图片ID",
defaultValue: DataTypes.UUIDV4,
},
worker_sheet_id: {
type: DataTypes.STRING,
allowNull: false,
comment: "所属工作表ID",
references: {
model: WorkerSheetModel,
key: "worker_sheet_id",
},
},
image_type: {
type: DataTypes.STRING,
allowNull: false,
comment: "图片类型",
},
image_src: {
type: DataTypes.STRING,
allowNull: false,
comment: "图片地址",
},
in_cell: {
type: DataTypes.STRING,
allowNull: false,
comment: "单元格图片标识",
},
image_key: {
type: DataTypes.STRING,
allowNull: false,
comment: "图表标识",
},
image_originWidth: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "原始宽度",
},
image_originHeight: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "原始高度",
},
image_default_width: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "默认宽度",
},
image_default_height: {
type: DataTypes.INTEGER,
allowNull: false,
},
image_default_left: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "默认左边距",
},
image_default_top: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "默认上边距",
},
image_crop_width: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "裁剪宽度",
},
image_crop_height: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "裁剪高度",
},
image_crop_offsetLeft: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "裁剪左边距",
},
image_crop_offsetTop: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "裁剪上边距",
},
image_isFixedPos: {
type: DataTypes.BOOLEAN,
allowNull: false,
comment: "是否固定位置",
},
image_fixedLeft: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "固定左边距",
},
image_fixedTop: {
type: DataTypes.INTEGER,
allowNull: false,
},
image_border_width: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "边框宽度",
},
image_border_radius: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "圆角",
},
image_border_style: {
type: DataTypes.STRING,
allowNull: false,
},
image_border_color: {
type: DataTypes.STRING,
allowNull: false,
comment: "边框颜色",
},
},
{
sequelize,
tableName: "images",
}
);
}
}
// 导出类型
export type ImageModelType = InferAttributes<ImageModel>;
@@ -0,0 +1,77 @@
/**
* Config Merges 合并单元格数据模型
*
*/
import { WorkerSheetModel } from "./WorkerSheet";
import { DataTypes, InferAttributes, Model, Sequelize } from "sequelize";
export class MergeModel extends Model {
declare config_merge_id?: string;
declare worker_sheet_id: string;
declare r: number;
declare c: number;
declare rs: number;
declare cs: number;
// 注册模型
static registerModule(sequelize: Sequelize) {
MergeModel.init(
{
config_merge_id: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "config merge id", // 描述
primaryKey: true,
defaultValue: DataTypes.UUIDV4, // 默认使用 uuid 作为 主键ID
},
worker_sheet_id: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "外键:关联 cell_datas 的 worker_sheet_id", // 描述
references: {
model: WorkerSheetModel,
key: "worker_sheet_id",
},
},
/**
*
* 设置一个合并单元格,需要处理两个地方,一是单元格对象中设置mc属性,二是在config中设置merge
* "r": 0, //主单元格的行号
* "c": 0, //主单元格的列号
* "rs": 2, //合并单元格占的行数
* "cs": 2 //合并单元格占的列数
*
* 业务上的处理逻辑是:先查询这个表,如果有字段值,则反馈生成 celldata mc 属性字段
*/
r: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "r 主单元格的行号",
},
c: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "c 主单元格的列号",
},
rs: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "rs 合并单元格占的行数",
},
cs: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "cs 合并单元格占的列数",
},
},
{
sequelize, // 将模型与 Sequelize 实例关联
tableName: "merges", // 直接式提供数据库表名
}
);
}
}
// 导出类型
export type MergeModelType = InferAttributes<MergeModel>;
@@ -0,0 +1,44 @@
/**
* Worker Books 工作簿模型表
*/
import { DataTypes, InferAttributes, Model, Sequelize } from "sequelize";
export class WorkerBookModel extends Model {
declare gridKey: string; /** gridKey 是用户系统唯一文件ID 必须由用户传递 */
declare title?: string;
declare lang?: string;
static registerModule(sequelize: Sequelize) {
WorkerBookModel.init(
{
gridKey: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "gridKey", // 描述
primaryKey: true, // 主键
},
title: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "工作簿名称", // 描述
defaultValue: "未命名工作簿", // 默认值
},
lang: {
type: DataTypes.STRING(10), // 类型
allowNull: false, // 非空
comment: "语言", // 描述
defaultValue: "zh", // zh en
},
// ... 更多字段,根据项目实际情况添加
},
{
sequelize, // 将模型与 Sequelize 实例关联
tableName: "workerbooks", // 直接式提供数据库表名
}
);
}
}
// 导出类型
export type WorkerBookModelType = InferAttributes<WorkerBookModel>;
@@ -0,0 +1,113 @@
/**
* Worker Books 工作簿模型表
*/
import { WorkerBookModel } from "./WorkerBook";
import { DataTypes, InferAttributes, Model, Sequelize } from "sequelize";
export class WorkerSheetModel extends Model {
declare worker_sheet_id?: string; /** 有默认值,非必传 */
declare gridKey: string;
declare name: string;
declare order?: number;
declare status?: number;
declare hide?: boolean;
declare row?: number;
declare column?: number;
declare color?: string;
declare defaultRowHeight?: number;
declare defaultColWidth?: number;
declare deleteFlag?: boolean; // 是否被删除
static registerModule(sequelize: Sequelize) {
WorkerSheetModel.init(
{
worker_sheet_id: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "worker sheet id", // 描述
primaryKey: true, // 主键
defaultValue: DataTypes.UUIDV4, // 默认使用 uuid 作为 主键ID
},
gridKey: {
type: DataTypes.STRING, // 类型
allowNull: false, // 非空
comment: "外键:关联 workerbooks 的 gridKey", // 描述
// 外键
references: {
model: WorkerBookModel,
key: "gridKey",
},
},
name: {
type: DataTypes.STRING,
allowNull: false,
comment: "工作表名称",
},
order: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "工作表下标序号",
defaultValue: 0,
},
status: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "工作表激活状态,仅有一个激活状态的工作表,其他工作表为 0",
defaultValue: 0,
},
hide: {
type: DataTypes.BOOLEAN,
allowNull: false,
comment: "是否隐藏,0为不隐藏,1为隐藏",
defaultValue: false,
},
row: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "行数", // 描述
defaultValue: 30, // 默认值
},
column: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "列数", // 描述
defaultValue: 24, // 默认值
},
color: {
type: DataTypes.STRING,
allowNull: false,
comment: "表格颜色", // 描述
defaultValue: "", // 默认值
},
defaultRowHeight: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "默认行高",
defaultValue: 20,
},
defaultColWidth: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "默认列宽",
defaultValue: 80,
},
deleteFlag: {
type: DataTypes.BOOLEAN,
allowNull: false,
comment: "是否被删除",
defaultValue: false,
},
// ... 更多字段,根据项目实际情况添加
},
{
sequelize, // 将模型与 Sequelize 实例关联
tableName: "workersheets", // 直接式提供数据库表名
}
);
}
}
// 导出类型
export type WorkerSheetModelType = InferAttributes<WorkerSheetModel>;
@@ -0,0 +1,109 @@
/**
* 使用 sequelzie 操作数据库
* 导出唯一实例,需要提供判断数据库连接是否正常的方法
*/
import { Sequelize } from "sequelize";
import { logger } from "../Utils/Logger";
import { ImageModel } from "./Models/Image";
import { MergeModel } from "./Models/Merge";
import { ChartModel } from "./Models/Chart";
import { SQL_CONFIG } from "../Config/index";
import { CellDataModel } from "./Models/CellData";
import { BorderInfoModel } from "./Models/BorderInfo";
import { WorkerBookModel } from "./Models/WorkerBook";
import { WorkerSheetModel } from "./Models/WorkerSheet";
import { HiddenAndLenModel } from "./Models/HiddenAndLen";
import { CalcChainModel } from "./Models/CalcChain";
class DataBase {
private _connected: boolean = false; // 连接状态
private _sequelize: Sequelize | null = null; // 连接对象
constructor() {
this._connected = false;
this._sequelize = null;
}
/**
* 初始化数据库
*/
public async connect() {
const { port, host, database, user, password, enable } = SQL_CONFIG;
// 禁用数据库
if (!enable) return logger.warn("🚫 数据库服务已禁用!");
// 创建连接
this._sequelize = new Sequelize(database, user, password, {
port,
host,
dialect: "mysql",
logging: SQL_CONFIG.logger ? (sql: string) => logger.debug(sql) : false,
logQueryParameters: true,
});
// 测试连接
try {
await this._sequelize.authenticate();
logger.info("✅️ Successfully connected to the database!");
this._connected = true;
/** 连接成功后,进行模型注册 */
this.registerModule();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
logger.error("🚫 Failed to connect to the database!");
this._connected = false;
this._sequelize = null;
}
}
/**
* 提供原始查询方法
*/
public async query(sql: string) {
if (!this._sequelize || !this._connected) return;
return await this._sequelize.query(sql);
}
/**
* 关闭数据库连接
*/
public close() {
if (this._sequelize) this._sequelize.close();
}
/**
* 获取连接状态
*/
public getConnectState(): boolean {
return this._connected;
}
/**
* 同步表结构
* 1. 请注意表的主键/外键关联关系,如果依赖外键,需要先注册主键表,不然会报错
*/
private registerModule() {
if (!this._sequelize || !this._connected) return;
// 初始化数据库表
WorkerBookModel.registerModule(this._sequelize);
WorkerSheetModel.registerModule(this._sequelize);
CellDataModel.registerModule(this._sequelize);
MergeModel.registerModule(this._sequelize);
BorderInfoModel.registerModule(this._sequelize);
HiddenAndLenModel.registerModule(this._sequelize);
ImageModel.registerModule(this._sequelize);
ChartModel.registerModule(this._sequelize);
CalcChainModel.registerModule(this._sequelize);
}
}
/**
* DataBase 数据库类 - 单例模式(简单实现)
* 1. connect 连接数据库方法
* 2. close 关闭数据库方法
* 3. query(sql:string) 执行原生 SQL 查询
* 4. getConnectState():boolean 获取连接状态
*/
export const DB = new DataBase();
@@ -0,0 +1,50 @@
/**
* database 重要文件 - 用于 package 初始化数据库表结构
*/
import { Sequelize } from "sequelize";
import { SQL_CONFIG } from "../Config";
import { logger } from "../Utils/Logger";
import { MergeModel } from "./Models/Merge";
import { ImageModel } from "./Models/Image";
import { ChartModel } from "./Models/Chart";
import { CellDataModel } from "./Models/CellData";
import { WorkerBookModel } from "./Models/WorkerBook";
import { BorderInfoModel } from "./Models/BorderInfo";
import { WorkerSheetModel } from "./Models/WorkerSheet";
import { HiddenAndLenModel } from "./Models/HiddenAndLen";
import { CalcChainModel } from "./Models/CalcChain";
(async () => {
const { port, host, database, user, password } = SQL_CONFIG;
const sequelize = new Sequelize(database, user, password, {
port,
host,
dialect: "mysql",
});
try {
await sequelize.authenticate();
logger.success("Connection has been established successfully.");
// 初始化数据库表
WorkerBookModel.registerModule(sequelize);
WorkerSheetModel.registerModule(sequelize);
CellDataModel.registerModule(sequelize);
MergeModel.registerModule(sequelize);
BorderInfoModel.registerModule(sequelize);
HiddenAndLenModel.registerModule(sequelize);
ImageModel.registerModule(sequelize);
ChartModel.registerModule(sequelize);
CalcChainModel.registerModule(sequelize);
// 2. 同步模型 (非强制同步)
await sequelize.sync({ alter: true });
sequelize.close();
logger.success("✅️ 所有模型均已成功同步至最新状态.");
} catch (error) {
logger.error(error);
sequelize.close();
}
})();
@@ -0,0 +1,66 @@
import { BorderInfoModel, BorderInfoModelType } from "../Sequelize/Models/BorderInfo";
import { logger } from "../Utils/Logger";
/**
* 查找是否存在边框
* @param info
* @returns
*/
async function hasConfigBorder(info: BorderInfoModelType) {
try {
return await BorderInfoModel.findOne({
where: info,
});
} catch (error) {
logger.error(error);
}
}
/**
* 创建新的边框记录
* @param info
* @returns
*/
async function createConfigBorder(info: BorderInfoModelType) {
try {
return await BorderInfoModel.create(info);
} catch (error) {
logger.error(error);
}
}
/**
* 更新边框记录
* @param info
* @returns
*/
async function updateConfigBorder(info: BorderInfoModelType) {
try {
return await BorderInfoModel.update(info, {
where: { config_border_id: info.config_border_id },
});
} catch (error) {
logger.error(error);
}
}
/**
* 初始化查询全部 borderInfo
* @param worker_sheet_id
* @returns
*/
async function findAllBorder(worker_sheet_id: string) {
try {
// 边框属性需要根据创建记录升序排序,先创建的在第一条,返回时,才不会覆盖后面的记录
return await BorderInfoModel.findAll({ where: { worker_sheet_id }, order: [["createdAt", "ASC"]] });
} catch (error) {
logger.error(error);
}
}
export const BorderInfoService = {
findAllBorder,
hasConfigBorder,
createConfigBorder,
updateConfigBorder,
};
@@ -0,0 +1,66 @@
import { CalcChainModel, CalcChainModelType } from "../Sequelize/Models/CalcChain";
import { logger } from "../Utils/Logger";
/**
* 查找是否已经存在记录
*/
async function findOne(worker_sheet_id: string, r: number, c: number) {
try {
return await CalcChainModel.findOne({
where: { worker_sheet_id, r, c },
});
} catch (error) {
logger.error(error);
}
}
// 更新
async function update(calcchain_id: string, func: string) {
try {
return await CalcChainModel.update(
{ func },
{
where: { calcchain_id },
}
);
} catch (error) {
logger.error(error);
return null;
}
}
// ** 创建
async function create(info: CalcChainModelType) {
try {
return await CalcChainModel.create(info);
} catch (error) {
logger.error(error);
}
}
// 删除
async function deleteCalcChain(worker_sheet_id: string, r: number, c: number) {
try {
return await CalcChainModel.destroy({
where: { worker_sheet_id, r, c },
});
} catch (error) {
logger.error(error);
}
}
// 查询全部
async function findAll(worker_sheet_id: string) {
try {
return await CalcChainModel.findAll({ where: { worker_sheet_id } });
} catch (error) {
logger.error(error);
}
}
export const CalcChainService = {
findOne,
update,
create,
findAll,
deleteCalcChain,
};
@@ -0,0 +1,151 @@
import { logger } from "../Utils/Logger";
import { CellDataModel, CellDataModelType } from "../Sequelize/Models/CellData";
/**
* 通过 sheetid 查找当前数据表的单元格数据
*/
async function getCellData(worker_sheet_id: string) {
try {
return await CellDataModel.findAll({ where: { worker_sheet_id } });
} catch (error) {
logger.error(error);
return null;
}
}
/**
* 根据传入的 sheetid rc 判断是否存在记录
*/
async function hasCellData(worker_sheet_id: string, r: number, c: number) {
try {
return await CellDataModel.findOne({
where: { worker_sheet_id, r, c },
});
} catch (error) {
logger.error(error);
}
}
async function updateCellData(info: CellDataModelType) {
try {
return await CellDataModel.update(info, {
where: { cell_data_id: info.cell_data_id },
});
} catch (error) {
logger.error(error);
return null;
}
}
async function createCellData(info: CellDataModelType) {
try {
return await CellDataModel.create(info);
} catch (error) {
logger.error(error);
return null;
}
}
async function deleteCellDataRC(worker_sheet_id: string, index: number, rc: "r" | "c") {
try {
return await CellDataModel.destroy({
where: { worker_sheet_id, [rc]: index },
});
} catch (error) {
logger.error(error);
return null;
}
}
async function deleteCellData(worker_sheet_id: string, r: number, c: number) {
try {
return await CellDataModel.destroy({
where: { worker_sheet_id, r, c },
});
} catch (error) {
logger.error(error);
return null;
}
}
/**
* 删除行列
*/
async function updateCellDataRC(payload: { worker_sheet_id: string; index: number; len: number; update_type: "r" | "c" }) {
// {"t":"drc","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364","v":{"index":0,"len":1},"rc":"r"}
const { worker_sheet_id, index, len, update_type } = payload;
try {
// 1. 查询所有的 celldata 的 数据
const cellData = await CellDataModel.findAll({
where: { worker_sheet_id },
});
// 2. 更新 r = r + len
if (cellData.length) {
for (let i = 0; i < cellData.length; i++) {
// 只有比 index 大的值,才进行处理
if (cellData[i][update_type] > index) {
cellData[i][update_type] = cellData[i][update_type] - len;
await cellData[i].save();
}
}
}
} catch (error) {
logger.error(error);
return null;
}
}
/**
* 增加行列
*/
async function addCellDataRC(payload: { worker_sheet_id: string; index: number; len: number; update_type: "r" | "c" }) {
const { worker_sheet_id, index, len, update_type } = payload;
// 在上面加一列跟在下面加一列的区别:就是标记的这一行是否也跟着变化
try {
// 遍历查询
const cellData = await CellDataModel.findAll({
where: { worker_sheet_id },
});
// {"t":"arc","i":"2b62e1f2...","v":{"index":8,"len":1,"direction":"lefttop","data":[]},"rc":"r"}
if (!cellData.length) return null;
// 不然执行增加操作
// 如果rc的值是r新增行, 如果rc的值为c则新增列, 例如 rc=r,index=4,len=5,则代表从第4行开始增加5行
// 然后把r大于4的单元格的整体的r值+5
for (let i = 0; i < cellData.length; i++) {
if (cellData[i][update_type] >= index) {
cellData[i][update_type] = cellData[i][update_type] + len;
await cellData[i].save();
}
}
} catch (error) {
logger.error(error);
return null;
}
}
/**
* 存储函数链操作
*/
async function createFC(worker_sheet_id: string, r: number, c: number, calcChain: string) {
try {
await CellDataModel.update({ calcChain }, { where: { worker_sheet_id, r, c } });
} catch (error) {
logger.error(error);
}
}
export const CellDataService = {
getCellData,
hasCellData,
updateCellData,
createCellData,
deleteCellData,
updateCellDataRC,
deleteCellDataRC,
addCellDataRC,
createFC,
};
@@ -0,0 +1,55 @@
import { logger } from "../Utils/Logger";
import { ChartModel, ChartModelType } from "../Sequelize/Models/Chart";
/**
* 创建图表
*/
async function createChart(data: ChartModelType) {
try {
return await ChartModel.create(data);
} catch (error) {
logger.error(error);
}
}
/**
* 更新图表
* @param data xy wh option
* @returns
*/
async function updateChart(data: ChartModelType) {
try {
return await ChartModel.update(data, {
where: { chart_id: data.chart_id },
});
} catch (error) {
logger.error(error);
}
}
// 删除图表
async function deleteChart(chart_id: string) {
try {
return await ChartModel.destroy({ where: { chart_id } });
} catch (error) {
logger.error(error);
}
}
// findAllChart
async function findAllChart(worker_sheet_id: string) {
try {
return await ChartModel.findAll({
where: { worker_sheet_id },
});
} catch (error) {
logger.error(error);
}
}
export const ChartService = {
createChart,
updateChart,
deleteChart,
findAllChart,
};
@@ -0,0 +1,70 @@
import { HiddenAndLenModelType, HiddenAndLenModel } from "../Sequelize/Models/HiddenAndLen";
import { logger } from "../Utils/Logger";
/**
* 创建隐藏记录
* HidenAndLen 表结构设定为 存储单条记录,因此,在创建之前,需要先查询是否已经存在 gridkey and config_type and config_index 的记录,
* 如果存在的话,需要先进行删除
* @param data
* @returns
*/
async function create(data: HiddenAndLenModelType) {
try {
// 查询是否存在记录
const exist = await HiddenAndLenModel.findOne({
where: {
worker_sheet_id: data.worker_sheet_id,
config_type: data.config_type,
config_index: data.config_index,
},
});
if (exist)
await HiddenAndLenModel.destroy({
where: { config_id: exist.config_id },
});
return await HiddenAndLenModel.create(data);
} catch (error) {
logger.error(error);
}
}
// 查询 worker_sheet_id 下所有隐藏记录 - 用于初始化时查询
async function findConfig(worker_sheet_id: string) {
try {
return await HiddenAndLenModel.findAll({
where: { worker_sheet_id },
});
} catch (error) {
logger.error(error);
}
}
// 删除 hidden 记录
async function deleteHidden(worker_sheet_id: string, config_type: string, config_index?: string) {
try {
return await HiddenAndLenModel.destroy({
where: Object.assign({}, { worker_sheet_id, config_type }, config_index ? { config_index } : {}),
});
} catch (error) {
logger.error(error);
}
}
// 删除列
async function deleteRC(worker_sheet_id: string, config_index: string) {
try {
return await HiddenAndLenModel.destroy({
where: { worker_sheet_id, config_index },
});
} catch (error) {
logger.error(error);
}
}
export const HiddenAndLenService = {
create,
findConfig,
deleteHidden,
deleteRC,
};
@@ -0,0 +1,36 @@
import { logger } from '../Utils/Logger';
import { ImageModel, ImageModelType } from '../Sequelize/Models/Image';
/**
* 先删除当前 sheet 的所有图片
*/
async function deleteImage(worker_sheet_id: string) {
try {
return await ImageModel.destroy({ where: { worker_sheet_id } });
} catch (error) {
logger.error(error);
}
}
/**
* 创建 image 记录
*/
async function createImage(data: ImageModelType) {
try {
return await ImageModel.create(data);
} catch (error) {
logger.error(error);
}
}
/**
* 查询当前 workersheetid 的图片
*/
async function findAllImage(worker_sheet_id: string) {
try {
return await ImageModel.findAll({ where: { worker_sheet_id } });
} catch (error) {
logger.error(error);
}
}
export const ImageService = { deleteImage, createImage, findAllImage };
@@ -0,0 +1,57 @@
/**
* 表格合并单元格处理逻辑:
* 设置一个合并单元格,需要处理两个地方,一是单元格对象中设置mc属性,二是在config中设置merge
* "r": 0, //主单元格的行号
* "c": 0, //主单元格的列号
* "rs": 2, //合并单元格占的行数
* "cs": 2 //合并单元格占的列数
*
* 业务上的处理逻辑是:先查询 merges 这个表,如果有字段值,则反馈生成 celldata mc 属性字段
*
* 如果是创建新的合并单元格,需要判断 r c 是否存在,存在则更新 rs cs,不存在则插入
*/
import { logger } from "../Utils/Logger";
import { MergeModel, MergeModelType } from "../Sequelize/Models/Merge";
async function deleteMerge(worker_sheet_id: string) {
try {
return await MergeModel.destroy({
where: {
worker_sheet_id,
},
});
} catch (error) {
logger.error(error);
return null;
}
}
// 新增记录
async function createMerge(info: MergeModelType) {
try {
return await MergeModel.create(info);
} catch (error) {
logger.error(error);
return null;
}
}
// 查询全部
async function findAllMerge(worker_sheet_id: string) {
try {
return await MergeModel.findAll({
where: { worker_sheet_id },
order: [["createdAt", "ASC"]],
});
} catch (error) {
logger.error(error);
return null;
}
}
export const MergeService = {
deleteMerge,
createMerge,
findAllMerge,
};
@@ -0,0 +1,100 @@
/**
* Worker Books Service
*/
import { logger } from "../Utils/Logger";
import {
WorkerBookModel,
WorkerBookModelType,
} from "../Sequelize/Models/WorkerBook";
import {
WorkerSheetModel,
WorkerSheetModelType,
} from "../Sequelize/Models/WorkerSheet";
import { FindOptions } from "sequelize";
/**
* 新增 workerBooks 记录
* @param info WorkerBookModelType
* @returns
*/
async function create(info: WorkerBookModelType) {
try {
// 1. 先查询用户传递的 gridKey 是否存在记录
const exist = await findOne(info.gridKey);
if (exist) return { code: 1, msg: "该 gridKey 已经存在" };
// 2. 创建一个 worker books
const book = await WorkerBookModel.create(info);
// 3. 为当前 worker book 创建一个 worker sheet 因为一个 Ecxel 文档中至少需要一个 sheet
const sheetInfo: WorkerSheetModelType = {
gridKey: info.gridKey,
name: "Sheet1",
order: 0,
status: 1,
};
const sheet = await WorkerSheetModel.create(sheetInfo);
return {
code: 0,
msg: "工作簿创建成功,已同步创建 worker sheet 记录.",
book,
sheet,
};
} catch (error) {
logger.error(error);
return null;
}
}
/**
* 更新 workerBooks 记录
*/
async function update(info: WorkerBookModelType) {
try {
return await WorkerBookModel.update(info, {
where: { gridKey: info.gridKey },
});
} catch (error) {
logger.error(error);
}
}
/**
* 删除 workerBooks 记录 - 注意:该表有外键关联,如果删除记录可能会导致其他业务表有问题,因此,提供 stage 参数,标记记录是否可用
*/
async function del() {}
/**
* 查询 workerBooks 记录
*/
async function findOne(
gridKey: string,
options?: FindOptions<WorkerBookModelType>
) {
try {
return await WorkerBookModel.findOne({ where: { gridKey }, ...options });
} catch (error) {
console.error(error);
}
}
/**
* 查询 workerBooks 记录列表
*/
async function findAll() {
try {
return await WorkerBookModel.findAll();
} catch (error) {
console.error(error);
}
}
export const WorkerBookService = {
create,
update,
delete: del,
findOne,
findAll,
};
@@ -0,0 +1,49 @@
import { logger } from "../Utils/Logger";
import {
WorkerSheetModel,
WorkerSheetModelType,
} from "../Sequelize/Models/WorkerSheet";
/**
* 更新相关配置
*/
async function update(data: WorkerSheetModelType) {
try {
return await WorkerSheetModel.update(data, {
where: { worker_sheet_id: data.worker_sheet_id },
});
} catch (error) {
logger.error(error);
}
}
/**
* 通过 gridkey 查找记录
*/
async function findAllByGridKey(gridKey: string) {
try {
return await WorkerSheetModel.findAll({
// 要查找当前没有被删除的表
where: { gridKey, deleteFlag: false },
});
} catch (error) {
logger.error(error);
}
}
/**
* 新建 sheet
*/
async function createSheet(data: WorkerSheetModelType) {
try {
return await WorkerSheetModel.create(data);
} catch (error) {
logger.error(error);
}
}
export const WorkerSheetService = {
findAllByGridKey,
update,
createSheet,
};
@@ -0,0 +1,70 @@
/**
* 手动实现 logger4js 日志记录功能
*/
import fs from "fs";
import dayjs from "dayjs";
import { LOGGER_CONFIG } from "../../Config";
type LogMessage = string | number | object | unknown;
type LogType = "INFO" | "ERROR" | "WARN" | "DEBUG" | "SUCCESS";
class Logger {
info(...argus: LogMessage[]) {
const time = this.getTime();
console.log("\x1b[94m %s \x1b[0m %s", `[${time}] [INFO] -`, ...argus);
this.writeLogFile(time, "INFO", ...argus);
}
error(...argus: LogMessage[]) {
const time = this.getTime();
console.log("\x1b[31m %s \x1b[0m %s", `[${time}] [ERROR] -`, ...argus);
this.writeLogFile(time, "ERROR", ...argus);
}
warn(...argus: LogMessage[]) {
const time = this.getTime();
console.log("\x1b[93m %s \x1b[0m %s", `[${time}] [WARN] -`, ...argus);
this.writeLogFile(time, "WARN", ...argus);
}
debug(...argus: LogMessage[]) {
const time = this.getTime();
console.log("\x1b[90m %s \x1b[0m %s", `[${time}] [DEBUG] -`, ...argus);
this.writeLogFile(time, "DEBUG", ...argus);
}
success(...argus: LogMessage[]) {
const time = this.getTime();
console.log("\x1b[92m %s \x1b[0m %s", `[${time}] [SUCCESS] -`, ...argus);
this.writeLogFile(time, "SUCCESS", ...argus);
}
// 获取时间戳
private getTime = () => dayjs().format("YYYY-MM-DD HH:MM:ss");
// 写入日志文件
private writeLogFile = (
time: string,
type: LogType,
...argus: LogMessage[]
) => {
const { filepath, filename } = LOGGER_CONFIG;
const message = argus
.map((i) => (typeof i === "object" ? JSON.stringify(i) : i))
.join(" ");
// 保存日志文件的信息,还可以进行拓展,包括执行当前日志的用户信息等
const data = `[${time}] [${type}] - ${message}\n`;
// 尝试查找目录、目录不存在,则自动创建目录
if (!fs.existsSync(filepath)) fs.mkdirSync(filepath);
try {
fs.appendFileSync(filepath + "/" + filename, data);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
fs.writeFile(filepath + "/" + filename, data, (err) => {
logger.error(err!.message.toString());
});
}
};
}
export const logger = new Logger();
@@ -0,0 +1,54 @@
import pako from "pako";
/**
* Pako 数据解析
*/
export function unzip(str: string): string {
const chartData = str
.toString()
.split("")
.map((i) => i.charCodeAt(0));
const binData = new Uint8Array(chartData);
const data = pako.inflate(binData);
// 使用 TextDecoder 替代 String.fromCharCode + Array.from 展开
const decoder = new TextDecoder("utf-8");
return decodeURIComponent(decoder.decode(data));
}
/**
* 获取 url 的某个 query 值
* @param { string } url http://localhost:8089/?type=luckysheet&userid=1&username=userA&t=111&g=
* @param { string } key type
* @example type => luckysheet
* @return { string } query 值
*/
export function getURLQuery(url: string | undefined, key: string) {
if (!url) return "";
// 通过 ? 分割
const params = url.split("?")[1];
if (!params || !params.includes("=")) return "";
/**
* 此时,拿到的参数对象为 params ==> type=luckysheet&userid=1&username=userA&t=111&g=
*/
const queryArr = params.split("&");
for (let i = 0; i < queryArr.length; i++) {
const item = queryArr[i];
const itemArr = item.split("=");
if (itemArr[0] === key) {
return itemArr[1];
}
}
return "";
}
/**
* 判断传入的参数是否为空 null undefined ""
*/
export function isEmpty(val: unknown) {
return val === null || val === undefined || val === "";
}
@@ -0,0 +1,76 @@
import { DB } from "../Sequelize";
import { logger } from "../Utils/Logger";
import { getURLQuery, unzip } from "../Utils";
import { RawData, WebSocketServer } from "ws";
import { IncomingMessage, Server } from "http";
import { databaseHandler } from "./luckysheet/database";
import { CustomWebSocket } from "../Interface/WebSocket";
import { broadcastOtherClients } from "./luckysheet/broadcast";
/**
* 创建 Web Socket 服务
* 应该通过命名空间来处理 ai 及 luckysheet 协同消息的处理函数
* @param port 服务端口
*/
export function createWebSocketServer(server: Server) {
/** 创建WebSocketServer 实例,并监听 connection 事件 */
const wss = new WebSocketServer({ server }).on("connection", connection);
/**
* 监听 websocket 连接事件 - 主要做一些数据初始化、客户端连接对象事件注册等
* @param client WebSocket 实例
* @param req 请求对象
*/
function connection(client: CustomWebSocket, req: IncomingMessage) {
/** 解析 request url 的参数,识别当前连接用户的 userid username gridkey type 属性 */
const type = getURLQuery(req.url, "type");
const userid = getURLQuery(req.url, "userid");
const gridkey = getURLQuery(req.url, "gridkey");
const username = getURLQuery(req.url, "username");
logger.info(`luckysheet 协同用户连接成功 [ID: ${userid} | NAME: ${username}].`);
/** 将 url 参数添加到 client 上,方便后续使用 */
client.clientInfo = { userid, username, type, gridkey };
/** 监听消息 */
client.on("message", (d) => onmessage(d, client));
/** 监听错误 */
client.on("error", logger.error);
/** 监听关闭 code = 1000 正常关闭 */
client.on("close", () => onclose(client));
}
/**
* 监听 websocket 消息 - 协同消息处理函数
* @param data
* @param client
*/
function onmessage(data: RawData, client: CustomWebSocket) {
// Luckysheet 协同消息处理函数
try {
// 1. 进行 pako 解压,将 buffer 转成 可识别 json 字符串
const data_str = unzip(data.toString());
// 2. 用户每次编辑,都会触发 message 事件,因此,在这里实现协同数据存储
if (DB.getConnectState()) databaseHandler(data_str, client.clientInfo.gridkey);
// 3. 广播给 wss.clients 其他客户端 - 所有的权限校验、文件ID 等验证,均在 broadcastOtherClients 函数中处理
broadcastOtherClients(wss, client, data_str);
} catch (error) {
// 4. 捕获异常 判断是否为心跳包消息,心跳不处理,异常信息则记录日志
if (data.toString() !== "rub") logger.error(error);
}
}
/**
* 监听 websocket 的 close 事件
* @param client
*/
function onclose(client: CustomWebSocket) {
broadcastOtherClients(wss, client, "exit");
logger.warn("luckysheet 协同用户关闭连接");
}
}
@@ -0,0 +1,54 @@
/**
* 处理广播给其他客户端事件,客户端接收服务端要求数据结构:
*
* data: 修改的命令
* id: "7a" websocket的id
* username: 用户名(用于显示 xxx 正在编辑)
* type:
* message === '用户退出' 用户退出时,关闭协同编辑时其提示框
* type == 1 send 成功或失败
* type == 2 更新数据
* type == 3 多人操作不同选区("t": "mv")(用不同颜色显示其他人所操作的选区)
* type == 4 批量指令更新
* type == 5 showloading
* type == 6 hideloading
*/
import WebSocket from "ws";
import { CustomWebSocket } from "../../Interface/WebSocket";
/**
* 广播给其他客户端
* @param wss
* @param currentClient
* @param data
*/
export function broadcastOtherClients(wss: WebSocket.Server, currentClient: CustomWebSocket, data: string) {
/** 根据传入的 data 进行协同回传消息类型处理 */
const callback = getCallbackData(currentClient, data);
/**
* 请注意,此处应该做校验:
* 1. 协同类型是否为 luckysheet(同一个协同服务类型才协同)
* 2. 当前用户的文件ID(使用 gridkey 替代 fileid) 是否一致(同一篇文档的用户才协同)
* 3. 当前用户的 userid 是否一致(同一用户不发送消息)
*/
wss.clients.forEach((conn) => {
const { type, gridkey, userid } = (<CustomWebSocket>conn).clientInfo;
if (type !== "luckysheet") return;
if (gridkey !== currentClient.clientInfo.gridkey) return;
if (userid === currentClient.clientInfo.userid) return;
// 校验通过,发送消息给客户端
if (conn.readyState === WebSocket.OPEN) conn.send(callback);
});
}
function getCallbackData(currentClient: CustomWebSocket, data: string): string {
const { userid, username } = currentClient.clientInfo;
if (data === "exit") return JSON.stringify({ message: "用户退出", id: userid });
// 不然就正常封装,根据data的t识别不同的 type
const { t } = JSON.parse(data);
const info = { data, id: userid, username: decodeURIComponent(username), type: t === "mv" ? 3 : 2 };
return JSON.stringify(info);
}
@@ -0,0 +1,125 @@
import { CRDTDataType, MERGE } from "../../../Interface/WebSocket";
import { HiddenAndLenModelType } from "../../../Sequelize/Models/HiddenAndLen";
import { WorkerSheetModelType } from "../../../Sequelize/Models/WorkerSheet";
import { HiddenAndLenService } from "../../../Service/HiddenAndLen";
import { ImageService } from "../../../Service/Image";
import { MergeService } from "../../../Service/Merge";
import { WorkerSheetService } from "../../../Service/WorkerSheet";
import { isEmpty } from "../../../Utils";
import { logger } from "../../../Utils/Logger";
/**
* 通用保存 冻结行列、修改工作表名、修改工作表颜色、合并单元格、筛选等操作,
* 此方法仅处理 修改工作表名 合并单元格
* @param data
* @returns
*/
export async function all(data: string) {
logger.info("[CRDT DATA]:", data);
const { t, i, v, k } = <CRDTDataType<MERGE>>JSON.parse(data);
if (t !== "all") return logger.error("t is not all.");
if (isEmpty(i)) return logger.error("i is undefined.");
// 修改工作表名
// {"t":"all","i":"12f8254d-3914-4f79-9886-9f9aec173048","v":"123","k":"name"}
if (k === "name") {
const info = <WorkerSheetModelType>{
worker_sheet_id: i,
name: <string>(<unknown>v),
};
await WorkerSheetService.update(info);
} else if (k === "config") {
// 合并单元格 - 又是一个先删除后新增的操作,由luckysheet 前台设计决定的
// {"t":"all","i":"e73f971....","v":{"merge":{"1_0":{"r":1,"c":0,"rs":3,"cs":3}},},"k":"config"}
// {"t":"all","i":"e73f971....","v":{"merge":{"1_0":{"r":1,"c":0,"rs":3,"cs":3},"9_1":{"r":9,"c":1,"rs":5,"cs":3}},},"k":"config"}
// {"t":"all","i":"e73f971....","v":{"merge":{"9_1":{"r":9,"c":1,"rs":5,"cs":3}},},"k":"config"}
// 如果当前 sheet 没有 merge 属性,则表示最后一个合并单元格被取消,则删除当前 sheetindex 下的所有 merge 记录
// 删除当前 sheet Index 下的所有merge 记录
await MergeService.deleteMerge(i);
// 如果有记录 再新增
if (v && v.merge && Object.keys(v.merge).length) {
// 再新增
for (const key in v.merge) {
if (Object.prototype.hasOwnProperty.call(v.merge, key)) {
const { r, c, rs, cs } = v.merge[key];
await MergeService.createMerge({
worker_sheet_id: i,
r,
c,
rs,
cs,
});
}
}
}
// 文本换行处理方式 - 自动换行是通过自定义行高实现的,这部分也需要存储哈
// {"t":"all","i":"28a60885-46e3-4f59-9d25-442a30fdbba6","v":{"merge":{},"rowhidden":{},"colhidden":{},"borderInfo":[],"rowlen":{"10":63.5},"columnlen":{},"customHeight":{}},"k":"config"}
if (v && v.rowlen && Object.keys(v.rowlen).length) {
// "rowlen":{"10":63.5},
for (const key in v.rowlen) {
if (Object.prototype.hasOwnProperty.call(v.rowlen, key)) {
const value = Number(v.rowlen[key]);
// 此时 key 是行号,value 是行高
const configInfo: HiddenAndLenModelType = {
worker_sheet_id: i,
config_index: key,
config_type: "rowlen",
config_value: value,
};
await HiddenAndLenService.create(configInfo);
}
}
}
} else if (k === "images") {
// {"t":"all","i":"4735b996-d89d-4d7f-ad8e-1124bccc89b0","v":{
// "img_3aaW3S653e10_1733991741264":{"type":"3","src":"/uploads/36399c90241d782399d05acd9dfb1d9d.png","originWidth":685,"originHeight":490,"default":{"width":400,"height":286,"left":18,"top":229},"crop":{"width":400,"height":286,"offsetLeft":0,"offsetTop":0},"isFixedPos":false,"fixedLeft":46,"fixedTop":90,"border":{"width":0,"radius":0,"style":"solid","color":"#000"}},
// "img_eeeKop3oTl3a_1733991749199":{"type":"3","src":"/uploads/31ae8c6088f50c267a09b00b3555d787.png","originWidth":685,"originHeight":386,"default":{"width":400,"height":225,"left":0,"top":171},"crop":{"width":400,"height":225,"offsetLeft":0,"offsetTop":0},"isFixedPos":false,"fixedLeft":46,"fixedTop":90,"border":{"width":0,"radius":0,"style":"solid","color":"#000"}}
// },"k":"images"}
/* eslint-disable */
// 又是一个先删除后新增的操作
await ImageService.deleteImage(i);
for (const key in v) {
if (Object.prototype.hasOwnProperty.call(v, key)) {
// @ts-ignore
const value = v[key];
// 解析 value 值
await ImageService.createImage({
image_key: key,
worker_sheet_id: i,
image_type: value.type, // type 1移动并调整单元格大小 2移动并且不调整单元格的大小 3不要移动单元格并调整其大小
image_src: value.src, // 图片地址
in_cell: value.inCell || "", // 所在单元格位置
image_originWidth: value.originWidth, // 原始宽度
image_originHeight: value.originHeight, // 原始高度
image_default_width: value.default.width, // 默认宽度
image_default_height: value.default.height, // 默认高度
image_default_left: value.default.left, // 默认左边距
image_default_top: value.default.top, // 默认上边距
image_crop_width: value.crop.width, // 裁剪宽度
image_crop_height: value.crop.height, // 裁剪高度
image_crop_offsetLeft: value.crop.offsetLeft, // 裁剪左边距
image_crop_offsetTop: value.crop.offsetTop, // 裁剪上边距
image_isFixedPos: value.isFixedPos, // 是否固定位置
image_fixedLeft: value.fixedLeft, // 固定左边距
image_fixedTop: value.fixedTop, // 固定上边距
image_border_width: value.border.width, // 边框宽度
image_border_radius: value.border.radius, // 圆角
image_border_style: value.border.style, // 边框样式
image_border_color: value.border.color, // 边框颜色
});
}
}
} else if (k === "color") {
// {"t":"all","i":"62e09f1d-b294-46b5-8924-a0f1a8e011a2","v":"#ffff00","k":"color"}
// 更新 worker sheet color 字段即可
const color_info = <WorkerSheetModelType>{
worker_sheet_id: i,
color: <string>(<unknown>v),
};
await WorkerSheetService.update(color_info);
}
}
@@ -0,0 +1,80 @@
// 增加行或列 - 会影响 celldata r c 的值,需要更新比新增行列大/小的 r c 值
import { CRDTDataType, ARC } from "../../../Interface/WebSocket";
import { CellDataModelType } from "../../../Sequelize/Models/CellData";
import { CellDataService } from "../../../Service/CellData";
import { isEmpty } from "../../../Utils";
import { logger } from "../../../Utils/Logger";
// 撤销删除行列时,也会触发该事件,并且携带 data 字段
export async function arc(data: string) {
logger.info("[CRDT DATA]:", data);
const { t, i, v, rc } = <CRDTDataType<ARC>>JSON.parse(data);
if (t !== "arc") return logger.error("t is not arc.");
if (isEmpty(i)) return logger.error("i is undefined.");
// 如果rc的值是r新增行,
// 如果rc的值为c则新增列,
// 例如rc=rindex=4len=5 则代表从第4行开始增加5行,
// direction 标识添加行列的方向 lefttop 上方/左方添加 rightbottom 下方/右方添加
// 无数据示例(一般是新增空白行列)
// {"t":"arc","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364","v":{"index":0,"len":1,"direction":"rightbottom","data":[]},"rc":"r"}
// 有数据示例(一般是撤销删除行列时,会携带数据):
// {"t":"arc","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364","v":{"index":3,"len":1,"direction":"lefttop","data":[[null,
// {"ct":{"fa":"General","t":"n"},"v":"333","m":"333","bg":"#FFFFFF","ff":"5","fc":"#000000","bl":false,"it":false,"fs":10,"cl":false,"ht":0,"vt":0,"f":null,"un":false},
// ... 没值部分均为 null ...,null]]},"rc":"r"}
// {"t":"arc","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364",
// "v":{"index":6,"len":3,"direction":"lefttop",
// "data":[
// [null,null,null,{"v":"1","ct":{"fa":"General","t":"n"},"m":"1"},null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],
// [null,null,null,{"v":"2","ct":{"fa":"General","t":"n"},"m":"2"},null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],
// [null,null,null,{"v":"3","ct":{"fa":"General","t":"n"},"m":"3"},null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]]},"rc":"r"}
// 1. 先新增行列 - 先处理 celldata r c 的关系
await CellDataService.addCellDataRC({
worker_sheet_id: i,
index: v.index,
len: v.len,
update_type: <"r" | "c">rc,
});
// 2. 后处理 data - 如果是撤销的话,需要新增 celldata 记录
if (!v.data.length) return;
for (let k = 0; k < v.data.length; k++) {
const item = v.data[k]; // 同时操作的可能有多列,因此这个item也是个数组,并且是 cellDataTypeItem []
for (let j = 0; j < item.length; j++) {
const cellItem = item[j];
if (cellItem === null) continue;
let r = 0;
let c = 0;
// 注意撤销后的 r/c 取值
if (rc === "r") {
r = v.index + k;
c = j;
} else if (rc === "c") {
r = k;
c = v.index + j;
}
// 不然 执行celldata 的插入操作
const celldata: CellDataModelType = {
worker_sheet_id: i,
...cellItem,
r,
c, // 注意撤销后的 r/c 取值
m: <string>cellItem.m,
v: <string>cellItem.v,
ctfa: <string>cellItem?.ct?.fa,
ctt: <string>cellItem?.ct?.t,
ps: <string>cellItem?.ps?.value,
};
await CellDataService.createCellData(celldata);
}
}
}
@@ -0,0 +1,71 @@
import { CRDTDataType, CHART } from "../../../Interface/WebSocket";
import { ChartService } from "../../../Service/Chart";
import { isEmpty } from "../../../Utils";
import { logger } from "../../../Utils/Logger";
// 图表操作
export async function c(data: string) {
logger.info("[CRDT DATA]:", data);
const { t, v, i, op } = <CRDTDataType<CHART>>JSON.parse(data);
if (t !== "c") return logger.error("t is not c.");
if (isEmpty(i)) return logger.error("i is undefined.");
// 所有的图表ID均由前台传递
// 创建图表
const chartInfo = {
chartType: v.chartType,
worker_sheet_id: i,
chart_id: v.chart_id,
width: v.width,
height: v.height,
left: v.left,
top: v.top,
needRangeShow: v.needRangeShow,
chartOptions: JSON.stringify(v.chartOptions),
};
if (op === "add") {
await ChartService.createChart(chartInfo);
}
// 更新图表
// {"t":"c","i":"89357e56-c6bc-4de0-bfd1-0e00b3086da4","v":{"chart_id":"chart_01ieK40e4Kal_1734335434241","left":"172.3px","top":"158.3px","scrollTop":0,"scrollLeft":0},"cid":"chart_01ieK40e4Kal_1734335434241","op":"xy"}
else if (op === "xy" || op === "wh") {
await ChartService.updateChart({
chartType: v.chartType,
worker_sheet_id: i,
chart_id: v.chart_id,
left: v.left,
top: v.top,
width: v.width,
height: v.height,
});
}
// 更新图表配置
else if (op === "update") {
await ChartService.updateChart({
chartType: v.chartType,
worker_sheet_id: i,
chart_id: v.chart_id,
chartOptions: JSON.stringify(v.chartOptions),
});
}
// 删除图表
// {"t":"c","i":"89357e56-c6bc-4de0-bfd1-0e00b3086da4","v":{"cid":"chart_WW0t3io1towN_1734335743092"},"cid":"chart_WW0t3io1towN_1734335743092","op":"del"}
else if (op === "del") {
await ChartService.deleteChart(v.chart_id);
}
// TODO:
// 图表更新单元格数据 update_data
// {"t":"c","i":0,"v":{"r_st":1,"r_ed":1,"c_st":1,"c_ed":1,chart_id:"",chartOptions:""},"op":"update_data"}
else if (op === "update_data") {
// 更新 ChartModel options 配置项
await ChartService.updateChart({
chartType: v.chartType,
worker_sheet_id: i,
chart_id: v.chart_id,
chartOptions: JSON.stringify(v.chartOptions),
});
}
}
@@ -0,0 +1,103 @@
import { CRDTDataType, CG } from "../../../Interface/WebSocket";
import { BorderInfoModelType } from "../../../Sequelize/Models/BorderInfo";
import { HiddenAndLenModelType } from "../../../Sequelize/Models/HiddenAndLen";
import { BorderInfoService } from "../../../Service/Border";
import { HiddenAndLenService } from "../../../Service/HiddenAndLen";
import { isEmpty } from "../../../Utils";
import { logger } from "../../../Utils/Logger";
// config操作
export async function cg(data: string) {
logger.info("[CRDT DATA]:", data);
const { t, i, v, k } = <CRDTDataType<CG>>JSON.parse(data);
if (t !== "cg") return logger.error("t is not cg.");
if (isEmpty(i)) return logger.error("i is undefined.");
// k: 操作的key值,边框:'borderInfo' / :行隐藏:'rowhidden' / 列隐藏:'colhidden' / 行高:'rowlen' / 列宽:'columnlen'
// 行隐藏/列隐藏 统一处理
// { "t": "cg", "i": "Sheet_0554kKiKl4M7_1597974810804", "v": { "5": 0, "6": 0, "13": 0, "14": 0 }, "k": "rowhidden"}
// 修改行高列宽
// {"t":"cg","i":"e73f971d-606f-4b04-bcf1-98550940e8e3","v":{"4":100},"k":"rowlen"}
// 取消隐藏行
// {"t":"cg","i":"Sheet_13073il53h5h_1757404832048","v":{},"k":"colhidden"}
if (k === "rowhidden" || k === "colhidden" || k === "rowlen" || k === "columnlen") {
// 因为前台发送的全量数据,因此,每次执行,都需要先删除后添加
// {"t":"cg","i":"e73f971d-606f-4b04-bcf1-98550940e8e3","v":{"7":0,"8":0,"9":0},"k":"rowhidden"}
// {"t":"cg","i":"e73f971d-606f-4b04-bcf1-98550940e8e3","v":{},"k":"rowhidden"}
await HiddenAndLenService.deleteHidden(i, k);
for (const key in v) {
if (Object.prototype.hasOwnProperty.call(v, key)) {
const value = Number(v[key]);
// 判断具体是 行还是列
const configInfo: HiddenAndLenModelType = {
worker_sheet_id: i,
config_index: key,
config_type: k,
config_value: value,
};
await HiddenAndLenService.create(configInfo);
}
}
}
// 场景三: k borderInfo 边框处理
// {"t":"cg","i":"e73f971d606...","v":[{"rangeType":"range","borderType":"border-all","color":"#000","style":"1","range":[{"row":[0,0],"column":[0,0],"row_focus":0,"column_focus":0,"left":0,"width":73,"top":0,"height":19,"left_move":0,"width_move":73,"top_move":0,"height_move":19}]}],"k":"borderInfo"}
// {"t":"cg","i":"e73f971d......","v":[{"rangeType":"range","borderType":"border-all","color":"#000","style":"1","range":[{"row":[2,7],"column":[1,2],"row_focus":2,"column_focus":1,"left":74,"width":73,"top":40,"height":19,"left_move":74,"width_move":147,"top_move":40,"height_move":119,}]}],"k":"borderInfo"}
// {"t":"cg","i":"e73f971d......","v":[{"rangeType":"range","borderType":"border-bottom","color":"#000","style":"1","range":[{"left":148,"width":73,"top":260,"height":19,"left_move":148,"width_move":73,"top_move":260,"height_move":19,"row":[13,13],"column":[2,2],"row_focus":13,"column_focus":2}]}],"k":"borderInfo"}
// 20250827 新增修复BUG:边框[无] 时,刷新异常BUG 导致该BUG的原因: 传入的数据类型,并非顺序的 all none
/**
* { "rangeType": "range", "borderType": "border-all", "color": "#000", "range": [{ "row": [2, 14], "column": [3, 6] }] },
* { "rangeType": "range", "borderType": "border-all", "color": "#000", "range": [{ "row": [8, 16], "column": [6, 8] }] },
* { "rangeType": "range", "borderType": "border-none", "color": "#000", "range": [{ "row": [2, 14], "column": [3, 6] }] },
* { "rangeType": "range", "borderType": "border-all", "color": "#000", "range": [{ "row": [2, 8], "column": [2, 4] }] },
* { "rangeType": "range", "borderType": "border-none", "color": "#000", "range": [{ "row": [4, 13], "column": [5, 7] }] },
* { "rangeType": "range", "borderType": "border-all", "color": "#000", "range": [{ "row": [4, 13], "column": [5, 7] }] },
* { "rangeType": "range", "borderType": "border-none", "color": "#000", "range": [{ "row": [8, 16], "column": [6, 8] }] },
* { "rangeType": "range", "borderType": "border-none", "color": "#000", "range": [{ "row": [2, 8], "column": [2, 4] }] },
* { "rangeType": "range", "borderType": "border-none", "color": "#000", "range": [{ "row": [4, 13], "column": [5, 7] }] }
*/
else if (k === "borderInfo") {
// 处理 rangeType
for (let idx = 0; idx < v.length; idx++) {
const border = v[idx];
const { rangeType, borderType, color, style, range } = border;
// 用 i row column 及 borderType === 'border-none' 删除即可,需要添加 border-none 的记录
// 如果是其他的 borderType,添加的时候,都需要先检查是否存在 border-none,如果存在,则不处理
const info: BorderInfoModelType = {
worker_sheet_id: i,
rangeType,
borderType,
row_start: range[0].row[0],
row_end: range[0].row[1],
col_start: range[0].column[0],
col_end: range[0].column[1],
};
const exist = await BorderInfoService.hasConfigBorder(info);
if (exist) {
// 更新
await BorderInfoService.updateConfigBorder({
config_border_id: exist.config_border_id,
...info,
color,
style: Number(style),
});
} else {
// 创建新的边框记录
await BorderInfoService.createConfigBorder({
...info,
style: Number(style),
color,
});
}
}
}
}
@@ -0,0 +1,45 @@
// 删除的该行,可能会引起其他的一些变化,因此,也会触发 all 事件类型
// {"t":"all","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364","v":[],"k":"calcChain"}
// {"t":"all","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364","v":null,"k":"filter_select"}
// {"t":"all","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364","v":null,"k":"filter"}
// {"t":"all","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364","v":[],"k":"luckysheet_conditionformat_save"}
// {"t":"all","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364","v":[],"k":"luckysheet_alternateformat_save"}
// {"t":"all","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364","v":{},"k":"dataVerification"}
// {"t":"all","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364","v":{},"k":"hyperlink"}
import { CRDTDataType, DRC } from "../../../Interface/WebSocket";
import { CellDataService } from "../../../Service/CellData";
import { HiddenAndLenService } from "../../../Service/HiddenAndLen";
import { isEmpty } from "../../../Utils";
import { logger } from "../../../Utils/Logger";
// 删除行或列 - 会影响 celldata r c 的值,需要更新比新增行列大/小的 r c 值
export async function drc(data: string) {
logger.info("[CRDT DATA]:", data);
const { t, i, v, rc } = <CRDTDataType<DRC>>JSON.parse(data);
if (t !== "drc") return logger.error("t is not drc.");
if (isEmpty(i)) return logger.error("i is undefined.");
// {"t":"drc","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364","v":{"index":5,"len":1},"rc":"r"}
// {"t":"drc","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364","v":{"index":1,"len":5},"rc":"r"}
// {"t":"drc","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364","v":{"index":7,"len":3},"rc":"c"}
// TODO: 删除该列触发的 all 事件
// 删除行,则删除该行所有的列数据
await CellDataService.deleteCellDataRC(i, v.index, <"r" | "c">rc);
// 通过 index len 来实现标记 从那里开始删除、删除多少行
// 删除的索引 index 小的,不需要处理,只需要将 比 索引大的 记录 减小 len 即可
await CellDataService.updateCellDataRC({
worker_sheet_id: i,
index: v.index,
len: v.len,
update_type: <"r" | "c">rc,
});
// 删除该列的 hide and len
await HiddenAndLenService.deleteRC(i, v.index.toString());
}
@@ -0,0 +1,41 @@
import { CRDTDataType } from "../../../Interface/WebSocket";
import { CalcChainService } from "../../../Service/CalcChain";
import { isEmpty } from "../../../Utils";
import { logger } from "../../../Utils/Logger";
// 函数链操作 r c index func color parent children times
export async function fc(data: string) {
// console.log("==> fc", data);
// https://dream-num.github.io/LuckysheetDocs/zh/guide/operate.html#%E5%87%BD%E6%95%B0%E9%93%BE%E6%93%8D%E4%BD%9C
// fc {"t":"fc","i":"4298e271-548d-4207-a826-ecececceef6f","v":"{\"r\":0,\"c\":2,\"index\":\"4298e271-548d-4207-a826-ecececceef6f\"}","op":"add","pos":0}
// {"t":"fc","i":"4298e271-548d-4207-a826-ecececceef6f","v":"{\"r\":6,\"c\":2,\"index\":\"4298e271-548d-4207-a826-ecececceef6f\",\"func\":[true,122,\"=A7+B7\"]}","op":"add","pos":0}
logger.info("[CRDT DATA]:", data);
const { t, i, v, op } = <CRDTDataType<string>>JSON.parse(data);
if (t !== "fc") return logger.error("t is not fc.");
if (isEmpty(i)) return logger.error("i is undefined.");
// 请注意,此处的 v 是字符串
const item = JSON.parse(v);
const { r, c, index, func } = item;
// 如果是 del 直接删除记录
// {"t":"fc","i":"61f708fc-b159-4950-afab-176a81f4e1f6","v":null,"op":"del","pos":0}
if (op === "del") {
await CalcChainService.deleteCalcChain(index, r, c);
return;
}
// 判断是否存在该记录
const calcChain = await CalcChainService.findOne(index, r, c);
// 存在则更新,不存在则创建
if (calcChain) {
await CalcChainService.update(calcChain.calcchain_id!, JSON.stringify(func));
} else {
await CalcChainService.create({
worker_sheet_id: index,
r,
c,
func: JSON.stringify(func),
});
}
}
@@ -0,0 +1,79 @@
import { v } from "./v";
import { rv } from "./rv";
import { cg } from "./cg";
import { all } from "./all";
import { fc } from "./fc";
import { drc } from "./drc";
import { arc } from "./arc";
import { shd } from "./shd";
import { shre } from "./shre";
import { shr } from "./shr";
import { c } from "./c";
import { sh } from "./sh";
import { na } from "./na";
import { sha } from "./sha";
import { shc } from "./shc";
/**
* 处理协同数据存储: 更多操作请参考: https://dream-num.github.io/LuckysheetDocs/zh/guide/operate.html
* 1. 单个单元格刷新 "t": "v",
* 2. 范围单元格刷新 "t": "rv"
* 3. config操作 "t": "cg",
* 4. 通用保存 "t": "all",
* 5. 函数链操作 "t": "fc",
* 6. 行列操作
* - 删除行或列 "t": "drc",
* - 增加行或列 "t": "arc",
* 7. 筛选操作
* - 清除筛选 "t": "fsc",
* - 恢复筛选 "t": "fsr",
* 8. sheet操作
* - 新建sheet "t": "sha",
* - 复制sheet "t": "shc",
* - 删除sheet "t": "shd",
* - 删除sheet后恢复操作 "t": "shre",
* - 调整sheet位置 "t": "shr",
* - 切换到指定sheet "t": "shs",
* 9. sheet属性(隐藏或显示) "t": "sh", ==> "op": "show" / "hide"
* 10. 表格信息更改
* - 修改工作簿名称 "t": "na",
* 11. 图表(TODO)
*
* 注意一点,对象中的i为当前sheet的index值,而不是order
*/
/**
* 协同消息映射的操作
* @param data
*/
export function databaseHandler(data: string, gridKey: string) {
const { t } = JSON.parse(data);
if (t === "v") v(data);
else if (t === "rv") rv(data);
else if (t === "cg") cg(data);
else if (t === "all") all(data);
else if (t === "fc") fc(data);
else if (t === "drc") drc(data);
else if (t === "arc") arc(data);
else if (t === "fsc") fsc(data);
else if (t === "fsr") fsr(data);
else if (t === "sha") sha(data, gridKey);
else if (t === "shc") shc(data, gridKey);
else if (t === "shd") shd(data);
else if (t === "shre") shre(data);
else if (t === "shr") shr(data);
else if (t === "c") c(data);
// else if (t === "shs") shs(data); // 切换到指定 sheet 是前台操作,可不存储数据库
else if (t === "sh") sh(data);
else if (t === "na") na(data, gridKey);
}
// 清除筛选
async function fsc(data: string) {
console.log("==> fsc", data);
}
// 恢复筛选
async function fsr(data: string) {
console.log("==> fsr", data);
}
@@ -0,0 +1,14 @@
import { CRDTDataType } from "../../../Interface/WebSocket";
import { WorkerBookService } from "../../../Service/WorkerBook";
import { logger } from "../../../Utils/Logger";
// 修改工作簿名称
export async function na(data: string, gridKey: string) {
// {"t":"na","i":null,"v":"Luckysheet Demo222"}
logger.info("[CRDT DATA]:", data);
const { t, v } = <CRDTDataType<string>>JSON.parse(data);
if (t !== "na") return logger.error("t is not na.");
// 更新 workerBook name 属性即可 gridkey 在用户身上
await WorkerBookService.update({ title: v, gridKey });
}
@@ -0,0 +1,121 @@
import { CRDTDataType, RV } from "../../../Interface/WebSocket";
import { CellDataService } from "../../../Service/CellData";
import { isEmpty } from "../../../Utils";
import { logger } from "../../../Utils/Logger";
/**
* 范围单元格刷新 - 与 单个单元格刷新的场景一致,也存在 复制粘贴 删除多个单元格 v m =null 的场景,都需要做区分
* @param data
* @returns
*/
export async function rv(data: string) {
/**
* 范围单元格刷新
* 需要先取 range 范围行列数,v 的内容是根据 range 循环而来
*/
const { t, i, v, range } = <CRDTDataType<RV>>JSON.parse(data);
if (t !== "rv") return logger.error("t is not rv.");
if (isEmpty(i)) return logger.error("i is undefined.");
if (isEmpty(range)) return logger.error("range is undefined.");
if (isEmpty(v)) return logger.error("v is undefined.");
logger.info("[CRDT DATA]:", data);
// 范围添加背景颜色存储失败
// {"t":"rv","i":"25a110fd-8e06-4318-a2d2-c42606b266ee","v":[[{"v":null,"bg":"#f19594"},{"v":null,"bg":"#f19594"}],[{"v":null,"bg":"#f19594"},{"v":null,"bg":"#f19594"}]],"range":{"row":[10,11],"column":[9,10]}}
// {"t":"rv","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364",
// "v":[
// [{"ct":{"fa":"General","t":"g"},"v":"B","m":"B","bg":"#FFFFFF","ff":"5","fc":"#000000","bl":false,"it":false,"fs":10,"cl":false,"ht":0,"vt":0,"f":null,"un":false},
// {"ct":{"fa":"General","t":"n"},"v":"111","m":"111","bg":"#FFFFFF","ff":"5","fc":"#000000","bl":false,"it":false,"fs":10,"cl":false,"ht":0,"vt":0,"f":null,"un":false}
// ],
// [{"ct":{"fa":"General","t":"g"},"v":"C","m":"C","bg":"#FFFFFF","ff":"5","fc":"#000000","bl":false,"it":false,"fs":10,"cl":false,"ht":0,"vt":0,"f":null,"un":false},
// {"ct":{"fa":"General","t":"n"},"v":"111","m":"111","bg":"#FFFFFF","ff":"5","fc":"#000000","bl":false,"it":false,"fs":10,"cl":false,"ht":0,"vt":0,"f":null,"un":false}
// ]
// ],"range":{"row":[5,6],"column":[1,2]}}
// {"t":"rv","i":"2b62e1f2-...",
// "v":[
// [{"ct":{"fa":"General","t":"g"},"v":"B","m":"B","bg":"#FFFFFF","ff":"5","fc":"#000000","bl":false,"it":false,"fs":10,"cl":false,"ht":0,"vt":0,"f":null,"un":false},
// {"ct":{"fa":"General","t":"n"},"v":"111","m":"111","bg":"#FFFFFF","ff":"5","fc":"#000000","bl":false,"it":false,"fs":10,"cl":false,"ht":0,"vt":0,"f":null,"un":false}
// ]
// ],
// "range":{"row":[4,4],"column":[0,1]}}
// 循环列,取 v 的内容,然后创建记录
for (let k = 0; k < v.length; k++) {
// 这里面的每一项,都是一条记录
for (let j = 0; j < v[k].length; j++) {
// 解析内部的 r c 值
const item = v[k][j];
const r = range!.row[0] + k;
const c = range!.column[0] + j;
// 场景一:设置空单元格的样式数据 加粗、背景、颜色、字号等
// {"t":"rv","i":"2b62e1f2-7f7f-4889-b34d-007fe7277364","v":[[{"v":null,"bl":1},{"v":null,"bl":1}],[{"v":null,"bl":1},{"v":null,"bl":1}]],"range":{"row":[6,7],"column":[3,4]}}
// 场景二:范围添加内容 v m 不为空
// "v":[
// [{"ct":{"fa":"General","t":"g"},"v":"B","m":"B","bg":"#FFFFFF","ff":"5","fc":"#000000","bl":false,"it":false,"fs":10,"cl":false,"ht":0,"vt":0,"f":null,"un":false},
// {"ct":{"fa":"General","t":"n"},"v":"111","m":"111","bg":"#FFFFFF","ff":"5","fc":"#000000","bl":false,"it":false,"fs":10,"cl":false,"ht":0,"vt":0,"f":null,"un":false}
// ]
// ],
// "range":{"row":[4,4],"column":[0,1]}}
if ((item && item.v === null) || (item && item.v && item.m)) {
// i r c 先判断是否存在记录,存在则更新,不存在则创建
const exist = await CellDataService.hasCellData(i, r, c);
// 检查 item 是否为 null 或 undefined
const cellInfo = {
worker_sheet_id: i,
r,
c,
f: item?.f || "",
ctfa: item?.ct?.fa,
ctt: item?.ct?.t,
v: <string>item?.v || "",
m: <string>item?.m || "",
bg: item?.bg,
bl: <boolean>item?.bl,
cl: <boolean>item?.cl,
fc: item?.fc,
ff: <string>item?.ff,
fs: <number>item?.fs,
ht: item?.ht,
it: <boolean>item?.it,
un: <boolean>item?.un,
vt: item?.vt,
};
if (exist) {
// 如果存在则更新 - 注意全量的样式数据
await CellDataService.updateCellData({
cell_data_id: exist.cell_data_id,
...cellInfo,
bg: cellInfo.bg,
bl: <boolean>cellInfo.bl,
cl: <boolean>cellInfo.cl,
fc: cellInfo.fc,
ff: <string>cellInfo.ff,
});
} else await CellDataService.createCellData(cellInfo);
}
// 复制范围单元格,有样式与删除范围单元格触发的事件参数是一样的,目前未解决BUG
// {"t":"rv","i":"Sheet_eiro660he3z5_1740971352584",
// "v":[
// [{"ct":{"fa":"General","t":"d"},"bg":"#FFFF00","ff":"5","fc":"#FF0000","bl":false,"it":false,"fs":10,"cl":false,"ht":0,"vt":0,"f":null,"un":false,"ps":null}],
// [{"ct":{"fa":"General","t":"d"},"bg":"#FFFF00","ff":"5","fc":"#FF0000","bl":false,"it":false,"fs":10,"cl":false,"ht":0,"vt":0,"f":null,"un":false,"ps":null}]
// ],
// "range":{"row":[5,6],"column":[0,0]}
// }
// {"t":"rv","i":"Sheet_eiro660he3z5_1740971352584",
// "v":[
// [{"ct":{"fa":"General","t":"d"},"bg":"#FFFF00","ff":"5","fc":"#FF0000","bl":false,"it":false,"fs":10,"cl":false,"ht":0,"vt":0,"f":null,"un":false,"ps":null}],
// [{"ct":{"fa":"General","t":"d"},"bg":"#FFFF00","ff":"5","fc":"#FF0000","bl":false,"it":false,"fs":10,"cl":false,"ht":0,"vt":0,"f":null,"un":false,"ps":null}]
// ],
// "range":{"row":[5,6],"column":[0,0]}}
// 场景三:删除单元格内容
else if (item && !item.v && !item.m) {
await CellDataService.deleteCellData(i, r, c); // 删除记录
}
}
}
}
@@ -0,0 +1,21 @@
import { CRDTDataType } from "../../../Interface/WebSocket";
import { WorkerSheetModelType } from "../../../Sequelize/Models/WorkerSheet";
import { WorkerSheetService } from "../../../Service/WorkerSheet";
import { isEmpty } from "../../../Utils";
import { logger } from "../../../Utils/Logger";
// sheet属性(隐藏或显示)
export async function sh(data: string) {
// {"t":"sh","i":"Sheet_06ok13WM3kS3_1734398401711","v":1,"op":"hide","cur":"Sheet_ieo3iK3lo0m3_1734353939113"}
logger.info("[CRDT DATA]:", data);
const { t, v, i } = <CRDTDataType<number>>JSON.parse(data);
if (t !== "sh") return logger.error("t is not sh.");
if (isEmpty(i)) return logger.error("i is empty.");
// 更新 deleteFlag = false
const info = <WorkerSheetModelType>{
worker_sheet_id: i,
hide: Boolean(v),
};
await WorkerSheetService.update(info);
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,82 @@
import { CRDTDataType, SHA } from "../../../Interface/WebSocket";
import { WorkerSheetModelType } from "../../../Sequelize/Models/WorkerSheet";
import { BorderInfoService } from "../../../Service/Border";
import { CellDataService } from "../../../Service/CellData";
import { MergeService } from "../../../Service/Merge";
import { WorkerSheetService } from "../../../Service/WorkerSheet";
import { isEmpty } from "../../../Utils";
import { logger } from "../../../Utils/Logger";
// 复制sheet - 复制了sheet 后,应该从被复制 sheet 的 index 直接复制数据即可,不需要saveParam
export async function shc(data: string, gridKey: string) {
// {"t":"shc","i":"Sheet_0NHdie3o0ba5_1734351308939","v":{"copyindex":"12f8254d-3914-4f79-9886-9f9aec173048","name":"Sheet(副本)"}}
// 至于复制的sheet该放在什么位置上,会同步触发 shr 事件,调整sheet的位置
logger.info("[CRDT DATA]:", data);
const { t, i, v } = <CRDTDataType<SHA>>JSON.parse(data);
if (t !== "shc") return logger.error("t is not shc.");
if (isEmpty(i)) return logger.error("i is empty.");
const copy_sheet_info: WorkerSheetModelType = {
worker_sheet_id: i,
name: v.name,
gridKey,
};
await WorkerSheetService.createSheet(copy_sheet_info);
// 真实实现复制 sheet 数据
await copySheetData(v.copyindex!, i);
}
/**
* 工具函数 - 复制 Sheet 时,需要同步被复制 sheet 的所有数据 borders、celldatas、charts、hidneandlens、images、merges
* @param copyIndex 被复制的 sheet index
* @param newSheetIndex 新 sheet index
*/
async function copySheetData(copyIndex: string, newSheetIndex: string) {
// ==> copyIndex 25a110fd-8e06-4318-a2d2-c42606b266ee
// ==> newSheetIndex Sheet_5330eTr735lz_1743664108612
// 处理思路:先 查询当前被复制 sheet 的所有数据,然后批量插入到新 sheet 中
// borders
const copySheetBorder = await BorderInfoService.findAllBorder(copyIndex);
if (copySheetBorder?.length) {
for (let i = 0; i < copySheetBorder.length; i++) {
const item = copySheetBorder[i].dataValues;
delete item.config_border_id;
// 批量插入到新 sheet 中
await BorderInfoService.createConfigBorder({
...item,
worker_sheet_id: newSheetIndex,
});
}
}
// celldatas
const copySheetCellData = await CellDataService.getCellData(copyIndex);
if (copySheetCellData?.length) {
for (let i = 0; i < copySheetCellData.length; i++) {
const item = copySheetCellData[i].dataValues;
delete item.cell_data_id;
// 批量插入到新 sheet 中
await CellDataService.createCellData({
...item,
worker_sheet_id: newSheetIndex,
});
}
}
// charts hidneandlens images 暂不处理 原理类似哈
// merges
const copySheetMerge = await MergeService.findAllMerge(copyIndex);
if (copySheetMerge?.length) {
for (let i = 0; i < copySheetMerge.length; i++) {
const item = copySheetMerge[i].dataValues;
delete item.config_merge_id;
// 批量插入到新 sheet 中
await MergeService.createMerge({
...item,
worker_sheet_id: newSheetIndex,
});
}
}
}
@@ -0,0 +1,21 @@
// 删除sheet - 不可以直接删除记录,因为还需要恢复,应该标记 deleteFlag 属性即可
// celldata可能存在外键关联,请注意删除顺序
import { CRDTDataType } from "../../../Interface/WebSocket";
import { WorkerSheetModelType } from "../../../Sequelize/Models/WorkerSheet";
import { WorkerSheetService } from "../../../Service/WorkerSheet";
import { logger } from "../../../Utils/Logger";
// 请注意: 删除 Sheet 请真实删除 celldata 数据
export async function shd(data: string) {
logger.info("[CRDT DATA]:", data);
const { t, v } = <CRDTDataType<{ deleIndex: string }>>JSON.parse(data);
if (t !== "shd") return logger.error("t is not shd.");
// {"t":"shd","i":null,"v":{"deleIndex":"Sheet_06ok13WM3kS3_1734398401711"}}
// 更新 deleteFlag = true
const info = <WorkerSheetModelType>{
worker_sheet_id: v.deleIndex,
deleteFlag: true,
};
await WorkerSheetService.update(info);
}
@@ -0,0 +1,22 @@
import { CRDTDataType } from "../../../Interface/WebSocket";
import { WorkerSheetModelType } from "../../../Sequelize/Models/WorkerSheet";
import { WorkerSheetService } from "../../../Service/WorkerSheet";
import { logger } from "../../../Utils/Logger";
// 调整sheet位置
export async function shr(data: string) {
// {"t":"shr","i":null,"v":{"12f8254d-3914-4f79-9886-9f9aec173048":0,"Sheet_0NHdie3o0ba5_1734351308939":1,"Sheet_oi07n566135m_1734351229761":2}}
logger.info("[CRDT DATA]:", data);
const { t, v } = <CRDTDataType<{ [key: string]: number }>>JSON.parse(data);
if (t !== "shr") return logger.error("t is not shr.");
// 循环调整位置
for (const i in v) {
if (Object.prototype.hasOwnProperty.call(v, i)) {
const order = v[i];
// 调整位置
const info = <WorkerSheetModelType>{ worker_sheet_id: i, order };
await WorkerSheetService.update(info);
}
}
}
@@ -0,0 +1,18 @@
import { CRDTDataType } from "../../../Interface/WebSocket";
import { WorkerSheetModelType } from "../../../Sequelize/Models/WorkerSheet";
import { WorkerSheetService } from "../../../Service/WorkerSheet";
import { logger } from "../../../Utils/Logger";
// 删除sheet后恢复操作
export async function shre(data: string) {
// {"t":"shre","i":null,"v":{"reIndex":"Sheet_06ok13WM3kS3_1734398401711"}}
logger.info("[CRDT DATA]:", data);
const { t, v } = <CRDTDataType<{ reIndex: string }>>JSON.parse(data);
if (t !== "shre") return logger.error("t is not shre.");
// 更新 deleteFlag = false
const info = <WorkerSheetModelType>{
worker_sheet_id: v.reIndex,
deleteFlag: false,
};
await WorkerSheetService.update(info);
}
@@ -0,0 +1,212 @@
import { isEmpty } from "../../../Utils";
import { logger } from "../../../Utils/Logger";
import { CellDataService } from "../../../Service/CellData";
import { CRDTDataType, V } from "../../../Interface/WebSocket";
import { CellDataModelType } from "../../../Sequelize/Models/CellData";
// 单个单元格刷新
export async function v(data: string) {
// 1. 解析 rc 单元格
const { t, r, c, v, i } = <CRDTDataType<V>>JSON.parse(data);
logger.info("[CRDT DATA]:", data);
// 纠错判断
if (t !== "v") return logger.error("t is not v.");
if (isEmpty(i)) return logger.error("i is undefined.");
if (isEmpty(r) || isEmpty(c)) return logger.error("r or c is undefined.");
// 场景一 输入内容存在换行符,则内容是通过 s 传输
// {"t":"v","i":"28a60885-46e3-4f59-9d25-442a30fdbba6","v":{"ct":{"fa":"General","t":"inlineStr","s":[{"ff":"Times New Roman","fc":"#000000","fs":10,"cl":0,"un":0,"bl":0,"it":0,"v":"123\r\nhhh"}]}},"r":7,"c":0}
// {"t":"v","i":"28a60885-46e3-4f59-9d25-442a30fdbba6","v":{"ct":{"fa":"General","t":"inlineStr","s":[{"ff":"Times New Roman","fc":"#000000","fs":10,"cl":0,"un":0,"bl":0,"it":0,"v":"jasdjkasdh\r\n换行"}]}},"r":14,"c":1}
if (v && !v.v && v.ct && v.ct.s && v.ct.s.length > 0) {
// 取 v m
const ctfa = v.ct.fa;
const ctt = v.ct.t;
const cts = JSON.stringify(v.ct.s);
// 判断表内是否存在当前记录
const exist = await CellDataService.hasCellData(i, r, c);
const info: CellDataModelType = {
worker_sheet_id: i,
r,
c,
ctfa,
ctt,
cts,
v: "",
m: "",
bg: v.bg,
bl: <boolean>v.bl,
cl: <boolean>v.cl,
fc: v.fc,
ff: <string>v.ff,
fs: <number>v.fs,
ht: v.ht,
it: <boolean>v.it,
un: <boolean>v.un,
vt: v.vt,
tb: <number>v.tb,
ps: <string>v?.ps?.value,
};
// 如果存在则更新
if (exist) {
await CellDataService.updateCellData({
cell_data_id: exist.cell_data_id,
...info,
});
} else {
// 创建新的记录时,当前记录的 cell_data_id 由 sequelize 自动创建
await CellDataService.createCellData(info);
}
}
// 场景一:单个单元格插入值(包括格式刷)
// {"t":"v","i":"e73f971d-606f-4b04-bcf1-98550940e8e3","v":{"v":"123","ct":{"fa":"General","t":"n"},"m":"123"},"r":5,"c":0}
else if (v && !isEmpty(v.v) && !isEmpty(v.m)) {
// 取 v m
const value = <string>v.v;
const monitor = <string>v.m;
// 处理 ct 字段可能缺失的情况(格式刷时常见)
const ctfa = v.ct?.fa || "General";
const ctt = v.ct?.t || "n";
// 判断表内是否存在当前记录
const exist = await CellDataService.hasCellData(i, r, c);
const info: CellDataModelType = {
worker_sheet_id: i,
r,
c,
f: v.f,
v: value,
m: monitor,
ctfa,
ctt,
bg: v.bg,
bl: <boolean>v.bl,
cl: <boolean>v.cl,
fc: v.fc,
ff: <string>v.ff,
fs: <number>v.fs,
ht: v.ht,
it: <boolean>v.it,
un: <boolean>v.un,
vt: v.vt,
tb: <number>v.tb,
ps: <string>v?.ps?.value,
};
// 如果存在则更新
if (exist) {
await CellDataService.updateCellData({
cell_data_id: exist.cell_data_id,
...info,
});
} else {
// 创建新的记录时,当前记录的 cell_data_id 由 sequelize 自动创建
await CellDataService.createCellData(info);
}
}
// 场景二:设置空单元格的样式数据 加粗、背景、颜色、字号等
// {"t":"v","i":"e73f971d-606f-4b04-bcf1-98550940e8e3","v":{"v":null,"bg":"#ff0000"},"r":3,"c":2}
else if (v && v.v === null) {
// 判断 i r c 是否存在
const exist = await CellDataService.hasCellData(i, r, c);
const info: CellDataModelType = {
worker_sheet_id: i,
r,
c,
v: "",
m: "",
f: v.f || "",
bg: v.bg,
bl: <boolean>v.bl,
cl: <boolean>v.cl,
fc: v.fc,
ff: <string>v.ff,
fs: <number>v.fs,
ht: v.ht,
it: <boolean>v.it,
un: <boolean>v.un,
tb: <number>v.tb,
vt: v.vt,
};
if (exist) {
// 如果存在则更新 - 注意全量的样式数据
await CellDataService.updateCellData({
cell_data_id: exist.cell_data_id,
...info,
});
} else await CellDataService.createCellData(info);
}
// 场景三:剪切/粘贴到某个单元格 - 会触发两次广播(一次是删除,一次是创建)
// {"t":"v","i":"e73f971d-606f-4b04-bcf1-98550940e8e3","v":null,"r":9,"c":0}
// {"t":"v","i":"e73f971d-606f-4b04-bcf1-98550940e8e3","v":{"ct":{"fa":"General","t":"n"},"v":"123","m":"123"},"r":13,"c":0}
else if (v === null) {
await CellDataService.deleteCellData(i, r, c);
}
// 场景五:公式链操作时,会直接生成数值
// {"t":"v","i":"61f708fc-b159-4950-afab-176a81f4e1f6","v":6,"r":0,"c":2}
else if (v && typeof v === "number") {
// console.log(" ==> 监听到了");
// 更新 r c 的value 值
const cell = await CellDataService.hasCellData(i, r, c);
await CellDataService.updateCellData({
cell_data_id: cell?.cell_data_id,
worker_sheet_id: i,
r,
c,
v: v,
m: v,
});
}
// 场景六:单元格图片上设置
// {"t":"v","i":"3fc7f0b8-8fda-4e12-8a51-f2bad072b9c5","v":{"f":"=DISPIMG('img_onb00NooTrk6_1757387493023',1)"},"r":5,"c":3}
else if (v && v.f && v.f.startsWith("=DISPIMG(")) {
// 更新 r c 的f 值
// 处理 ct 字段可能缺失的情况(格式刷时常见)
const ctfa = v.ct?.fa || "General";
const ctt = v.ct?.t || "n";
// 判断表内是否存在当前记录
const exist = await CellDataService.hasCellData(i, r, c);
const info: CellDataModelType = {
worker_sheet_id: i,
r,
c,
f: v.f,
v: "",
m: "",
ctfa,
ctt,
};
// 如果存在则更新
if (exist) {
await CellDataService.updateCellData({
cell_data_id: exist.cell_data_id,
...info,
});
} else {
// 创建新的记录时,当前记录的 cell_data_id 由 sequelize 自动创建
await CellDataService.createCellData(info);
}
}
// 场景四: 删除单元格内容
// {"t":"v","i":"e73f971d-606f-4b04-bcf1-98550940e8e3","v":{"ct":{"fa":"General","t":"n"}},"r":5,"c":0}
else if (v && typeof v === "object" && !v.v && !v.m) {
await CellDataService.deleteCellData(i, r, c); // 删除记录
}
}
+58
View File
@@ -0,0 +1,58 @@
import express from "express";
import routes from "./Router";
import { DB } from "./Sequelize/index"; // 导入 DB
import { logger } from "./Utils/Logger";
import { initMeddlewear } from "./Middleware";
import { WorkerBookService } from "./Service/WorkerBook";
import { SERVER_PORT, WORKER_BOOK_INFO } from "./Config";
import { createWebSocketServer } from "./WebSocket/index"; // 导入 ws
/**
* @author https://gitee.com/wfeng0/luckysheet-crdt
* @description Luckysheet CRDT 协同全功能实现 - Server 服务端
* @license Apache 2.0
* @copyright Copyright (c) 2025 https://gitee.com/wfeng0/luckysheet-crdt
* @time 2024年12月05日
*/
(async () => {
logger.info("✨ Server is starting, wait a moment please... ");
/** 创建 express 实例对象 */
const app = express();
/** 注册中间件 */
initMeddlewear(app);
/** 连接数据库 DB 请注意需要 await 不然后续的操作可能会受到影响 */
try {
await DB.connect();
} catch (error) {
logger.error(error);
}
/** 初始化路由 */
app.use(routes);
/** 监听端口,获取 server 对象 */
const server = app.listen(SERVER_PORT);
logger.info(`✅️ Server is started at http://localhost:${SERVER_PORT} !`);
/** 初始化 WebSocket - 传入 server 对象 */
createWebSocketServer(server);
/**
* ⛔️ 注意:
* 目前前台没有按钮创建工作簿,因此,需要提供一个默认的文件初始化, 也就是需要初始化一个 workerbooks 记录
* 如果前台能通过 router 调用 WorkerBooksService.create 时,会直接返回一个默认文件,则应该注释下列代码
* service 中已经做了兼容处理,不会重复添加 workerbook
* **下列代码仅作演示使用**
*/
try {
if (!DB.getConnectState()) return;
await WorkerBookService.create(WORKER_BOOK_INFO);
} catch (error) {
logger.error(error);
}
})();
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./build"
},
"exclude": ["scripts"]
}