03- Luckysheet与Vue3+Electron集成实战指南
本文基于「工会预决算报表填报查询系统」项目经验,手把手教你将 Luckysheet 电子表格集成到 Vue 3 + Electron 桌面应用中,涵盖组件封装、生命周期管理、IPC 通信、开发环境配置等完整流程。
目录
- 一、项目架构总览
- 二、环境准备与项目搭建
- [三、Luckysheet Vue 组件封装](#三、Luckysheet Vue 组件封装)
- 四、生命周期与内存管理
- [五、Electron 主进程与渲染进程通信](#五、Electron 主进程与渲染进程通信)
- 六、开发/生产环境适配
- [七、与 Pinia 状态管理结合](#七、与 Pinia 状态管理结合)
- 八、实战:报表填报页面完整实现
- 九、常见问题与解决方案
一、项目架构总览
1.1 技术栈
| 层级 | 技术 | 作用 |
|---|---|---|
| 桌面框架 | Electron 37 | 桌面应用容器 |
| 前端框架 | Vue 3.5 + TypeScript | UI 框架 |
| 构建工具 | Vite 7 | 开发构建 |
| 状态管理 | Pinia 3 | 全局状态 |
| UI 组件库 | Element Plus 2.10 | 基础组件 |
| 电子表格 | Luckysheet | 在线表格 |
| Excel 处理 | ExcelJS + LuckyExcel | 导入导出 |
| 本地数据库 | SQLite3 | 数据持久化 |
| 路由 | Vue Router 4.5 | 页面路由 |
1.2 整体架构图
┌─────────────────────────────────────────────────────┐
│ Electron 主进程 │
│ ┌───────────┐ ┌───────────┐ ┌─────────────────┐ │
│ │ main.ts │ │ SQLite │ │ 文件系统操作 │ │
│ │ (窗口) │ │ (数据) │ │ (xlsx/备份) │ │
│ └─────┬─────┘ └─────┬─────┘ └────────┬────────┘ │
│ │ │ │ │
│ └──────────────┼──────────────────┘ │
│ │ IPC │
└───────────────────────┼──────────────────────────────┘
│
┌───────────────────────┼──────────────────────────────┐
│ Electron 渲染进程 │
│ ┌────────────────────┴────────────────────┐ │
│ │ Vue 3 应用 │ │
│ │ ┌─────────┐ ┌───────┐ ┌───────────┐ │ │
│ │ │ 路由 │ │ Pinia │ │ Element + │ │ │
│ │ │ Router │ │ Store │ │ Luckysheet│ │ │
│ │ └─────────┘ └───────┘ └─────┬─────┘ │ │
│ │ │ │ │
│ │ ┌─────────┐ ┌───────┐ ┌────┴────┐ │ │
│ │ │ 填报页 │ │ 查询页 │ │ 分析页 │ │ │
│ │ │ Fill │ │ Query │ │Analysis │ │ │
│ │ └─────────┘ └───────┘ └─────────┘ │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
1.3 项目目录结构
ghbbxt/
├── electron/ # Electron 主进程代码
│ ├── main.ts # 主进程入口
│ ├── preload.ts # 预加载脚本
│ ├── menu.ts # 应用菜单
│ ├── taskScheduler.ts # 定时任务
│ ├── db/ # 数据库层
│ │ ├── db.ts # 数据库连接
│ │ ├── index.ts # 模型聚合
│ │ ├── migrations/ # 迁移脚本
│ │ └── models/ # 数据模型
│ ├── fs/ # 文件系统操作
│ │ ├── xlsx.ts # Excel 读写
│ │ ├── options.ts # 选项读写
│ │ └── organization.ts # 组织结构导入
│ └── auth/ # 授权认证
├── src/ # 渲染进程(Vue)代码
│ ├── main.ts # Vue 入口
│ ├── App.vue # 根组件
│ ├── router/ # 路由
│ ├── stores/ # Pinia 状态
│ ├── components/ # 组件
│ │ └── luckysheet/ # Luckysheet 封装组件
│ ├── views/ # 页面视图
│ │ ├── fill/ # 报表填报
│ │ ├── query/ # 报表查询
│ │ ├── analysis/ # 数据分析
│ │ └── ...
│ ├── api/ # API 封装
│ ├── configs/ # 配置文件
│ │ ├── data.ts # 表格模板数据
│ │ ├── editCell.ts # 可编辑单元格配置
│ │ └── calcCell.ts # 公式配置
│ └── utils/ # 工具函数
│ └── exportXlsx.ts # Excel 导出工具
├── public/ # 静态资源
│ ├── static/luckysheet/ # Luckysheet 静态文件
│ ├── static/model.xlsx # Excel 模板
│ └── config/config.json # 配置文件
└── package.json
二、环境准备与项目搭建
2.1 依赖安装
bash
# 核心依赖
npm install electron --save-dev
npm install vue@3 vue-router@4 pinia
npm install element-plus @element-plus/icons-vue
npm install vite @vitejs/plugin-vue --save-dev
# 电子表格相关
npm install exceljs luckyexcel xlsx big.js
# Electron 构建工具
npm install electron-builder --save-dev
# 本地数据库
npm install sqlite3
npm install @types/sqlite3 --save-dev
# 工具库
npm install nodemon --save-dev
npm install sass --save-dev
2.2 Vite 配置
typescript
// vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
import path from "path";
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
// 注意:Luckysheet 是通过 index.html 静态引入的
// 不需要在 vite 中配置
base: "./", // Electron 打包需要相对路径
});
2.3 HTML 中引入 Luckysheet
html
<!-- index.html -->
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<!-- Luckysheet 样式 -->
<link rel="stylesheet" href="/static/luckysheet/plugins/css/pluginsCss.css">
<link rel="stylesheet" href="/static/luckysheet/plugins/plugins.css">
<link rel="stylesheet" href="/static/luckysheet/css/luckysheet.css">
<link rel="stylesheet" href="/static/luckysheet/assets/iconfont/iconfont.css">
<!-- Luckysheet 脚本 -->
<script src="/static/luckysheet/plugins/js/plugin.js"></script>
<script src="/static/luckysheet/luckysheet.umd.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>工会预决算报表填报查询系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
为什么不通过 npm 安装 Luckysheet?
- Luckysheet 的 UMD 包包含了所有插件,体积较大
- 静态资源(字体、样式图片等)需要手动处理路径
- 静态引入更稳定,不受构建工具影响
- 通过 CDN 或本地静态目录加载,可利用浏览器缓存
2.4 TypeScript 类型声明
由于 Luckysheet 是全局变量,需要在 TypeScript 中声明:
typescript
// src/vite-env.d.ts
/// <reference types="vite/client" />
declare global {
interface Window {
luckysheet: any;
electronAPI: any;
myService: any;
}
}
export {};
三、Luckysheet Vue 组件封装
3.1 组件设计原则
封装 Luckysheet 组件时,遵循以下原则:
- 单一职责:组件只负责表格的创建、销毁和基本配置
- 可配置:通过 props 控制工具栏、编辑权限等
- 生命周期友好:正确处理创建和销毁
- 事件驱动:通过事件通知父组件表格状态
3.2 完整组件实现
vue
<!-- src/components/luckysheet/index.vue -->
<template>
<div
id="luckysheet"
style="width: 100%; height: 100%; margin: 0; padding: 0"
></div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import { useUserStore } from "@/stores/userStore";
import { data } from "../../configs/data";
// 定义事件
const emit = defineEmits(["sheet-ready"]);
// 状态
const user = useUserStore();
const isSheetReady = ref(false);
// Props 定义
const props = defineProps({
sheetFormulaBar: {
type: Boolean,
default: true, // 是否显示公式栏
},
showinfobar: {
type: Boolean,
default: true, // 是否显示信息栏
},
showtoolbar: {
type: Boolean,
default: true, // 是否显示工具栏
},
allowEdit: {
type: Boolean,
default: true, // 是否允许编辑
},
});
/**
* 初始化 Luckysheet
* 使用配置文件中的模板数据
*/
const initXlsx = async () => {
window.luckysheet.create({
container: "luckysheet", // 容器 ID
data: data, // 表格数据
title: "工会财务报表填报查询系统", // 标题
userInfo: user.name, // 用户信息(用于协作显示)
lang: "zh", // 语言
autoCalc: true, // 自动计算
sheetFormulaBar: props.sheetFormulaBar,
showinfobar: props.showinfobar,
showtoolbar: props.showtoolbar,
allowEdit: props.allowEdit,
forceCalculation: true, // 强制重算公式
});
isSheetReady.value = true;
};
// 暴露方法给父组件
defineExpose({ initXlsx });
// 监听表格就绪状态,触发事件
watch(isSheetReady, (newValue) => {
if (newValue === true) {
setTimeout(() => {
emit("sheet-ready");
isSheetReady.value = false;
}, 100);
}
});
onMounted(async () => {
// 不在挂载时自动初始化,由父组件控制时机
// 因为可能需要等待数据加载
});
onBeforeUnmount(() => {
isSheetReady.value = false;
// 组件卸载时销毁 Luckysheet 实例,释放内存
if (window.luckysheet && window.luckysheet.destroy) {
window.luckysheet.destroy();
}
});
</script>
3.3 组件使用方式
vue
<template>
<div>
<LuckySheet
ref="luckySheetRef"
:sheetFormulaBar="true"
:showinfobar="false"
:showtoolbar="false"
:allowEdit="true"
@sheet-ready="handleInitData"
/>
</div>
</template>
<script setup>
import { ref, nextTick, onMounted } from "vue";
import LuckySheet from "@/components/luckysheet/index.vue";
const luckySheetRef = ref<any>(null);
const handleInitData = () => {
console.log("表格已就绪,可以加载数据了");
// 从数据库加载数据并填充到表格
};
onMounted(() => {
nextTick(async () => {
if (luckySheetRef.value) {
await luckySheetRef.value.initXlsx();
}
});
});
</script>
3.4 为什么用 nextTick + ref 调用
typescript
// ❌ 直接在 onMounted 中调用可能失败
onMounted(() => {
luckySheetRef.value.initXlsx(); // 可能还没渲染完成
});
// ✅ 用 nextTick 确保 DOM 已渲染
onMounted(() => {
nextTick(async () => {
if (luckySheetRef.value) {
await luckySheetRef.value.initXlsx();
}
});
});
原因:
- Luckysheet 需要容器 DOM 存在才能初始化
- Vue 的
onMounted只保证组件挂载,不保证子组件内部 DOM 完全就绪 nextTick确保下一次 DOM 更新后再执行
四、生命周期与内存管理
4.1 Luckysheet 生命周期管理
Luckysheet 实例是全局的,管理不当容易导致内存泄漏:
组件挂载
↓
initXlsx() → luckysheet.create()
↓
用户操作(编辑、保存等)
↓
组件卸载
↓
luckysheet.destroy() → 清理内存
4.2 内存泄漏的常见原因
| 原因 | 后果 | 解决方案 |
|---|---|---|
未调用 destroy() |
Canvas 上下文未释放,内存持续增长 | 在 onBeforeUnmount 中销毁 |
| 事件监听未移除 | 回调函数持有组件引用 | Luckysheet 销毁时自动清理 |
| 定时器未清理 | 定时任务持续运行 | 组件卸载时 clearInterval |
| 全局变量引用 | 数据无法被 GC | 销毁时置空引用 |
4.3 多页面切换的正确姿势
在 SPA 应用中,用户可能在多个表格页面间切换:
typescript
// 填报页 → 查询页 → 填报页
// 每次切换都要重新创建 Luckysheet
// fill/index.vue
onBeforeUnmount(() => {
if (window.luckysheet && window.luckysheet.destroy) {
window.luckysheet.destroy();
}
});
// query/index.vue
onBeforeUnmount(() => {
if (window.luckysheet && window.luckysheet.destroy) {
window.luckysheet.destroy();
}
});
注意 :Luckysheet 是单实例的(全局只有一个
window.luckysheet),如果同时存在两个表格组件,会互相覆盖。每个页面应该在自己的生命周期内管理实例。
4.4 编辑模式退出
保存或导出前,必须确保单元格已退出编辑模式,否则最后一个编辑的单元格数据可能丢失:
typescript
// 保存前退出编辑模式
const handleSave = async () => {
await window.luckysheet.exitEditMode();
// 然后再获取数据...
};
// 导出前同样需要
const handleExport = async () => {
await window.luckysheet.exitEditMode();
// 然后导出...
};
五、Electron 主进程与渲染进程通信
5.1 IPC 通信架构
┌──────────────┐ ipcMain.handle ┌──────────────┐
│ 渲染进程 │ ──────────────────→ │ 主进程 │
│ (Vue 页面) │ │ (Node.js) │
│ │ ←────────────────── │ │
└──────────────┘ invoke 返回值 └──────────────┘
5.2 主进程注册 IPC 处理器
typescript
// electron/main.ts
import { app, BrowserWindow, ipcMain } from "electron";
import { readXlsx, writeXlsx } from "./fs/xlsx";
app.whenReady().then(() => {
// 读取 Excel 模板
ipcMain.handle("readXlsx", async () => readXlsx());
// 写入 Excel 文件
ipcMain.handle("writeXlsx", async (event, data) => writeXlsx(data));
// 数据库操作
ipcMain.handle("getFillReport", async (event, report_order, row_num, column_num) => {
return db.fillReport.get(report_order, row_num, column_num);
});
ipcMain.handle("insertFillReport", async (event, data) => {
return db.fillReport.insert(data);
});
ipcMain.handle("updateFillReport", async (event, data) => {
return db.fillReport.update(data);
});
// 文件对话框
ipcMain.handle("dialog:openFile", async (event, options = {}) => {
const result = await dialog.showOpenDialog({
properties: ["openFile"],
filters: [{ name: "Excel Files", extensions: ["xlsx", "xls"] }],
...options,
});
return {
canceled: result.canceled,
filePaths: result.filePaths,
};
});
});
5.3 Preload 脚本暴露 API
typescript
// electron/preload.ts
import { contextBridge, ipcRenderer } from "electron";
// 通过 contextBridge 安全地暴露 API
contextBridge.exposeInMainWorld("electronAPI", {
// 报表操作
getFillReport: (report_order: number, row_num: number, column_num: number) =>
ipcRenderer.invoke("getFillReport", report_order, row_num, column_num),
insertFillReport: (data: any) =>
ipcRenderer.invoke("insertFillReport", data),
updateFillReport: (data: any) =>
ipcRenderer.invoke("updateFillReport", data),
getAllFillReport: () =>
ipcRenderer.invoke("getAllFillReport"),
resetFillReport: () =>
ipcRenderer.invoke("resetFillReport"),
// 文件操作
readXlsx: () => ipcRenderer.invoke("readXlsx"),
writeXlsx: (data: any) => ipcRenderer.invoke("writeXlsx", data),
// 对话框
openFileDialog: (options: any) =>
ipcRenderer.invoke("dialog:openFile", options),
saveFileDialog: (options: any) =>
ipcRenderer.invoke("dialog:saveFile", options),
openDirectoryDialog: (options: any) =>
ipcRenderer.invoke("dialog:openDirectory", options),
});
5.4 渲染进程中使用
typescript
// src/api/report.ts
export const ReportAPI = {
async getFillReport(report_order: number, row_num: number, column_num: number) {
return window.electronAPI.getFillReport(report_order, row_num, column_num);
},
async insertFillReport(data: any) {
return window.electronAPI.insertFillReport(data);
},
async updateFillReport(data: any) {
return window.electronAPI.updateFillReport(data);
},
async getAllFillReport() {
return window.electronAPI.getAllFillReport();
},
async resetFillReport() {
return window.electronAPI.resetFillReport();
},
};
5.5 为什么用 contextBridge
Electron 推荐使用 contextBridge 而不是直接暴露 ipcRenderer:
| 方式 | 安全性 | 便利性 |
|---|---|---|
nodeIntegration: true |
❌ 高风险 | 高 |
直接暴露 ipcRenderer |
⚠️ 中风险 | 高 |
contextBridge 白名单 |
✅ 安全 | 中 |
contextBridge 的好处是只暴露明确指定的 API,渲染进程无法访问 Node.js API,降低了 XSS 攻击的风险。
六、开发/生产环境适配
6.1 开发环境 vs 生产环境
| 项目 | 开发环境 | 生产环境 |
|---|---|---|
| 前端加载方式 | http://localhost:5173 |
file://dist/index.html |
| 静态资源路径 | public/static/ |
resources/static/ |
| 配置文件路径 | public/config/ |
resources/config/ |
| 调试工具 | 打开 DevTools | 关闭 |
6.2 主进程中的环境判断
typescript
// electron/main.ts
const isDev = process.env.NODE_ENV === "development";
const url = isDev
? "http://localhost:5173"
: `file://${path.join(__dirname, "../dist/index.html")}`;
win.loadURL(url);
6.3 文件路径适配
typescript
// electron/fs/xlsx.ts
const modelXlsxPath = process.env.NODE_ENV === "development"
? path.join(__dirname, "../../public/static/model.xlsx") // 开发环境
: path.join(process.resourcesPath, "static/model.xlsx"); // 生产环境
6.4 electron-builder 配置
json
// package.json
{
"build": {
"appId": "com.gh.bbxt",
"productName": "ghbbxt",
"directories": {
"buildResources": "build",
"output": "dist_electron"
},
"files": [
"dist/**/*",
"dist-electron/**/*",
"node_modules/**/*",
"public/**/*"
],
"extraResources": [
{ "from": "public/static", "to": "static", "filter": ["**/*"] },
{ "from": "public/config", "to": "config", "filter": ["**/*"] },
{ "from": "public/docs", "to": "docs", "filter": ["**/*"] },
{ "from": "public/images", "to": "images", "filter": ["**/*"] }
],
"win": {
"target": "nsis",
"icon": "./build/icon.ico"
}
}
}
extraResources中的文件会被复制到安装目录的resources文件夹下,通过process.resourcesPath访问。
6.5 启动脚本
json
{
"scripts": {
"dev": "vite",
"build": "vite build",
"build-electron": "tsc -p electron/tsconfig.json",
"start": "npm run build-electron && nodemon --exec \"npm run build-electron && electron .\" --watch ./electron --ext .ts",
"build:win": "npm run clean && npm run build && npm run build-electron && electron-builder --win"
}
}
开发时需要同时启动两个进程:
- Vite 开发服务器 :
npm run dev(端口 5173) - Electron 应用 :
npm run start(加载 localhost:5173)
七、与 Pinia 状态管理结合
7.1 用户状态 Store
typescript
// src/stores/userStore.ts
import { defineStore } from "pinia";
export const useUserStore = defineStore("user", {
state: () => ({
name: "管理员",
reportName: "",
reportCode: "",
organization: null as any,
}),
actions: {
setName(name: string) {
this.name = name;
},
},
});
7.2 操作状态 Store
typescript
// src/stores/operateStore.ts
import { defineStore } from "pinia";
export const useOperateStore = defineStore("operate", {
state: () => ({
currentOperate: "首页",
}),
actions: {
updateCurrentOperate(operate: string) {
this.currentOperate = operate;
},
},
});
7.3 在表格组件中使用
typescript
const user = useUserStore();
// 初始化时传入用户信息
luckysheet.create({
userInfo: user.name,
// ...
});
八、实战:报表填报页面完整实现
8.1 页面结构
报表填报页
├── 工具栏(保存、重置、导出)
└── Luckysheet 表格区域
8.2 完整代码
vue
<!-- src/views/fill/index.vue -->
<template>
<div class="container">
<div class="content">
<!-- 工具栏 -->
<div class="tools">
<el-button @click="handleSave">保存</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button @click="handleExport">导出</el-button>
</div>
<!-- 表格区域 -->
<div class="sheet" v-loading="loading" element-loading-text="数据加载中...">
<LuckySheet
ref="luckySheetRef"
:sheetFormulaBar="true"
:showinfobar="false"
:showtoolbar="false"
:allowEdit="true"
@sheet-ready="handleInitData"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onBeforeUnmount, onMounted, nextTick } from "vue";
import { useOperateStore } from "@/stores/operateStore";
import LuckySheet from "@/components/luckysheet/index.vue";
import { ElMessage, ElMessageBox } from "element-plus";
import exportSheetExcel from "@/utils/exportXlsx";
import { editCell } from "../../configs/editCell";
import { ReportAPI } from "@/api/report";
const operate = useOperateStore();
const loading = ref(true);
const data = ref<any>([]);
const luckySheetRef = ref<any>(null);
// ===== 保存功能 =====
const handleSave = async () => {
await window.luckysheet.exitEditMode();
try {
await Promise.all(
editCell
.map((reportGroup) => {
return reportGroup.cell.map(async (cellItem) => {
// 获取单元格值
const temp = luckysheet.getCellValue(
cellItem.row_num,
cellItem.column_num,
{ order: reportGroup.report_order }
);
const fillReport = {
report_order: reportGroup.report_order,
row_num: cellItem.row_num,
column_num: cellItem.column_num,
cell_value: temp,
};
// 查询已有数据,存在则更新,不存在则插入
const res = await ReportAPI.getFillReport(
reportGroup.report_order,
cellItem.row_num,
cellItem.column_num
);
if (res?.cell_value !== undefined) {
await ReportAPI.updateFillReport(fillReport);
} else {
await ReportAPI.insertFillReport(fillReport);
}
});
})
.flat()
);
// 重新获取最新数据
const newData = await ReportAPI.getAllFillReport();
data.value = newData;
ElMessage.success("保存成功");
} catch (error: any) {
ElMessage.error("保存失败: " + error.message);
}
};
// ===== 重置功能 =====
const handleReset = async () => {
try {
await ElMessageBox.confirm(
"重置将清空所有已填入数据重新填报!",
"确认重置",
{ type: "warning" }
);
await ReportAPI.resetFillReport();
const newData = await ReportAPI.getAllFillReport();
data.value = newData;
await setCellValues();
ElMessage.success("重置成功");
} catch (error) {
if (error !== "cancel") {
ElMessage.error("重置失败: " + error);
}
}
};
// ===== 导出功能 =====
const handleExport = async () => {
await window.luckysheet.exitEditMode();
try {
const allSheetData = luckysheet.getluckysheetfile();
const { downloadExcel } = exportSheetExcel(allSheetData);
await downloadExcel();
} catch (error: any) {
ElMessage.error("导出失败: " + error.message);
}
};
// ===== 设置单元格值(从数据库加载) =====
const setCellValues = async () => {
await window.luckysheet.setSheetActive(0);
try {
const fillReportData = await ReportAPI.getAllFillReport();
if (Array.isArray(fillReportData) && fillReportData.length > 0) {
data.value = fillReportData;
fillReportData.forEach((item) => {
if (
typeof item.row_num === "number" &&
typeof item.column_num === "number" &&
item.cell_value !== undefined
) {
luckysheet.setCellValue(
item.row_num,
item.column_num,
item.cell_value,
{ order: item.report_order }
);
}
});
} else {
data.value = [];
}
} catch (error) {
console.error("加载数据失败:", error);
} finally {
luckysheet.refresh();
loading.value = false;
}
};
// 表格就绪后加载数据
const handleInitData = async () => {
setCellValues();
};
// ===== 生命周期 =====
onMounted(async () => {
operate.updateCurrentOperate("报表填报页");
loading.value = true;
nextTick(async () => {
if (luckySheetRef.value) {
await luckySheetRef.value.initXlsx();
}
});
});
onBeforeUnmount(() => {
loading.value = true;
if (window.luckysheet && window.luckysheet.destroy) {
window.luckysheet.destroy();
}
});
</script>
<style lang="scss" scoped>
.container {
display: flex;
width: 100%;
height: 100%;
.content {
width: 100%;
height: 100%;
.tools {
display: flex;
width: 100%;
height: 32px;
}
.sheet {
width: 100%;
height: calc(100% - 32px);
}
}
}
</style>
8.3 保存逻辑详解
保存时使用 Upsert 模式(存在则更新,不存在则插入):
遍历配置的可编辑单元格
↓
获取单元格当前值
↓
查询数据库中是否已有记录
├─ 有 → 执行 UPDATE
└─ 无 → 执行 INSERT
↓
全部完成后提示保存成功
使用 Promise.all 并发执行所有数据库操作,提升保存速度。
九、常见问题与解决方案
9.1 Luckysheet 找不到 / undefined
问题 :window.luckysheet is undefined
原因:
- Luckysheet 的 JS 文件没加载成功
- 加载顺序不对(在 Vue 挂载后才加载)
解决方案:
- 确认
index.html中的脚本路径正确 - 检查控制台 404 错误
- 在
onMounted中加延迟或检查window.luckysheet是否存在
typescript
onMounted(() => {
// 等待 Luckysheet 加载完成
const checkLuckysheet = () => {
if (window.luckysheet) {
initXlsx();
} else {
setTimeout(checkLuckysheet, 100);
}
};
checkLuckysheet();
});
9.2 生产环境白屏
问题:开发环境正常,打包后白屏
原因:
base路径配置不对- 静态资源路径问题
- Luckysheet 资源未正确打包
解决方案:
typescript
// vite.config.ts
export default defineConfig({
base: "./", // 使用相对路径
});
确认 electron-builder 的 files 配置包含了 public 目录。
9.3 IPC 调用无响应
问题:渲染进程调用 API 没反应
排查步骤:
- 检查主进程是否注册了对应的
ipcMain.handle - 检查
preload.ts是否暴露了对应方法 - 检查
contextIsolation是否为true(默认值) - 查看主进程控制台是否有报错
9.4 表格高度不正确
问题:表格只显示一部分,或者出现滚动条
解决方案:
css
/* 确保父元素有确定的高度 */
.sheet-container {
width: 100%;
height: 100%; /* 或者 calc(100vh - xxxpx) */
}
#luckysheet {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
9.5 切换页面后表格不显示
问题:从其他页面切回表格页面,表格区域空白
原因:Luckysheet 实例在上一次卸载时被销毁了,但没有重新创建
解决方案 :确保每次进入页面时都调用 initXlsx(),使用 onActivated(keep-alive 场景)或 onMounted。
总结
本文从项目架构、组件封装、生命周期、IPC 通信、环境适配等多个维度,详细介绍了 Luckysheet 与 Vue 3 + Electron 的集成方案。
核心要点回顾:
- Luckysheet 通过静态资源引入,组件化封装管理生命周期
- Electron 使用 IPC 进行主渲染进程通信,通过 contextBridge 安全暴露 API
- 开发/生产环境的路径差异需要特别处理
- 正确的生命周期管理是避免内存泄漏的关键
掌握这些知识,你就能在 Electron 桌面应用中优雅地集成 Luckysheet 电子表格了。
参考资料
关于作者:本文基于工会预决算报表填报查询系统的实际开发经验撰写,涵盖了 Luckysheet 在 Vue3 + Electron 项目中的完整集成方案。如果觉得有帮助,欢迎点赞、收藏、关注!