Electron 初体验

2025博客之星年度评选已开启 10w+人浏览 1.5k人参与

用户输入一些东西,然后选择本地的文件夹路径,等选好后会点击提交,提交就是将用户填写的东西传参给该电脑上的脚本,让其跑出一些文件。

这个需求,一开始描述的时候,同事说的服务器,菜鸟就以为是做一个网页,给用户访问,那问题就来了:怎么去获得服务器上的文件路径?纯前端做不到啊!

当时给菜鸟整懵逼了,后面交流才发现,是将服务器和脚本一起给用户或者理解为用户用向日葵远程服务器!

然后大佬就说了,这种情况,可以用Electron,因为有node在里面,所以可以获取路径,正好也可以搞成一个桌面软件!

初识 Electron

这个菜鸟是直接看官网:https://electron.nodejs.cn/docs/latest/tutorial/tutorial-first-app/

按照官网的来,是那种从零搭建的,不是和vue一样,搭建起来就有很多内置的东西!

坑点

这里一步一步照着来就行,没啥大问题,最大的问题就是Electron可能下载不完全!

菜鸟一开始想用pnpm下载的,下载速度确实快,但是差东西也是真的差,反正运行就报错:

barcodeformedicinal@1.0.0 dev

electron .

F:\proGitLab\BarcodeForMedicinal\node_modules.pnpm\electron@30.5.1\node_modules\electron\index.js:17

throw new Error(‘Electron failed to install correctly, please delete node_modules/electron and try installing again’);

Error: Electron failed to install correctly, please delete node_modules/electron and try installing again

菜鸟一开始问Trae,说可能是最新版本的Electron国内没有稳定镜像,需要换成28~32之间的版本,所以把package.json改成

{
  "name": "barcodeformedicinal",
  "version": "1.0.0",
  "main": "main.js",
  "scripts": {
    "dev": "electron .",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "description": "",
  "devDependencies": {
    "electron": "30.x"
  }
}

pnpm install electron --save-dev下载之后,就会自动变成一个30比较稳定的版本了。

但还没有用,继续报错,菜鸟就尝试设置pnpm为国内镜像

pnpm config set registry <https://registry.npmmirror.com>

还是没有用,所以只能还是用npm设置镜像为国内

npm config set registry <https://registry.npmmirror.com>

并新建一个.npmrc文件,内容如下

electron\_mirror=<https://npmmirror.com/mirrors/electron/>

然后执行

npm i

可能会卡在这一步

npm warn deprecated boolean\@3.2.0: Package no longer supported. Contact Support at <https://www.npmjs.com/support> for more info.

这里建议重启电脑,重新开翻墙,菜鸟昨天晚上一直卡这里,第二天电脑自己关机了,再重新运行一下直接就成功了,还是npm最牛皮,虽然有点幽灵依赖问题,但是稳

vite + Electron

菜鸟按上面的搞完,发现这个是按照原生去开发的,而不是用vue,想用vue应该先创建vue项目,再来接入Electron

创建vue项目可以去看我的 —— vue3+vite+eslint|prettier+elementplus+国际化+axios封装+pinia

这里参考了别人的文章 —— 用electron+vite+vue3搭建桌面端项目,electron基础配置一

创建vue项目完毕后,还需要下载几个包:

npm install -D electron-builder
npm install -D electron-devtools-installer
npm install -D vite-plugin-electron

这里先解释一下这几个包,其中最重要的是 vite-plugin-electron!

在这里插入图片描述

其次就是 electron-builder,这个是你 electron 打包成安装包必须要下载的!

electron-devtools-installer 是辅助开发,可以在 electron 控制台的 Chrome DevTools 里自动安装 Vue3 Devtools 方便调试vue代码!

electron/main.js 默认模板

electron引入成功了,可以开始写electron的相关代码了,新建一个ElectronSrc 文件用来写electron的代码,在它下面创建一个main.js文件用来写主进程代码

const { app, BrowserWindow } = require('electron')
const { join } = require('path')

// 屏蔽安全警告 - 只在开发或你确认安全的场景使用,生产环境最好不要屏蔽安全警告
// ectron Security Warning (Insecure Content-Security-Policy)
process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'

// 创建浏览器窗口时,调用这个函数。
const createWindow = () => {
    const win = new BrowserWindow({
        width: 800,
        height: 600,
    })

    // development模式
    if(process.env.VITE_DEV_SERVER_URL) {
        win.loadURL(process.env.VITE_DEV_SERVER_URL)
        // 开启调试台
        win.webContents.openDevTools()
    }else {
        win.loadFile(join(__dirname, '../dist/index.html'))
    }
}

// Electron 会在初始化后并准备
app.whenReady().then(() => {
    createWindow()
    app.on('activate', () => {
        if (BrowserWindow.getAllWindows().length === 0) createWindow()
    })
})

app.on('window-all-closed', () => {
    if (process.platform !== 'darwin') app.quit()
})

这基本就是每个项目都要写的最简单的一点内容,所以可以搞成默认模板!

全屏

  const win = new BrowserWindow({
    frame: true, // 保留窗口框(默认 true)
    fullscreen: false, // 按F11的效果,所以不要开启
    autoHideMenuBar: true, // Windows/Linux 下隐藏菜单栏(按 Alt 会显示)
    webPreferences: {
      preload: path.join(__dirname, "preload.js"),
      contextIsolation: true,
      nodeIntegration: false
    }
  });

  // 启动时最大化
  win.maximize();

配置

vite.config.js 中配置 vite-plugin-electron 插件入口

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import electron from 'vite-plugin-electron'

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueDevTools(),
    electron({
      // 主进程入口文件
      entry: './ElectronSrc/main.js' // 你自己创建的为准
    })
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    },
  },
})

