03- Luckysheet与Vue3+Electron集成实战指南

03- Luckysheet与Vue3+Electron集成实战指南

本文基于「工会预决算报表填报查询系统」项目经验,手把手教你将 Luckysheet 电子表格集成到 Vue 3 + Electron 桌面应用中,涵盖组件封装、生命周期管理、IPC 通信、开发环境配置等完整流程。


目录


一、项目架构总览

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?

  1. Luckysheet 的 UMD 包包含了所有插件,体积较大
  2. 静态资源(字体、样式图片等)需要手动处理路径
  3. 静态引入更稳定,不受构建工具影响
  4. 通过 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 组件时,遵循以下原则:

  1. 单一职责:组件只负责表格的创建、销毁和基本配置
  2. 可配置:通过 props 控制工具栏、编辑权限等
  3. 生命周期友好:正确处理创建和销毁
  4. 事件驱动:通过事件通知父组件表格状态

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();
    }
  });
});

原因:

  1. Luckysheet 需要容器 DOM 存在才能初始化
  2. Vue 的 onMounted 只保证组件挂载,不保证子组件内部 DOM 完全就绪
  3. 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"
  }
}

开发时需要同时启动两个进程:

  1. Vite 开发服务器 :npm run dev(端口 5173)
  2. 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 挂载后才加载)

解决方案:

  1. 确认 index.html 中的脚本路径正确
  2. 检查控制台 404 错误
  3. 在 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 没反应

排查步骤:

  1. 检查主进程是否注册了对应的 ipcMain.handle
  2. 检查 preload.ts 是否暴露了对应方法
  3. 检查 contextIsolation 是否为 true(默认值)
  4. 查看主进程控制台是否有报错

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 的集成方案。

核心要点回顾:

  1. Luckysheet 通过静态资源引入,组件化封装管理生命周期
  2. Electron 使用 IPC 进行主渲染进程通信,通过 contextBridge 安全暴露 API
  3. 开发/生产环境的路径差异需要特别处理
  4. 正确的生命周期管理是避免内存泄漏的关键

掌握这些知识,你就能在 Electron 桌面应用中优雅地集成 Luckysheet 电子表格了。


参考资料


关于作者:本文基于工会预决算报表填报查询系统的实际开发经验撰写,涵盖了 Luckysheet 在 Vue3 + Electron 项目中的完整集成方案。如果觉得有帮助,欢迎点赞、收藏、关注!

相关推荐
fellow992 天前
Electron + pnpm 在鸿蒙应用里跑起来
electron·harmonyos·openharmony·deepseek·deepseekharness
记得开心一点嘛2 天前
Trellora:基于 Electron、React 和本地知识库的 AI 知识工作台
人工智能·react.js·electron
500842 天前
React Native for OpenHarmony 实战:三方库 react-native-url-polyfill 的鸿蒙化适配指南
javascript·react native·react.js·性能优化·electron·harmonyos
不可能片场2 天前
内容运营自动化 用相似度拦住撞车选题
前端·electron
格数致用2 天前
01 - Luckysheet电子表格技术原理与架构深度解析
canvas·luckysheet·企业级报表
500842 天前
React Native for OpenHarmony 实战:三方库 react-native-volume-control 的鸿蒙化适配指南
javascript·react native·react.js·electron·harmonyos
凤城老人3 天前
从 PyQt6 到 Electron:给 Edge TTS 做一个“多角色配音机“的踩坑手记
javascript·typescript·electron
Helix2504 天前
Electron、WebView 框架与 HTA 对比:桌面应用选型、安全性与现代替代方案
前端·javascript·electron·webview·vbscript·hta·本地html应用
不可能片场4 天前
AI视频首尾帧控制 用参考图定住镜头
前端·electron