普通视图
Accessibility 辅助功能总结
总结一下接手项目以来Accessibility的一些收获,这个产品要求支持辅助功能,虽然已经是按照最低要求,但做起来即要考虑全键盘可以操作,也要考虑所有显示的内容和需要交互的内容可以被讲述人读出来,还要考虑高对比下的样式兼容,也是有不少的维护开发成本。
css可以通过特定选择器来指定高对比度模式下的样式
@media screen and (forced-colors: active) {
color: ButtonText;
backgroundColor: ButtonFace;
}
// 判断系统主题
@media (prefers-color-scheme: dark) {
.brand-text {
color: #ffffff;
}
}
可用的内置变量:
1. Button
| Keyword | Meaning |
|---|---|
ButtonBorder |
Border color of native buttons/controls (e.g., the outline of a button). |
2. Text & content
| Keyword | Meaning |
|---|---|
Canvas |
Background color of the main content area (e.g., a window's background). |
CanvasText |
Text color for content on the Canvas background. |
LinkText |
Color of unvisited hyperlinks (matches system's default link color). |
VisitedText |
Color of visited hyperlinks. |
3. Highlight & selection
| Keyword | Meaning |
|---|---|
Highlight |
Background color of selected text/elements (e.g., highlighted text). |
HighlightText |
Text color of selected content (contrasts with Highlight). |
4. UI chrome (window/frame elements)
| Keyword | Meaning |
|---|---|
ActiveBorder |
Border color of active windows/dialogs. |
ActiveCaption |
Background color of the title bar of an active window. |
ActiveCaptionText |
Text color in the title bar of an active window. |
InactiveBorder |
Border color of inactive windows/dialogs. |
InactiveCaption |
Background color of the title bar of an inactive window. |
5. Status & feedback
| Keyword | Meaning |
|---|---|
GrayText |
Color of disabled text (e.g., grayed-out buttons/options). |
InfoBackground |
Background color of tooltip/info panels. |
InfoText |
Text color in tooltip/info panels. |
html inert 属性
学到了一个新的且很重要的html属性 inert,这个词表示 “使惰性”,它可以让元素中的用户交互事件都失效,适合展示顶层窗口时,把交互trap到当前窗口中。 `
export function disableApp() {
document.getElementById('app')?.setAttribute('inert', '')
}
export function enableApp() {
document.getElementById('app')?.removeAttribute('inert')
}
焦点 管理
如果不是做 accessibility, 我都不知道聚焦是一个这个难以处理的东西。不过也是因为这个项目之前给第三方组件库提了需求,要求弹窗弹出来自动聚焦第一个可聚焦的元素,但是可能没区分好触发方式:
- 键盘导航触发 (应该自动聚焦)
- 鼠标点击触发(不应该自动聚焦)
- 编程式触发 xxx.focus() (不应该自动聚焦,但组件库聚焦了)
为了不牵扯第三方去修改,我们监听了focus事件,发现有自动聚焦但不想聚焦的时候,就去设置blur,其中监听事件有两种:
parentElement.addEventListener('focus', handlefocus, { capture: true, once: true })
等价于
parentElement.addEventListener('focusin', handlefocus, { once: true }) //childElement也会触发
}
几种关于聚焦元素的伪类选择器
*:focus
*:focus-within
*:focus-visible 如果没有意外情况,这个选择器本身就只有在键盘导航时才显示样式,已经和鼠标做了区分
判断DOM元素是否可聚焦或者已经聚焦
// 检查焦点相关状态
function diagnoseFocus(element) {
return {
hasFocusVisible: element.matches(':focus-visible'),
hasFocus: element.matches(':focus'),
isActive: element.matches(':active'),
tabIndex: element.tabIndex,
computedOutline: getComputedStyle(element).outline,
focusVisibleSupported: CSS.supports('selector(:focus-visible)')
};
}
// 实时监控
document.addEventListener('focusin', (e) => {
console.log('焦点诊断:', diagnoseFocus(e.target));
});
其实非预期的行为大多出现在编程式触发,让组件库在调用 focus()方法的时候加一个 flag,使用组件库的控制起来会方便很多。
Electron入门指南:从零开始构建跨平台桌面应用
前言
随着前端技术的飞速发展,Web 技术不再局限于浏览器。Electron 的出现,让开发者可以用熟悉的 HTML、CSS 和 JavaScript 构建功能强大的桌面应用程序。从 Visual Studio Code 到 Slack,再到网易云音乐,众多知名软件都基于 Electron 构建。
本文将带你全面了解 Electron 的核心概念,深入理解其架构与多进程通信机制,掌握常用 API,并通过一个简单的工程案例带你快速入门。
一、Electron 是什么?能做什么?优缺点分析
1.1 什么是 Electron?
Electron 是一个由 OpenJS Foundation 与社区共同维护的开源框架,它将 Chromium 渲染引擎 和 Node.js 运行时 深度整合,允许开发者使用 Web 技术(HTML、CSS、JavaScript)构建跨平台的桌面应用程序。
- 诞生背景:最初作为 GitHub Atom 编辑器的底层框架(名为 Atom Shell),于 2013 年启动,2015 年正式更名为 Electron 并独立发布。
- 核心技术栈:JavaScript + HTML + CSS
- 支持平台:Windows、macOS、Linux
1.2 Electron 能做什么?
Electron 不只是一个“网页打包器”,它能实现接近原生应用的功能和体验,典型应用场景包括:
- ✅ 代码编辑器与 IDE:如 Visual Studio Code、Atom
- ✅ 通讯工具:如 Slack、Discord、Zoom 客户端
- ✅ 媒体应用:如网易云音乐、百度网盘
- ✅ 远程控制与工具类软件:如 Figma 桌面端、Postman
- ✅ 企业级管理后台桌面化
- ✅ 游戏客户端或轻量级游戏
得益于 Node.js 的加持,Electron 应用可以访问文件系统、调用系统 API、创建系统托盘、发送通知等,实现传统 Web 应用无法完成的操作。
1.3 Electron 的优势
| 优势 | 说明 |
|---|---|
| 🌐 跨平台支持 | 一套代码可编译为 Windows、macOS、Linux 三端应用,极大降低开发与维护成本 |
| 🧑💻 上手简单 | 前端开发者无需学习 C++、Swift 等原生语言,即可开发桌面应用 |
| ⚙️ 底层系统权限 | 可通过 Node.js 访问文件系统、注册表、命令行工具等,实现复杂系统交互 |
| 🧩 生态丰富 | 背靠庞大的 npm 和前端生态,模块复用方便;社区活跃,文档齐全 |
| 🛠️ 易于调试 | 支持 Chrome DevTools,调试体验接近 Web 开发 |
1.4 Electron 的缺点
| 缺点 | 说明 | 建议 |
|---|---|---|
| 📦 应用体积较大 | 每个应用都内嵌 Chromium 和 Node.js,最小包通常在 50MB 以上 | 使用打包优化工具(如 electron-builder)压缩资源 |
| ⚠️ 性能开销 | 多进程架构和渲染开销可能导致低配设备卡顿 | 优化渲染逻辑,避免白屏,合理使用硬件加速 |
| 🔐 安全风险 | 若未正确配置 nodeIntegration 或加载远程内容,可能引发 RCE(远程代码执行)漏洞 |
禁用危险配置,启用上下文隔离,使用 contextIsolation: true
|
| 💸 内存占用高 | 多个窗口或复杂页面可能导致内存占用上升 | 合理管理窗口生命周期,及时销毁无用窗口 |
📌 总结:Electron 特别适合工具类、中后台管理类、跨平台协作型应用,不适合对性能和体积要求极高的场景(如大型游戏或系统级工具)。
二、Electron 架构与多进程通信原理
2.1 整体架构
Electron 采用 主进程(Main Process) + 渲染进程(Renderer Process) 的多进程架构,类似于现代浏览器的设计。
-
主进程:运行
main.js,负责创建窗口、管理应用生命周期、处理系统事件、调用原生 API。 - 渲染进程:每个窗口对应一个独立的渲染进程,运行在 Chromium 中,负责渲染 UI 界面,可使用 DOM、CSS、Canvas 等 Web API。
🔍 注意:主进程只有一个,但可以有多个渲染进程(每个窗口一个)。
2.2 多进程模型图示
+---------------------+
| 主进程 (Main) |
| - app, BrowserWindow |
| - Tray, Menu, etc. |
+----------+----------+
|
| IPC 通信
v
+---------------------+ +---------------------+
| 渲染进程 1 (Renderer) | | 渲染进程 2 (Renderer) |
| - HTML/CSS/JS | | - 独立上下文 |
| - 可访问 window | | - 无法直接访问 Node |
+---------------------+ +---------------------+
💡 关键点:
2.3 多进程通信原理(IPC)
Electron 提供了两种主要的 IPC 模块:
-
ipcMain和ipcRenderer:发送异步消息 -
ipcMain.invoke/ipcMain.handle与ipcRenderer.invoke:支持异步请求-响应模式(推荐用于函数调用)
示例:主进程与渲染进程通信
主进程(main.js)
const { ipcMain } = require('electron');
ipcMain.on('message-from-renderer', (event, data) => {
console.log('收到渲染进程消息:', data);
event.sender.send('message-to-renderer', { response: '主进程已处理' });
});
渲染进程(renderer.js)
const { ipcRenderer } = require('electron');
ipcRenderer.send('message-from-renderer', { text: '你好主进程' });
ipcRenderer.on('message-to-renderer', (event, response) => {
console.log('主进程回复:', response);
});
✅ 最佳实践:
三、Electron 常用 API 讲解
以下是 Electron 开发中最常用的核心模块及其用途:
| 模块 | 作用 | 使用场景 |
|---|---|---|
app |
控制应用生命周期 | 监听 ready、window-all-closed、activate 事件 |
BrowserWindow |
创建和管理浏览器窗口 | 创建主窗口、子窗口、设置大小、位置、是否置顶等 |
ipcMain / ipcRenderer
|
主进程与渲染进程通信 | 数据传递、触发系统操作 |
dialog |
显示系统对话框 | 打开文件、保存文件、提示框等 |
Tray |
创建系统托盘图标 | 后台运行、最小化到托盘 |
Menu / MenuItem
|
自定义菜单 | 右键菜单、顶部菜单栏 |
nativeImage |
处理原生图像 | 设置托盘图标、窗口图标 |
shell |
打开外部资源 | 打开 URL、打开文件夹 |
示例代码片段
1. 创建系统托盘
const { Tray, nativeImage } = require('electron');
let tray = null;
app.whenReady().then(() => {
const icon = nativeImage.createFromPath('icon.png');
tray = new Tray(icon);
tray.setToolTip('我的 Electron 应用');
tray.setContextMenu(Menu.buildFromTemplate([
{ label: '打开', click: () => createWindow() },
{ label: '退出', click: () => app.quit() }
]));
});
2. 打开文件选择框
const { dialog } = require('electron');
dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
filters: [{ name: 'Images', extensions: ['jpg', 'png', 'gif'] }]
}).then(result => {
console.log(result.filePaths);
});
四、简单工程搭建案例
下面我们从零开始搭建一个基础的 Electron 项目。
4.1 环境准备
确保已安装:
- Node.js(建议 v16+)
- npm 或 yarn
- 代码编辑器(推荐 VS Code)
4.2 创建项目结构
mkdir electron-hello-world
cd electron-hello-world
npm init -y
npm install --save-dev electron
4.3 创建项目文件
(1)main.js —— 主进程入口
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
function createWindow() {
const win = new BrowserWindow({
width: 1000,
height: 700,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
});
win.loadFile('index.html');
win.webContents.openDevTools(); // 开发时打开 DevTools
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
// 示例 IPC 处理
ipcMain.on('ping', (event) => {
event.sender.send('pong', { message: 'Pong 来自主进程!' });
});
(2)preload.js —— 预加载脚本(安全桥梁)
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
sendPing: () => ipcRenderer.send('ping'),
onPong: (callback) => ipcRenderer.on('pong', (event, data) => callback(data))
});
🔒 说明:使用 contextBridge 安全地将 API 暴露给渲染进程,避免直接引入 Node 模块。
(3)index.html —— 渲染界面
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Hello Electron</title>
<style>
body { font-family: "Segoe UI", sans-serif; text-align: center; margin-top: 100px; }
button { padding: 12px 24px; font-size: 16px; margin: 10px; }
</style>
</head>
<body>
<h1>🎉 欢迎来到 Electron 世界!</h1>
<p>这是一个基础的桌面应用示例</p>
<button id="pingBtn">发送 Ping</button>
<p id="result"></p>
<script type="module">
const { electronAPI } = window;
document.getElementById('pingBtn').addEventListener('click', () => {
electronAPI.sendPing();
});
electronAPI.onPong((data) => {
document.getElementById('result').textContent = data.message;
});
</script>
</body>
</html>
(4)package.json 配置启动脚本
{
"name": "electron-hello-world",
"version": "1.0.0",
"main": "main.js",
"scripts": {
"start": "electron .",
"dev": "electron ."
},
"author": "Your Name",
"license": "MIT",
"description": "A simple Electron demo app"
}
4.4 启动应用
npm start
如果一切正常,你将看到一个窗口弹出,点击按钮可与主进程通信。
五、后续学习建议
- 阅读官方文档:www.electronjs.org/docs 是最权威的学习资源。
-
使用脚手架工具:
- Electron Forge:集成打包、自动更新、发布等功能
- Electron Builder:更强大的打包与分发方案
-
关注安全性:
- 始终启用
contextIsolation: true - 避免在渲染进程中直接引入
require - 使用
sandbox: true提升安全性
- 始终启用
-
性能优化:
- 减少白屏时间(使用
ready-to-show事件) - 合理管理多窗口生命周期
- 使用懒加载和代码分割
- 减少白屏时间(使用
六、结语
Electron 让前端开发者真正实现了“一次编写,到处运行”的桌面开发梦想。虽然它有体积大、资源占用高等局限,但在工具类、跨平台协作型应用中表现出色。
通过本文的学习,你已经掌握了:
- Electron 是什么及其适用场景
- 多进程架构与 IPC 通信机制
- 常用 API 的使用方式
- 一个完整的入门项目搭建流程
下一步,你可以尝试添加以下功能来深化理解:
- 实现“打开文件”功能
- 添加系统托盘和右键菜单
- 实现窗口最小化到托盘
- 使用
electron-builder打包为.exe或.dmg
🌟 保持好奇心,持续探索。浏览器之外的世界,同样精彩。
🙌 感谢阅读!欢迎在评论区分享你的第一个 Electron 应用想法或遇到的问题。
Electron 初体验 —— AI辅助上手,确实不难(๑•̀ㅂ•́)و✧
2025最后一个月,我们公司来了一个小项目,项目具体内容如下:
用户输入一些东西,然后选择本地的文件夹路径,等选好后会点击提交,提交就是将用户填写的东西传参给该电脑上的脚本,让其跑出一些文件。
这个需求,一开始描述的时候,同事说的服务器,菜鸟就以为是做一个网页,给用户访问,那问题就来了:怎么去获得服务器上的文件路径?纯前端做不到啊!
当时给菜鸟整懵逼了,后面交流才发现,是将服务器和脚本一起给用户或者理解为用户用向日葵远程服务器!
然后大佬就说了,这种情况,可以用Electron,因为有node在里面,所以可以获取路径,正好也可以搞成一个桌面软件!
2025 是AI的一年
这个项目是个小项目,但是确实要花时间去写。然后公司还有两个更紧急的项目需要完成,我们公司的大佬在做一个,还有一个是我在做项目管理 + 开发,说实话都没有空,但菜鸟想着这是个可以学习的机会,就自告奋勇的接下了!
菜鸟想的是,虽然读取文件那一块的electron不会,但是自从AI流行起来,这些好像都没有那么困难了。
反正菜鸟今年没少用AI,什么:GPT、deepseek、Trae、copilot,都在开发过程中帮助了菜鸟不少。
但是大部分情况AI都是帮助菜鸟写一写公用方法、解决一些bug和报错、代码的提示,这种完全不会的暂时还没交给过AI,所以正好尝试一下!
初识 Electron
这个菜鸟是直接看官网:electron.nodejs.cn/docs/latest…
按照官网的来,是那种从零搭建的,不是和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
菜鸟一开始问GPT,说可能是最新版本的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
这里菜鸟完全不知道为什么要这些,所以让GPT先解释一下这几个包,其中最重要的是 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"
}
}
GPT解释
![]()
注意
菜鸟是第一次开发,所以这里目录结构其实不是很规范:
![]()
GPT建议目录结构
![]()
优化开发体验
这里直接用之前项目的eslint会不生效,排查了半天,发现是之前搞了一个自动引入的json文件,这个新建的项目没有,所以导致的报错!
这种情况自己排查很难排查,直接调用这两个命令更加方便
# 运行 ESLint 检查并自动修复
npm run lint
# 运行 Prettier 格式化
npm run format
tailwind 导入
这里菜鸟用的是官网提供的最新的导入方式
![]()
这里不导入base的方式可以看我之前的文章:工作两年,最后从css转向tailwind了!
搭建界面 - 最初
这里开发界面其实就是用vue开发,这里菜鸟选用了Naive UI,大佬说这个更加好用,比element plus要清晰!
实际开发发现,这个Naive UI更加适合jsx使用很强的人使用,菜鸟不是很会,这里的 h函数 是在和GPT沟通中学会使用的!
jsx可以看我的:差点因为性能优化而被”优化“!element plus虚拟化表格(Virtualized Table)性能优化
这里把两个界面的代码直接放过来了
![]()
<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都可以先帮你写个大概,剩下的就是调试了,只要不是学习曲线太陡峭的,基本可以顺利完成,这里更是直接生成了可以使用的代码!
![]()
找GPT要解释
![]()
![]()
![]()
![]()
搭建调用脚本
这个的vue和preload.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怎么去调用脚本?
这里调用脚本可以有两个选择:exec 和 execfile,这里菜鸟更推荐前面的!
![]()
菜鸟一开始用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`);
}
});
现在要做的就是怎么读取到配置文件了!还是AI辅助:
![]()
运行脚本的时候,去获取配置文件
// 运行脚本 -- 同步
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));
});
});
问GPT,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>
GPT解释 readRecords
![]()
打包 - Windows
完成了上面,感觉大功即将大成,但是要想看看有没有用,还是得看打包后的文件能不能运行!
但是这个时候执行npm run build只会打包vue项目,并不会变成electron项目!
这里打包想要修改package.json
{
"name": "barcodeformedicinal",
"version": "1.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": {
……
},
"devDependencies": {
……
}
}
![]()
为什么files里面需要加上package.json
![]()
注意
window 打包可能会报错,但点击是可以运行的,菜鸟暂时没有管。
菜鸟准备复现,结果又好了,所以建议大家多试试,很可能是和网络有关!
打包为Linux可以运行
这里菜鸟只打包了windows可以运行的exe,linux的在下一篇文章中展示:Electron 在乌班图上打包
![]()
![]()
总结
到这里,项目的基础架构其实已经基本完成,剩下的更多是一些性能与体验上的优化,以及在明确具体需求之后,对表结构、字段展示等细节逻辑进行调整。
目前的开发方式,更像是一种「人与 AI 的协作模式」:我不断向 AI 解释业务背景、拆解需求并校正实现方向,由它完成 Electron 层面的具体实现;而前端部分,由于我本身比较熟悉,且界面逻辑相对简单,仍然主要由我自己负责。
回头看这一年的变化,只能说 AI 的成长速度既让人感到压力,又很难不为之兴奋。
在实际实践中,AI 在代码编写、代码提示、Bug 排查以及问题定位等方面,确实为开发者带来了极大的便利。无论是快速生成可运行的示例代码,还是在遇到复杂报错时给出清晰的排查思路,都显著缩短了从“问题出现”到“问题解决”的时间成本。
从我的感受来说,AI 并不是在取代开发者,而是在持续介入我们的思考过程 —— 甚至在某些时候,让人产生一种“自己越来越不用动脑子”的错觉。
![]()
但冷静下来会发现,AI 真正取代的,往往只是那些简单、重复、缺乏上下文理解的逻辑书写者;它并不能淘汰业务梳理者、需求沟通者和系统架构的搭建者。
从我让AI写electron的读取文件代码、写接口代码,基本都是一次就过可以看出,后端的CRUD仔更容易被替换,这些固定逻辑,其实AI可以很好的写出来了,前端感觉只要界面复杂,那么不可替代性还是比较高的!
从使用 Trae 生成复杂项目的过程也能看出来:无论Trae公共方法写得多完善、GPT写的文件读取逻辑多么标准,AI 都无法直接交付一个真正可运行、符合个性化需求的完整系统,更无法独立完成复杂业务和界面层面的设计,但是也确实让技术越来越不值钱了。
Electron 在乌班图上打包
接上一篇: Electron 初体验
代码完善
上一篇中,就是简单的写了一些demo内容,菜鸟又在其上加上了不少功能,但是感觉从vue到初次开发electron真正需要注意的,还是那些读取文件、连接数据库之类的东西。
而上一篇中就是主要介绍的就是那些,其实看懂一个,后面的都是以此类推即可!
这里之所以要写,只是因为菜鸟后面读取文件的方案感觉还是不好,最后改成连接数据库,就是后端直接在服务器上安装了mySQL,然后我直接用electron去连接即可,这里就是代码,菜鸟也对之前写的代码进行了抽离
main.js
菜鸟的目录结构变成了这样
![]()
const { app, BrowserWindow, ipcMain, dialog } = require("electron");
const { join } = require("path");
const path = require("path");
const { exec, spawn } = require("child_process");
const fs = require("fs");
const express = require("express");
const mysql = require("mysql2/promise");
// 提出去的读写文件操作
const { selectPath } = require("./modules/selectPath.js");
const { runScript, runScriptNoWait } = require("./modules/runScript.js");
const { readDir } = require("./modules/readDir.js");
const { readConfig } = require("./modules/readConfig.js");
const { writeConfig } = require("./modules/writeConfig.js");
const { saveImageToUserData } = require("./modules/saveImageToUserData.js");
const { loadImageAsBase64 } = require("./modules/loadImageAsBase64.js");
const { getSeqChipPath } = require("./modules/getSeqChipPath.js");
const { readFa, readBFile, readCFile } = require("./modules/readFa.js");
// 提出去的API
const { loginApi } = require("./api/login.js");
const { appraisalRecordAddApi } = require("./api/appraisalRecordAdd.js");
const { appraisalRecordListApi } = require("./api/appraisalRecordList.js");
// 屏蔽安全警告
// ectron Security Warning (Insecure Content-Security-Policy)
process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "true";
// 创建浏览器窗口时,调用这个函数。
const createWindow = () => {
const win = new BrowserWindow({
width: 1920,
height: 1080,
fullscreen: false, // 👈 一启动就全屏
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false
}
});
// 启动时最大化(保留窗口按钮)
win.maximize();
// win.loadURL('http://localhost:3000')
// 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(async () => {
await startLocalApi(); // ⭐ 启动本地 API
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
// 保存配置 - 用于设置脚本执行目录
const userDataPath = app.getPath("userData");
const scriptDir = path.join(userDataPath, "scripts"); // 目标文件夹
const defaultScriptDir = path.join(__dirname, "scripts"); // 源文件夹
// 如果目标文件夹不存在,先创建
if (!fs.existsSync(scriptDir)) {
fs.mkdirSync(scriptDir, { recursive: true }); // 递归创建目录
}
// 读取默认目录下的文件列表,逐个拷贝
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} has copy to scripts`);
}
});
// 选择路径
selectPath(ipcMain, dialog);
// 运行脚本 -- 等待结果
runScript(ipcMain, app, path, fs, exec);
// 运行脚本 -- 不等待结果
runScriptNoWait(ipcMain, app, path, fs, spawn);
// 读取某个文件夹下是否有文件
readDir(ipcMain, fs);
// 读取配置文件
readConfig(ipcMain, app, path, fs);
// 写入配置文件
writeConfig(ipcMain, app, path, fs);
// 保存用户上传的图片到用户目录 —— 防止用户删除
saveImageToUserData(ipcMain, app, path, fs);
// 加载图片作为 base64 编码 ——
loadImageAsBase64(ipcMain, app, path, fs);
// 获取配置文件下的测序路径,并将下面的文件返回成select可以使用的选项
getSeqChipPath(ipcMain, app, path, fs);
// 读取 FA 文件
readFa(ipcMain, fs, path);
// 读取 B 文件
readBFile(ipcMain, fs, path);
// 读取 C 文件
readCFile(ipcMain, fs, path);
// 连接数据库 -- 重点
let apiServer;
let apiPort;
let mysqlPool;
async function startLocalApi() {
const api = express();
api.use(express.json());
// ⚠️ MySQL 连接池(只初始化一次)
mysqlPool = mysql.createPool({
host: "xxx.xx.xxx.xx", // 数据库地址
port: 13306, // 端口
user: "barcode_for_medicinal", // 数据库账号
password: "xxxxxxxx", // 数据库密码
database: "barcode_for_medicinal", // 数据库名称
connectionLimit: 5 // 连接池最大连接数
});
// 解决跨域
api.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
next();
});
// 登录接口
loginApi(api, mysqlPool);
// 鉴定记录添加接口
appraisalRecordAddApi(api, mysqlPool);
// 鉴定记录列表接口
appraisalRecordListApi(api, mysqlPool);
// ⭐ 随机端口,只监听本机
apiServer = api.listen(0, "127.0.0.1", () => {
apiPort = apiServer.address().port;
console.log("Local API started on port:", apiPort);
});
}
// 获取本地 API 端口
ipcMain.handle("getApiPort", () => {
return apiPort;
});
如何使用接口?
菜鸟直接将API的端口存在了pinia中,避免每次调用还要去和electron通信
port.js
import { ref } from "vue";
import { defineStore } from "pinia";
export const usePortStore = defineStore("port", () => {
let port = ref(null);
// 异步 action
async function fetchPort() {
port.value = await window.electronAPI.getApiPort();
}
function getPort() {
return port.value;
}
return { port, fetchPort, getPort };
});
login.vue
<script setup>
import router from "@/router";
import { NForm, NFormItemRow, NInput, NButton, useMessage } from "naive-ui";
import { reactive, ref } from "vue";
import logo from "@/assets/img/logo.png";
import { usePortStore } from "@/stores/port";
import { useUserStore } from "@/stores/user";
const portStore = usePortStore();
const userStore = useUserStore();
const message = useMessage();
const formData = reactive({
username: "xxx",
password: "xxxxx"
});
const rules = reactive({
username: [{ required: true, message: "请输入用户名", trigger: ["blur"] }],
password: [{ required: true, message: "请输入密码", trigger: ["blur"] }]
});
const formRef = ref(null);
const login = () => {
formRef.value?.validate(async (errors) => {
if (!errors) {
try {
const res = await fetch(`http://127.0.0.1:${portStore.getPort()}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: formData.username, password: formData.password })
});
const result = await res.json();
console.log("登录响应:", result);
if (result.ok) {
// 可以存到 Pinia 或 localStorage
userStore.setUser(result.user);
router.replace("/home");
} else {
message.error("登录失败:" + result.message);
}
} catch (err) {
console.error("请求错误:", err);
}
} else {
console.log(errors);
}
});
};
const model = ref({
leftLogo: [
{
url: logo
}
],
rightLogo: [
{
url: logo
}
]
});
// 读取配置文件
const readConfig = async () => {
try {
const config = await window.electronAPI.readConfig();
console.log("读取到的配置:", config);
if (config.leftLogo[0]?.name) {
model.value.leftLogo = [
{
url: await window.electronAPI.loadImageAsBase64(config.leftLogo[0].name)
}
];
}
if (config.rightLogo[0]?.name) {
model.value.rightLogo = [
{
url: await window.electronAPI.loadImageAsBase64(config.rightLogo[0].name)
}
];
}
// 文本类配置(直接覆盖没问题)
if (config?.supportText) {
model.value.supportText = config.supportText;
}
console.log("配置后的模型:", model.value);
} catch (err) {
console.error("读取配置文件失败:", err);
}
};
readConfig();
</script>
<template>
<div class="flex h-full flex-col items-center justify-center">
<header class="w-full">
<div class="mb-4 flex items-center justify-evenly">
<img class="w-[200px]" :src="model.leftLogo[0]?.url" alt="logo" />
<img class="w-[200px]" :src="model.rightLogo[0]?.url" alt="logo" />
</div>
</header>
<main class="loginBox relative h-[70%] w-full">
<div
class="loginForm absolute top-[50%] left-[80%] w-[400px] translate-x-[-50%] translate-y-[-50%] rounded-[10px] bg-white p-[30px]"
>
<n-form ref="formRef" :model="formData" :rules="rules">
<n-form-item-row label="用户名" path="username">
<n-input placeholder="请输入用户名" v-model:value="formData.username" />
</n-form-item-row>
<n-form-item-row label="密码" path="password">
<n-input
type="password"
show-password-on="mousedown"
placeholder="请输入密码"
v-model:value="formData.password"
/>
</n-form-item-row>
</n-form>
<n-button type="primary" block secondary strong @click="login"> 登录 </n-button>
</div>
</main>
<footer>
<p class="text-center whitespace-pre-line">
{{ model.supportText }}
</p>
</footer>
</div>
</template>
<style scoped>
.loginBox {
background-image: url("@/assets/img/bg.jpg");
background-size: 100% 100%;
}
.loginForm {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}
</style>
基本上最基础的electron就是这些,菜鸟这个主要就是读取配置文件、连接数据库读写数据,没有很复杂的操作!
在乌班图上打包
上一篇有说,windows上打包有缺陷,所以这里菜鸟用的方式是:让公司给开一个乌班图的服务器,在服务器上配置一下node环境,将本地代码搞成压缩包传到服务器,在服务器上解压后执行npm i,并打包!
配置服务器上的node环境
第一步:
在乌班图的浏览器上,访问:nodejs.org/zh-cn/downl… ,并选择对应的版本下载即可!
![]()
第二步:
进入下载目录解压,解压后将其转移到 /usr/local/node 目录
cd yourDwonloadPath
tar -xvf node-vxx.xx.xx-linux-x64.tar.xz
移动目录
sudo mv node-vxx.xx.xx-linux-x64 /usr/local/node
第三步:
设置环境变量
vim ~/.bashrc
并添加
export NODE_HOME=/usr/local/node
export PATH=$NODE_HOME/bin:$PATH
让其生效
source ~/.bashrc
然后就可以 node -v 和 npm -v 看看是否真的生效了!
运行
将本地代码,打包并上传到乌班图服务器
解压之前需要下载解压软件
sudo apt update
sudo apt install -y p7zip-full
解压
cd 你上传的文件路径
解压到当前目录
7z x file.7z
解压到指定目录
7z x file.7z -o/home/p/output
然后执行npm i、npm run dev 看能不能运行,如果出现白屏需要修改main.js
const { app, BrowserWindow, ipcMain, dialog } = require("electron");
const { join } = require("path");
const path = require("path");
const { exec, spawn } = require("child_process");
const fs = require("fs");
const express = require("express");
const mysql = require("mysql2/promise");
// 提出去的读写文件操作
const { selectPath } = require("./modules/selectPath.js");
const { runScript, runScriptNoWait } = require("./modules/runScript.js");
const { readDir } = require("./modules/readDir.js");
const { readConfig } = require("./modules/readConfig.js");
const { writeConfig } = require("./modules/writeConfig.js");
const { saveImageToUserData } = require("./modules/saveImageToUserData.js");
const { loadImageAsBase64 } = require("./modules/loadImageAsBase64.js");
const { getSeqChipPath } = require("./modules/getSeqChipPath.js");
const { readFa, readBFile, readCFile } = require("./modules/readFa.js");
// 提出去的API
const { loginApi } = require("./api/login.js");
const { appraisalRecordAddApi } = require("./api/appraisalRecordAdd.js");
const { appraisalRecordListApi } = require("./api/appraisalRecordList.js");
// 打包其实这几个配置无效 —— 如果需要在linux系统上npm run dev运行,要加上 —— 后面有说明(见下方deb包)
// 禁 GPU(消掉噪音) -- 新增
app.disableHardwareAcceleration();
// 关 sandbox(关键)-- 新增
app.commandLine.appendSwitch("disable-gpu");
app.commandLine.appendSwitch("no-sandbox");
app.commandLine.appendSwitch("disable-setuid-sandbox");
// 屏蔽安全警告
// ectron Security Warning (Insecure Content-Security-Policy)
process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "true";
// 新增 -- 看启动是否成功
const htmlPath = join(__dirname, "../dist/index.html");
console.log("Trying to load HTML:", htmlPath);
console.log("Exists:", fs.existsSync(htmlPath));
// 创建浏览器窗口时,调用这个函数。
const createWindow = () => {
const win = new BrowserWindow({
width: 1920,
height: 1080,
fullscreen: false, // 👈 一启动就全屏
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false
}
});
// 启动时最大化(保留窗口按钮)
win.maximize();
// win.loadURL('http://localhost:3000')
// 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(async () => {
await startLocalApi(); // ⭐ 启动本地 API
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
// 保存配置 - 用于设置脚本执行目录
const userDataPath = app.getPath("userData");
const scriptDir = path.join(userDataPath, "scripts"); // 目标文件夹
// 如果目标文件夹有东西,就不拷贝 -- 避免每次运行,都会导致之前配置没有了
if (fs.readdirSync(scriptDir).length > 0) {
console.log("scripts folder is not empty, skip copy");
} else {
const defaultScriptDir = path.join(__dirname, "scripts"); // 源文件夹
// 如果目标文件夹不存在,先创建
if (!fs.existsSync(scriptDir)) {
fs.mkdirSync(scriptDir, { recursive: true }); // 递归创建目录
}
// 读取默认目录下的文件列表,逐个拷贝
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} has copy to scripts`);
}
});
}
// 选择路径
selectPath(ipcMain, dialog);
// 运行脚本 -- 等待结果
runScript(ipcMain, app, path, fs, exec);
// 运行脚本 -- 不等待结果
runScriptNoWait(ipcMain, app, path, fs, spawn);
// 读取某个文件夹下是否有文件
readDir(ipcMain, fs);
// 读取配置文件
readConfig(ipcMain, app, path, fs);
// 写入配置文件
writeConfig(ipcMain, app, path, fs);
// 保存用户上传的图片到用户目录
saveImageToUserData(ipcMain, app, path, fs);
// 加载图片作为 base64 编码
loadImageAsBase64(ipcMain, app, path, fs);
// 获取配置文件下的测序路径,并将下面的文件返回成select可以使用的选项
getSeqChipPath(ipcMain, app, path, fs);
// 读取 FA 文件
readFa(ipcMain, fs, path);
// 读取 B 文件
readBFile(ipcMain, fs, path);
// 读取 C 文件
readCFile(ipcMain, fs, path);
// 连接数据库
let apiServer;
let apiPort;
let mysqlPool;
async function startLocalApi() {
const api = express();
api.use(express.json());
// ⚠️ MySQL 连接池(只初始化一次)
mysqlPool = mysql.createPool({
host: "xxx.xxx.xx.xx",
port: 13306,
user: "barcode_for_medicinal", // 数据库账号
password: "xxxx", // 数据库密码
database: "barcode_for_medicinal", // 数据库名称
connectionLimit: 5 // 连接池最大连接数
});
api.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
next();
});
// 登录接口
loginApi(api, mysqlPool);
// 鉴定记录添加接口
appraisalRecordAddApi(api, mysqlPool);
// 鉴定记录列表接口
appraisalRecordListApi(api, mysqlPool);
// ⭐ 随机端口,只监听本机
apiServer = api.listen(0, "127.0.0.1", () => {
apiPort = apiServer.address().port;
console.log("Local API started on port:", apiPort);
});
}
// 获取本地 API 端口
ipcMain.handle("getApiPort", () => {
return apiPort;
});
修改后,会发现可以运行,但是连不上数据库!
这个时候需要修改 main.js 中连接数据库的地方,不能再用ip地址了,要用本地ip地址
// 连接数据库
let apiServer;
let apiPort;
let mysqlPool;
async function startLocalApi() {
const api = express();
api.use(express.json());
// ⚠️ MySQL 连接池(只初始化一次)
mysqlPool = mysql.createPool({
host: "127.0.0.1", // 修改成这个或者localhost
port: 13306,
user: "barcode_for_medicinal", // 数据库账号
password: "xxxx", // 数据库密码
database: "barcode_for_medicinal", // 数据库名称
connectionLimit: 5 // 连接池最大连接数
});
api.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
next();
});
// 登录接口
loginApi(api, mysqlPool);
// 鉴定记录添加接口
appraisalRecordAddApi(api, mysqlPool);
// 鉴定记录列表接口
appraisalRecordListApi(api, mysqlPool);
// ⭐ 随机端口,只监听本机
apiServer = api.listen(0, "127.0.0.1", () => {
apiPort = apiServer.address().port;
console.log("Local API started on port:", apiPort);
});
}
整个功能可以正常使用后,就可以到打包这一步了。
打包
需要修改package.json(这里已经是菜鸟试错多次后修改好的文件了,所以就不一一列举是怎么踩坑的了)
{
"name": "barcode-medicinal",
"productName": "Barcode Medicinal",
"version": "1.0.0",
"private": true,
"homepage": "http://xxx.xxx.xxx.xx:5050/other/barcodeformedicinal.git", // 一定要有,不然会报错(随便放个git地址即可)
"main": "ElectronSrc/main.js",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"dev:electron": "electron .",
"dev:full": "npm run dev & npm run dev:electron",
"build:vue": "vite build",
"build:electron": "electron-builder",
"build": "npm run build:vue && npm run build:electron -- --linux",
"preview": "vite preview",
"lint": "eslint . --fix --cache",
"format": "prettier --write src/"
},
"build": {
"appId": "com.example.barcodeformedicinal",
"productName": "barcode-medicinal", // 一定要用英文,windows上可以中文
"asar": true,
"directories": {
"output": "dist_electron"
},
"files": [
"dist/**",
"ElectronSrc/**",
"package.json"
],
// 这样写可以打包4个出来
"linux": {
"target": [
{
"target": "deb",
"arch": [
"x64",
"arm64"
]
},
"AppImage",
// 可不要
{
"target": "rpm",
"arch": [
"x64"
]
}
],
"category": "Utility",
"description": "Barcode Medicinal Electron",
"maintainer": "Your Name <youremail@example.com>" // 必须写,假的也行,不然报错
}
},
"dependencies": {
……
},
"devDependencies": {
……
}
}
执行 npm run build,你会得到三个文件,打包后的文件,在package.json中的这里
"directories": {
"output": "dist_electron"
},
里面应该有几个乌班图可以运行的文件
![]()
运行AppImage
其中AppImage文件运行需要下载libfuse2
![]()
sudo apt update
sudo apt install libfuse2
cd dist_electron
chmod +x xxxx.AppImage
./xxxx.AppImage
AI给了第二种方式,但是菜鸟试了不行
![]()
运行deb
运行deb文件
sudo dpkg -i dist_electron/barcode-medicinal_1.0.0_amd64.deb
就会自动帮你安装,如果看到 依赖错误,则继续执行下面的代码(一般不会)
sudo apt -f install
卸载
安装后想删除?
这里还挺复杂,不同的安装方式,删除不一样,这里菜鸟只给出用sudo dpkg安装的卸载方法!
一般执行这一个就行了
sudo apt purge 你的应用名(就是package.json中build中的productName)
清理垃圾 —— 可不执行
sudo apt autoremove
sudo apt autoclean
确保卸载
dpkg -l | grep barcode(没有输出就是正确的)
槽点
这里有一个槽点,你就算执行了清理垃圾这步操作,其实用户数据还是会存在,需要手动删除
cd ~/.config
ll
cd 你的软件名
![]()
至于更深层的菜鸟就没搞了
![]()
都是白屏
这里不管是哪一种,都是白屏!
然后菜鸟执行了这些,Trae查看我的整个项目后给出的(完全不知道原因)
sudo apt install -y libnss3 libatk-bridge2.0-0 libxss1 libgbm-dev libx11-xcb1 libxcomposite1 libxcursor1 libxdamage1 libxi6 libxtst6 libxrandr2 libpangocairo-1.0-0 libgtk-3-0 libdrm2 libxkbcommon0 libwayland-client0 libwayland-server0
./barcode-medicinal-1.0.0.AppImage --disable-dev-shm-usage --no-sandbox --disable-gpu
就成功运行出来了,但是用户肯定不会这样运行啊!
所以还是要看deb,怎么搞?
deb包
一开始菜鸟,按照GPT说的加上了
![]()
却没有任何效果,后面才发现在main.js上加,在打包过程中会直接被忽略,应该写在package.json中的linux配置里,打包好后再按照上面的deb运行方式就可以运行不白屏了!
"linux": {
"target": [
{
"target": "deb",
"arch": [
"x64",
"arm64"
]
},
"AppImage",
// 可不要
{
"target": "rpm",
"arch": [
"x64"
]
}
],
"executableArgs": [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu"
],
"category": "Utility",
"description": "Barcode Medicinal Electron",
"maintainer": "Your Name <youremail@example.com>" // 必须写,假的也行,不然报错
}
AppImage包 —— 骚操作
一开始菜鸟是试的deb不行(但是后面搞好了),那就直接自己创建一个桌面快捷键,然后运行面的命令不就行了?
所以就按照这个来就行
# 创建 .desktop 文件
cat > ~/.local/share/applications/barcode-medicinal.desktop <<EOL
[Desktop Entry]
Name=Barcode Medicinal
Comment=Barcode Medicinal App
Exec=/home/bnzycjd/pbw/dist_electron/barcode-medicinal-1.0.0.AppImage --disable-dev-shm-usage --no-sandbox --disable-gpu
Icon=/home/bnzycjd/Pictures/logo.png
Terminal=false
Type=Application
Categories=Utility;
StartupWMClass=barcode-medicinal
EOL
# 赋予执行权限
chmod +x ~/.local/share/applications/barcode-medicinal.desktop
# 标记为可信任
gio set ~/.local/share/applications/barcode-medicinal.desktop metadata::trusted true
# 刷新应用菜单
update-desktop-database ~/.local/share/applications
建议提前都写好,不然很难敲,感觉vim不好用!
还有这个icon如果一开始没写,后面写了也可能半天不刷新。
这里有一个骚操作,就是在Show Apps里右键先将其Pin to Dash,然后再弄回来就好了!
问GPT为什么?
![]()
反正菜鸟感觉是服务器里面东西不全,所以导致需要这些操作,感觉和代码没啥关系!