package.json"type": "module", 删除掉并且配置main字段

{
  "name": "barcodeformedicinal",
  "version": "0.0.0",
  "private": true,
  "main": "ElectronSrc/main.js", // 你自己创建的为准
  "engines": {
    "node": "^20.19.0 || >=22.12.0"
  },
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "lint": "eslint . --fix --cache",
    "format": "prettier --write src/"
  },
  "dependencies": {
    "pinia": "^3.0.3",
    "vue": "^3.5.22",
    "vue-router": "^4.6.3"
  },
  "devDependencies": {
    "@eslint/js": "^9.37.0",
    "@vitejs/plugin-vue": "^6.0.1",
    "@vue/eslint-config-prettier": "^10.2.0",
    "electron": "^30.5.1",
    "electron-builder": "^26.0.12",
    "electron-devtools-installer": "^4.0.0",
    "eslint": "^9.37.0",
    "eslint-plugin-vue": "~10.5.0",
    "globals": "^16.4.0",
    "prettier": "3.6.2",
    "vite": "^7.1.11",
    "vite-plugin-electron": "^0.29.0",
    "vite-plugin-vue-devtools": "^8.0.3"
  }
}

解释

在这里插入图片描述

注意

菜鸟是第一次开发,所以这里目录结构其实不是很规范,如果大家按照大型项目那样的目录结构的话,下面有些package.json文件的配置,就要先cd,然后运行build

在这里插入图片描述

Trae建议目录结构

在这里插入图片描述

优化开发体验

这里直接用之前项目的eslint会不生效,排查了半天,发现是之前搞了一个自动引入的json文件,这个新建的项目没有,所以导致的报错!

这种情况自己排查很难排查,直接调用这两个命令更加方便

# 运行 ESLint 检查并自动修复
npm run lint

# 运行 Prettier 格式化
npm run format

tailwind 导入

这里菜鸟用的是官网提供的最新的导入方式

在这里插入图片描述

这里不导入base的方式可以看我之前的文章:工作两年,最后从css转向tailwind了!

搭建界面 - 最初

这里开发界面其实就是用vue开发,这里菜鸟选用了Naive UI,大佬说这个更加好用,比element plus要清晰!

实际开发发现,这个Naive UI更加适合jsx使用很强的人使用,菜鸟不是很会!

这里把两个界面的代码直接放过来了

在这里插入图片描述

<script setup>
import { useMessage, NForm, NFormItem, NInput, NButton, NDataTable } from "naive-ui";
import { ref, h } from "vue";

const formRef = ref(null);
const message = useMessage();

const modelRef = ref({
  path: "",
  sampleData: [
    {
      Barcode: "",
      sampleName: ""
    }
  ]
});
const rules = {
  sampleData: [
    {
      required: true,
      validator(rule, value) {
        console.log("Validating sampleData:", value);
        for (let i of value) {
          if (!i.Barcode || !i.sampleName) {
            return Promise.reject(new Error("样本信息需要完善!"));
          }
        }
        return true;
      },
      trigger: ["input", "blur"]
    }
  ],
  path: [
    {
      required: true,
      message: "请输入芯片路径"
    }
  ]
};
function createColumns() {
  return [
    {
      title: "Barcode",
      key: "Barcode",
      render(row, index) {
        return h(NInput, {
          placeholder: "请输入Barcode",
          value: row.Barcode,
          onUpdateValue(v) {
            modelRef.value.sampleData[index].Barcode = v;
          }
        });
      }
    },
    {
      title: "样本名称",
      key: "sampleName",
      render(row, index) {
        return h(NInput, {
          placeholder: "请输入样本名称",
          value: row.sampleName,
          onUpdateValue(v) {
            modelRef.value.sampleData[index].sampleName = v;
          }
        });
      }
    },
    {
      title: "操作",
      key: "operation",
      width: 80,
      render(row, index) {
        return h(
          "p",
          {
            class: "text-red-500 cursor-pointer",
            onClick: () => {
              modelRef.value.sampleData.splice(index, 1);
              message.success("删除成功");
            }
          },
          { default: () => "删除" }
        );
      }
    }
  ];
}
const columns = createColumns();

function addRow() {
  modelRef.value.sampleData.push({ Barcode: "", sampleName: "" });
}

function submitForm() {
  formRef.value
    .validate()
    .then(() => {
      // TODO:调用脚本
      message.success("鉴定成功");
    })
    .catch(() => {
      message.error("请完善样本信息");
    });
}
</script>

<template>
  <n-form ref="formRef" :model="modelRef" :rules="rules">
    <n-form-item path="sampleData" label="样本信息">
      <div class="w-full">
        <div class="mb-4 flex justify-end">
          <n-button class="ml-auto" size="small" @click="addRow">添加一行</n-button>
        </div>
        <n-data-table :columns="columns" :data="modelRef.sampleData" />
      </div>
    </n-form-item>
    <n-form-item path="path" label="芯片路径">
      <n-input v-model:value="modelRef.path" @keydown.enter.prevent />
    </n-form-item>
    <n-form-item>
      <div class="flex w-full justify-center">
        <n-button type="primary" @click="submitForm">开始鉴定</n-button>
      </div>
    </n-form-item>
  </n-form>
</template>

在这里插入图片描述

<script setup>
import { NButton, NDataTable } from "naive-ui";
import { h } from "vue";

const data = [
  {
    appraisalId: "123456",
    appraisalStatus: "已鉴定",
    createTime: "2023-08-01 10:00:00",
    path: "/path/to/chip"
  }
];
function createColumns() {
  return [
    {
      title: "鉴定编号",
      key: "appraisalId"
    },
    {
      title: "鉴定状态",
      key: "appraisalStatus"
    },
    {
      title: "创建时间",
      key: "createTime"
    },
    {
      title: "芯片路径",
      key: "path"
    },
    {
      title: "操作",
      key: "operation",
      width: 200,
      render(row, index) {
        return h(
          "div",
          {
            class: "flex justify-evenly"
          },
          {
            default: () => [
              h(NButton, { type: "primary", size: "small" }, { default: () => "查看详情" }),
              h(NButton, { type: "primary", size: "small" }, { default: () => "查看报告" })
            ]
          }
        );
      }
    }
  ];
}
const columns = createColumns();
</script>

<template>
  <div>
    <n-data-table :columns="columns" :data="data" />
  </div>
</template>

<style lang="scss" scoped></style>

访问路径搭建

前面只是前端部分,所以很简单,但是难的就是菜鸟这里要搞:访问路径、调用脚本、自己把数据存起来并展示到历史记录这些功能,接下来一个一个搞!

这里先写访问路径的搭建,现在的AI是真的很强

在这里插入图片描述

这里菜鸟直接用了,还真可以,所以找AI要了解释

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

搭建调用脚本

这个的vuepreload.js还是一样比较好写,就是preload.js暴露,然后vue调用electron暴露出来的方法即可

const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("electronAPI", {
  selectPath: () => ipcRenderer.invoke("select-path"),
  runScript: (args) => ipcRenderer.invoke("runScript", args)
});
function submitForm() {
  formRef.value
    .validate()
    .then(async () => {
      // 运行本地脚本
      const args = {
        path: modelRef.value.path, // 参数1,芯片路径
        sampleData: JSON.stringify(modelRef.value.sampleData) // 参数2,样本数据
      };

      try {
        const result = await window.electronAPI.runScript(args);
        message.success("脚本执行成功:" + result);
      } catch (err) {
        message.error("脚本执行失败:" + err);
      }
    })
    .catch(() => {
      message.error("请完善样本信息");
    });
}

难点在于electron怎么去调用脚本?

这里调用脚本可以有两个选择:execexecfile,这里菜鸟更推荐前面的!

在这里插入图片描述

菜鸟一开始用excefile总是报错

Error occurred in handler for ‘runScript’: Error: spawn EINVAL

一直解决不了,然后大佬建议我用exec,结果就可以了,具体原因如下

在这里插入图片描述

正确的写法比较复杂(第二个没试,因为菜鸟已经选用了exce所以就没有换了,所以就没有深究了!)

在这里插入图片描述

有兴趣的读者可以自己尝试一下第二种,和exec感觉差不多!

调用固定位置脚本

然后这个是菜鸟一开的代码

const { exec } = require("child_process");

// 运行脚本
ipcMain.handle("runScript", async (event, data) => { 
  return new Promise((resolve, reject) => {
    console.log("准备运行脚本,传入参数:", data);
    
    const scriptPath = path.join(__dirname, "scripts", "test.cmd"); 
    
    // 拼接命令(自动交由 shell 解析)
    const command = `"${scriptPath}" "${data.path}" '${JSON.stringify(data.sampleData)}'`; 
    
    // 关键:shell 由系统决定 
    exec(command, { shell: true }, (error, stdout, stderr) => { 
      if (error) { 
        console.error("脚本执行失败:", error); 
        reject(stderr || stdout || "脚本运行异常"); 
        return; 
      } 
      resolve(stdout); 
    }); 
  }); 
});

菜鸟这里是直接运行的自己写在

在这里插入图片描述

下的一个脚本文件,这里脚本可以随便写点什么,能证明调用了就行

@echo off
echo 这是一个测试脚本
echo 你传进来的路径是:%1
echo 你传进来的样本数据是:%2

根据配置运行脚本

但是菜鸟一想,这脚本还要我们公司的其他部门给我,然后还要一起打包进去,那这不就很奇怪,应该是根据配置文件,去某个文件夹下寻找脚本,并运行才对!

所以菜鸟建了一个配置文件 config.json

在这里插入图片描述

{
  "scriptDir": "F:\\myPro",
  "name": "test.cmd"
}

如何把配置文件移动到你需要的地方(菜鸟这里是存在了用户目录下),代码呈现

const fs = require("fs");

// 保存配置
const userDataPath = app.getPath("userData"); 
console.log(userDataPath);

const scriptDir = path.join(userDataPath, "scripts"); // 目标文件夹
const defaultScriptDir = path.join(__dirname, "scripts"); // 源文件夹
// 如果目标文件夹不存在,先创建
if (!fs.existsSync(scriptDir)) {
  fs.mkdirSync(scriptDir, { recursive: true }); // 递归创建目录
  console.log("scripts 文件夹已创建");
}
// 读取默认目录下的文件列表,逐个拷贝
fs.readdirSync(defaultScriptDir).forEach((file) => {
  const srcFile = path.join(defaultScriptDir, file);
  const destFile = path.join(scriptDir, file);
  // 只拷贝文件
  if (fs.lstatSync(srcFile).isFile()) {
    fs.copyFileSync(srcFile, destFile);
    console.log(`${file} 已拷贝到 scripts`);
  }
});

现在要做的就是怎么读取到配置文件了!

在这里插入图片描述

运行脚本的时候,去获取配置文件

// 运行脚本 -- 同步
ipcMain.handle("runScript", async (event, data) => {
  return new Promise((resolve, reject) => {
    console.log("准备运行脚本,传入参数:", data);
    try {
      const userDataPath = app.getPath("userData");
      const configPath = path.join(userDataPath, "scripts", "config.json");
      // 读取 config.json
      if (!fs.existsSync(configPath)) {
        throw new Error("config.json 不存在,请先初始化 scripts 文件夹");
      }
      const configContent = fs.readFileSync(configPath, "utf-8");
      const config = JSON.parse(configContent);
      // 获取脚本路径
      const scriptPath = path.join(config.scriptDir, config.name);
      if (!fs.existsSync(scriptPath)) {
        throw new Error(`脚本文件不存在: ${scriptPath}`);
      }
      // 拼接命令(自动交由 shell 解析)
      const command = `"${scriptPath}" "${data.path}" '${JSON.stringify(data.sampleData)}'`;
      // 关键:shell 由系统决定
      exec(command, { shell: true }, (error, stdout, stderr) => {
        if (error) {
          console.error("脚本执行失败:", error);
          reject(stderr || stdout || "脚本运行异常");
          return;
        }
        resolve(stdout);
      });
    } catch (err) {
      console.error("脚本运行异常:", err);
      reject(err.message);
    }
  });
});

但是又一想,万一别人给的脚本不能及时返回怎么办?

那就只能用一个进程调用一下脚本就行,不用管其是否成功,所以加了一个方法(加入方法后一定要npm run dev一下项目,不然会报错找不到)!

const { spawn } = require("child_process");

// 运行脚本 -- 不等待结果
ipcMain.handle("runScriptNoWait", async (event, data) => {
  return new Promise((resolve, reject) => {
    console.log("准备运行脚本,传入参数:", data);
    try {
      const userDataPath = app.getPath("userData");
      const configPath = path.join(userDataPath, "scripts", "config.json");
      // 读取 config.json
      if (!fs.existsSync(configPath)) {
        throw new Error("config.json 不存在,请先初始化 scripts 文件夹");
      }
      const configContent = fs.readFileSync(configPath, "utf-8");
      const config = JSON.parse(configContent);
      // 获取脚本路径
      const scriptPath = path.join(config.scriptDir, config.name);
      if (!fs.existsSync(scriptPath)) {
        throw new Error(`脚本文件不存在: ${scriptPath}`);
      }

      // 参数转换
      const args = [data.path, JSON.stringify(data.sampleData)];
      // spawn 后台执行
      const child = spawn(scriptPath, args, {
        shell: true, // 让系统选择 cmd/bash
        detached: true, // 让脚本成为独立进程
        stdio: "ignore" // 不接收任何输出
      });

      // 断开 Electron 与脚本的关系
      child.unref();

      // ***关键:不等待脚本执行结果***
      resolve("脚本已成功启动"); // 不等待 stdout,也不等待脚本结束
    } catch (err) {
      console.error("脚本运行异常:", err);
      reject(err.message);
    }
  });
});

这样,运行脚本的功能也算是完结了!

把数据存起来并展示到历史记录 —— 初始

这里菜鸟想到的是,直接存在data.json

在这里插入图片描述

[]

内容就是一个数组,然后就是读取和存入的逻辑

// 保存数据到data.json
ipcMain.handle("saveRecord", async (event, record) => {
  return new Promise((resolve, reject) => {
    console.log(record);
    try {
      const userDataPath = app.getPath("userData");
      const dataPath = path.join(userDataPath, "scripts", "data.json");
      // 读取 data.json
      if (!fs.existsSync(dataPath)) {
        throw new Error("data.json 不存在,请先初始化 scripts 文件夹");
      }
      const dataContent = fs.readFileSync(dataPath, "utf-8");
      const data = JSON.parse(dataContent);
      // 追加记录
      data.unshift(record);
      // 写入 data.json
      fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
      resolve("记录已成功保存");
    } catch (err) {
      console.error("保存记录异常:", err);
      reject(err.message);
    }
  });
});

// 读取data.json数据
ipcMain.handle("readRecords", async (event) => {
  return new Promise((resolve, reject) => {
    try {
      const userDataPath = app.getPath("userData");
      const dataPath = path.join(userDataPath, "scripts", "data.json");
      // 读取 data.json
      if (!fs.existsSync(dataPath)) {
        throw new Error("data.json 不存在,请先初始化 scripts 文件夹");
      }
      const dataContent = fs.readFileSync(dataPath, "utf-8");
      const data = JSON.parse(dataContent);
      resolve(data);
    } catch (err) {
      console.error("读取记录异常:", err);
      reject(err.message);
    }
  });
});

分页展示

<script setup>
import { NButton, NDataTable, NTag, NPagination } from "naive-ui";
import { h, ref } from "vue";
import { computedAsync } from "@vueuse/core";
import DetailDialog from "@/views/history/components/detailDialog.vue";

// 全量数据(从本地读)
let allData = [];

// 分页控制
const page = ref(0);
const pageSize = ref(10);
const pageCount = ref(0);

// 当前页的数据(永远只渲染当前页十条)
const pageData = computedAsync(async () => {
  const start = (page.value - 1) * pageSize.value;
  let tempData = allData.slice(start, start + pageSize.value);
  await Promise.all(
    tempData.map(async (item) => {
      console.log("item", item);
      let files = await window.electronAPI.readDir(item.appraisalNum);
      if (!files) {
        item.appraisalStatus = "鉴定失败";
      } else {
        if (files.length > 0) {
          item.appraisalStatus = "鉴定成功";
        } else {
          item.appraisalStatus = "鉴定中";
        }
      }
    })
  );
  return tempData;
}, []);

const readRecordsFunc = async () => {
  const records = await window.electronAPI.readRecords();
  console.log(records);
  allData = records;
  pageCount.value = allData.length;
  page.value = 1;
};
// 读取本地数据文件中的记录
readRecordsFunc();

function createColumns() {
  return [
    {
      title: "鉴定编号",
      key: "appraisalNum"
    },
    {
      title: "鉴定状态",
      key: "appraisalStatus",
      render(row, index) {
        const isDone =
          row.appraisalStatus === "鉴定失败"
            ? "error"
            : row.appraisalStatus === "鉴定成功"
              ? "success"
              : "warning";
        return h(NTag, { type: isDone }, { default: () => row.appraisalStatus });
      }
    },
    {
      title: "创建时间",
      key: "timestamp"
    },
    {
      title: "芯片路径",
      key: "path"
    },
    {
      title: "操作",
      key: "operation",
      width: 200,
      render(row, index) {
        return h(
          "div",
          {
            class: "flex justify-evenly"
          },
          {
            default: () => [
              h(
                NButton,
                {
                  type: "primary",
                  size: "small",
                  onClick: () => {
                    showDetailDialog.value = true;
                    detailData.value = row;
                  }
                },
                { default: () => "查看详情" }
              ),
              h(NButton, { type: "primary", size: "small" }, { default: () => "查看报告" })
            ]
          }
        );
      }
    }
  ];
}
const columns = createColumns();

// 详情弹窗
const showDetailDialog = ref(false);
const detailData = ref({});
</script>

<template>
  <div>
    <n-data-table :single-line="false" :single-column="false" :columns="columns" :data="pageData" />

    <div class="mt-4 flex justify-end">
      <n-pagination v-model:page="page" :page-count="pageCount" />
    </div>
  </div>

  <!-- 详情弹窗 -->
  <detail-dialog ref="detailDialogRef" v-model:active="showDetailDialog" :data="detailData" />
</template>

把数据存起来并展示到历史记录 - 优化

写完之后,问AI发现还是会有性能问题,不在于渲染了,而是文件的写入,如果按照菜鸟的方式,前端vue的分页展示确实没啥大问题,然后读取文件也不慢,慢的是saveRecord中的这段代码:

const dataContent = fs.readFileSync(dataPath, "utf-8");
const data = JSON.parse(dataContent);
// 追加记录
data.unshift(record);
// 写入 data.json
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));

还有readRecords中的这段代码:

const dataContent = fs.readFileSync(dataPath, "utf-8");
const data = JSON.parse(dataContent);

这两个都相当于全量读取、解析、写入,如果data.json变大了,就会越来越慢!

按照AI的提示,优化成了只写入最后一行,不用读取整个文件,分页获取数据也变成了倒序分块读取。

// 保存数据到 data.ndjson
ipcMain.handle("saveRecord", async (event, record) => {
  try {
    const userDataPath = app.getPath("userData");
    const dataPath = path.join(userDataPath, "scripts", "data.ndjson");

    // 读取 data.ndjson
    if (!fs.existsSync(dataPath)) {
      throw new Error(
        "The data.ndjson file does not exist. Please initialize the scripts folder first"
      );
    }

    // 以追加模式写入,不读取文件,不解析 JSON
    fs.appendFile(dataPath, JSON.stringify(record) + "\n", (err) => {
      if (err) throw err;
    });

    return "记录已成功保存";
  } catch (err) {
    console.error("Save record exception:", err);
    throw err.message;
  }
});

// 读取 data.ndjson(最新的在最前,倒序分页)
// page 从 1 开始;pageSize 为每页大小
ipcMain.handle("readRecords", async (event, { page, pageSize }) => {
  const userDataPath = app.getPath("userData");
  const dataPath = path.join(userDataPath, "scripts", "data.ndjson");

  const fd = fs.openSync(dataPath, "r"); // 只打开一次,永远不会丢数据
  const stat = fs.fstatSync(fd);
  const fileSize = stat.size;

  const bufferSize = 64 * 1024;
  let position = fileSize;

  let skipCount = (page - 1) * pageSize; // 前几页行数
  let skipped = 0;
  let results = [];
  let leftover = ""; // 用来拼接被拆断的 json 行

  while (position > 0 && results.length < pageSize) {
    const readSize = Math.min(position, bufferSize);
    position -= readSize;

    const buffer = Buffer.alloc(readSize);
    fs.readSync(fd, buffer, 0, readSize, position);

    let chunk = buffer.toString("utf8") + leftover;

    // split 后第一段可能是残段
    let lines = chunk.split("\n");

    // 保存最前面的残段,下一次拼接
    leftover = lines.shift();

    // 从后往前处理(倒序)
    for (let i = lines.length - 1; i >= 0; i--) {
      const line = lines[i].trim();
      if (!line) continue;

      if (skipped < skipCount) {
        skipped++;
        continue;
      }

      try {
        results.push(JSON.parse(line));
      } catch (err) {
        // 如果这一页遇到拆断行,把它加入 leftover 等下一轮处理,不丢
        leftover = line;
      }

      if (results.length >= pageSize) break;
    }
  }

  // 文件读完后 leftover 可能是最后一行(完整或合并后的)
  if (results.length < pageSize && leftover.trim()) {
    try {
      if (skipped >= skipCount) {
        results.push(JSON.parse(leftover.trim()));
      }
    } catch (err) {
      // 忽略解析错误
      console.error("JSON parse error:", err);
    }
  }

  fs.closeSync(fd);

  return results;
});

// 获取记录总数
ipcMain.handle("getRecordCount", async () => {
  const userDataPath = app.getPath("userData");
  const dataPath = path.join(userDataPath, "scripts", "data.ndjson");

  return new Promise((resolve) => {
    let count = 0;
    const stream = fs.createReadStream(dataPath);

    stream.on("data", (chunk) => {
      for (let i = 0; i < chunk.length; i++) {
        if (chunk[i] === 10) count++; // 换行符
      }
    });
    stream.on("end", () => resolve(count));
  });
});

ndjson 和 json 的区别

在这里插入图片描述

界面优化

<script setup>
import { NButton, NDataTable, NTag, NPagination } from "naive-ui";
import { h, ref } from "vue";
import { computedAsync } from "@vueuse/core";
import DetailDialog from "@/views/history/components/detailDialog.vue";

// 分页控制
const page = ref(0);
const pageSize = ref(10);
const pageCount = ref(0);

// 当前页的数据(永远只渲染当前页几十条)
const pageData = computedAsync(async () => {
  const records = await window.electronAPI.readRecords({
    page: page.value,
    pageSize: pageSize.value
  });

  return await Promise.all(
    records.map(async (record) => {
      let files = await window.electronAPI.readDir(record.appraisalNum);
      if (!files) {
        record.appraisalStatus = "鉴定失败";
      } else {
        if (files.length > 0) {
          record.appraisalStatus = "鉴定成功";
        } else {
          record.appraisalStatus = "鉴定中";
        }
      }
      return record;
    })
  );
}, []);

// 获取记录总数
const readRecordsFunc = async () => {
  const recordCount = await window.electronAPI.getRecordCount();
  pageCount.value = Math.ceil(recordCount / pageSize.value);
  page.value = 1;
};
// 读取本地数据文件中的记录
readRecordsFunc();

function createColumns() {
  return [
    {
      title: "鉴定编号",
      key: "appraisalNum"
    },
    {
      title: "鉴定状态",
      key: "appraisalStatus",
      render(row, index) {
        const isDone =
          row.appraisalStatus === "鉴定失败"
            ? "error"
            : row.appraisalStatus === "鉴定成功"
              ? "success"
              : "warning";
        return h(NTag, { type: isDone }, { default: () => row.appraisalStatus });
      }
    },
    {
      title: "创建时间",
      key: "timestamp"
    },
    {
      title: "芯片路径",
      key: "path"
    },
    {
      title: "操作",
      key: "operation",
      width: 200,
      render(row, index) {
        return h(
          "div",
          {
            class: "flex justify-evenly"
          },
          {
            default: () => [
              h(
                NButton,
                {
                  type: "primary",
                  size: "small",
                  onClick: () => {
                    showDetailDialog.value = true;
                    detailData.value = row;
                  }
                },
                { default: () => "查看详情" }
              ),
              h(NButton, { type: "primary", size: "small" }, { default: () => "查看报告" })
            ]
          }
        );
      }
    }
  ];
}
const columns = createColumns();

// 详情弹窗
const showDetailDialog = ref(false);
const detailData = ref({});
</script>

<template>
  <div>
    <n-data-table :single-line="false" :single-column="false" :columns="columns" :data="pageData" />

    <div class="mt-4 flex justify-end">
      <n-pagination v-model:page="page" :page-count="pageCount" />
    </div>
  </div>

  <!-- 详情弹窗 -->
  <detail-dialog ref="detailDialogRef" v-model:active="showDetailDialog" :data="detailData" />
</template>

解释 readRecords

在这里插入图片描述

打包

完成了上面,感觉大功即将大成,但是要想看看有没有用,还是得看打包后的文件能不能再Linux上运行!

但是这个时候执行npm run build只会打包vue项目,并不会变成electron项目!

这里打包想要修改package.json

{
  "name": "barcodeformedicinal",
  "version": "0.0.0",
  "private": true,
  "main": "ElectronSrc/main.js",
  "engines": {
    "node": "^20.19.0 || >=22.12.0"
  },
  "scripts": {
    "dev": "vite",
    "build:vue": "vite build",
    "build:electron": "electron-builder",
    "build": "npm run build:vue && npm run build:electron", // 要同时打包两个
    "preview": "vite preview",
    "lint": "eslint . --fix --cache",
    "format": "prettier --write src/"
  },
  // 配置electron打包
  "build": {
    "appId": "com.example.barcodeformedicinal",
    "productName": "中药材条形码鉴定",
    "asar": true,
    "directories": {
      "output": "dist_electron"
    },
    "files": [
      // 把dist和electron一起打包,要package.json是因为需要main
      "dist/**",
      "ElectronSrc/**",
      "package.json"
    ],
    "win": {
      "target": "nsis"
    }
  },
  "dependencies": {
    "pinia": "^3.0.3",
    "vue": "^3.5.22",
    "vue-router": "^4.6.3"
  },
  "devDependencies": {
    ……
  }
}

在这里插入图片描述

为什么files里面需要加上package.json

在这里插入图片描述

注意

window 打包可能会报错,但点击是可以运行的,菜鸟暂时没有管。
在这里插入图片描述 菜鸟准备复现,结果又好了,所以建议大家多试试,很可能是和网络有关!

打包为Linux可以运行

打包成Linux运行的,需要加上 -- --linux,不然默认是打包window上的exe!

{
  "name": "barcodeformedicinal",
  "version": "0.0.0",
  "private": true,
  "main": "ElectronSrc/main.js",
  "engines": {
    "node": "^20.19.0 || >=22.12.0"
  },
  "scripts": {
    "dev": "vite",
    "build:vue": "vite build",
    "build:electron": "electron-builder",
    "build": "npm run build:vue && npm run build:electron -- --linux", // 这里加上-- --linux
    "preview": "vite preview",
    "lint": "eslint . --fix --cache",
    "format": "prettier --write src/"
  },
  "build": {
    "appId": "com.example.barcodeformedicinal",
    "productName": "中药材条形码鉴定",
    "asar": true,
    "directories": {
      "output": "dist_electron"
    },
    "files": [
      "dist/**",
      "ElectronSrc/**",
      "package.json"
    ],
    "linux": {
      "target": [
        "AppImage",
        "deb",
        "rpm"
      ],
      "category": "Utility"
    }
  },
  "dependencies": {
    ……
  },
  "devDependencies": {
    ……
  }
}

在这里插入图片描述

在这里插入图片描述

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

PBitW

可以去掘金看更完善版本

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值