Electron内嵌Vue3应用的完整方案
对于已有 Vue3 PC 管理系统,想快速生成 Windows 桌面版 EXE,同时继续共用同一套代码 的场景,目前最推荐的是Electron
原理:
Vue3项目
↓
Electron 外壳
↓
打包成 exe / dmg
完整集成内嵌 dist,目录结构如下:
project-root/
├── electron/
│ ├── main.cjs 主进程
│ └── preload.cjs 预加载脚本
│ └── logo.ico
│ └── logo.icns
├── src/
│ └── router/
│ └── index.js
├── .env.electron electron 专用环境变量
├── electron-builder.yml 打包配置
├── vite.config.js 新增 electron 模式处理
└── package.json 新增 main + scripts
先安装 Electron相关:
npm install electron --save-dev
npm install electron-builder --save-dev
npm install concurrently
npm install wait-on
1、编写 electron/main.cjs
const {
app,
BrowserWindow,
shell,
Menu,
ipcMain,
globalShortcut,
} = require("electron");
const path = require("path");
// 开发时由 concurrently 注入 NODE_ENV=development
const isDev = process.env.NODE_ENV === "development";
// 开发服务器地址(和 vite electron:dev 脚本中端口保持一致)
const DEV_URL = "http://localhost:5174";
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 1280,
minHeight: 768,
title: "你的项目名",
icon: path.join(__dirname, "./logo.ico"),
webPreferences: {
preload: path.join(__dirname, "preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
devTools: true, // 显式允许 DevTools(打包后也生效)
},
show: false,
});
if (isDev) {
// 开发模式:加载 Vite devserver,支持热更新
mainWindow.loadURL(DEV_URL);
mainWindow.webContents.openDevTools();
} else {
// 生产模式:加载打包后的 dist/index.html
mainWindow.loadFile(path.join(__dirname, "../dist/index.html"));
// 临时调试用,排查完删掉这行 电脑f12唤醒不了devtools的可以用这行强制运行的时候打开
// mainWindow.webContents.openDevTools({ mode: "detach" });
}
mainWindow.once("ready-to-show", () => {
mainWindow.show();
mainWindow.maximize();
});
// 区分内部路由和外部链接
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
// 内部路由(包含 # 的 file:// 地址 或 localhost)→ 新建 electron 窗口
const isInternal = url.startsWith('file://') || url.startsWith('http://localhost')
if (isInternal) {
// 创建子窗口打开内部页面
createChildWindow(url)
return { action: 'deny' }
}
// 外部链接 → 系统浏览器打开
shell.openExternal(url)
return { action: 'deny' }
})
}
// 子窗口创建方法
function createChildWindow(url) {
const childWindow = new BrowserWindow({
width: 1280,
height: 800,
title: '你的项目名',
icon: path.join(__dirname, './logo.ico'),
parent: mainWindow, // 设置父窗口
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
devTools: true,
},
})
childWindow.loadURL(url)
Menu.setApplicationMenu(null)
// F12 调试也在子窗口生效
childWindow.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12') {
childWindow.webContents.toggleDevTools()
}
})
}
// 向渲染进程暴露 app 版本
ipcMain.handle("app-version", () => app.getVersion());
app.whenReady().then(() => {
Menu.setApplicationMenu(null);
createWindow();
try {
globalShortcut.register("F12", () => {
const win = BrowserWindow.getFocusedWindow();
if (win) {
if (win.webContents.isDevToolsOpened()) {
win.webContents.closeDevTools();
} else {
win.webContents.openDevTools({ mode: "detach" }); // detach 独立窗口,不影响布局
}
}
});
} catch (e) {
console.error("快捷键注册失败:", e);
}
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
// 退出时注销快捷键
app.on("will-quit", () => {
globalShortcut.unregisterAll();
});
2、编写 electron/preload.cjs
const { contextBridge, ipcRenderer } = require('electron')
// 安全地向渲染进程暴露 Native API
contextBridge.exposeInMainWorld('electronAPI', {
// 获取应用版本号(可在 Vue 组件中用 window.electronAPI.getVersion() 调用)
getVersion: () => ipcRenderer.invoke('app-version'),
platform: process.platform,
// 可按需扩展:文件读写、系统通知、自动更新等
})
3、编写.env.electron
VITE_APP_TITLE=欢迎登录
# electron 打包专用环境变量
VITE_APP_ENV=electron
# 关键:打包后没有 Vite 代理,axios 必须直连后端
# 如果你的 src/utils/request.js 用了 import.meta.env.VITE_APP_BASE_API 作为 baseURL
# 则这里填写完整地址,和正式环境保持一致,带上完整前缀
VITE_APP_BASE_API=https://xxx.com/ai-hos/prod-api
4、编写 electron-builder.yml
appId: com.aiad.hospital
productName: 你的项目名
copyright: Copyright © 2024 公司名
asar: true # 源码打包进 asar,防止直接查看
# 禁止 electron-builder 剥离 devtools 相关代码
electronVersion: null # 不强制指定版本,用项目安装的版本
directories:
output: release # 输出目录
buildResources: resources # 资源目录(可放安装包背景图等)
files:
- dist/**/* # Vite 构建产物
- electron/**/* # electron 主进程文件
- package.json
win:
target:
- target: nsis
arch:
- x64
icon: electron/logo.ico
# 不移除 devtools
extraResources: []
nsis:
oneClick: false # 显示安装向导
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
createStartMenuShortcut: true
shortcutName: AI辅助诊断医院端
installerLanguages:
- zh_CN
language: 2052 # 中文安装界面
publish:
provider: generic
url: http://your-update-server/ # 可选:配置自动更新服务器
publish 字段说明:
不需要自动更新的话,直接删掉整个
publish块即可,对打包没有任何影响如果需要自动更新,publish 中的 url 配置什么?
是一个专门存放更新文件的静态资源服务器地址,electron-builder 打包后会生成以下文件,需要把它们上传到这个地址:
release/ ├── 你的项目名-1.0.0-setup.exe # 安装包 ├── 你的项目名-1.0.0-setup.exe.blockmap ├── latest.yml # 核心:记录最新版本信息,更新检测靠它 └── builder-effective-config.yaml
latest.yml内容大概长这样:yaml
version: 1.0.1 files: - url: 你的项目名-1.0.1-setup.exe sha512: xxxxxx size: 58000000 releaseDate: '2024-01-01T00:00:00.000Z'客户端启动后会去请求
http://your-server/latest.yml,对比版本号决定是否更新。
需要修改的完整内容
1.
electron-builder.ymlpublish: provider: generic # 填你们公司内网或公网的静态文件服务器,用来托管安装包和 latest.yml url: http://192.xxx.30.xxx:9090/electron-update/2.
package.json加入electron-updater依赖npm install electron-updater3.
electron/main.cjs加入更新逻辑const { app, BrowserWindow, shell, Menu, ipcMain, dialog } = require('electron') const { autoUpdater } = require('electron-updater') const path = require('path') const isDev = process.env.NODE_ENV === 'development' // ---- 自动更新配置 ---- function initAutoUpdater(win) { if (isDev) return // 开发模式不检查更新 // 不自动下载,检测到新版本后由用户决定 autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true // 检测到新版本 autoUpdater.on('update-available', (info) => { dialog.showMessageBox(win, { type: 'info', title: '发现新版本', message: `发现新版本 v${info.version},是否立即下载更新?`, buttons: ['立即更新', '稍后再说'], }).then(({ response }) => { if (response === 0) { autoUpdater.downloadUpdate() // 通知渲染进程显示下载进度 win.webContents.send('update-downloading') } }) }) // 下载进度 autoUpdater.on('download-progress', (progress) => { win.webContents.send('update-progress', Math.floor(progress.percent)) }) // 下载完成 autoUpdater.on('update-downloaded', () => { dialog.showMessageBox(win, { type: 'info', title: '更新就绪', message: '新版本已下载完成,重启应用后生效', buttons: ['立即重启', '稍后重启'], }).then(({ response }) => { if (response === 0) autoUpdater.quitAndInstall() }) }) // 已是最新版本 autoUpdater.on('update-not-available', () => { console.log('当前已是最新版本') }) // 更新出错 autoUpdater.on('error', (err) => { console.error('更新检查失败:', err.message) }) // 应用启动后延迟 3 秒检查(避免启动时卡顿) setTimeout(() => { autoUpdater.checkForUpdates() }, 3000) } // ---- 主窗口 ---- function createWindow() { const win = new BrowserWindow({ // ... 同之前配置 ... }) // 窗口创建后初始化更新检测 initAutoUpdater(win) return win }
完整更新流程:
发布新版本流程: 1. 改 package.json 中的 version(如 0.0.1 → 0.0.2) 2. npm run electron:build → 生成 release/latest.yml + 新安装包 3. 把 release/ 目录下的文件上传到静态服务器 http://your-server/electron-update/latest.yml ← 必须 http://your-server/electron-update/xxx-0.0.2.exe ← 必须 4. 用户下次启动旧版本客户端 → 自动请求 latest.yml → 发现新版本 → 弹窗提示 → 下载 → 重启安装总结:
不需要自动更新就删掉
publish块;需要的话,url 填的是专门托管安装包的静态服务器地址。
5、package.json 修改(新增字段和脚本)
{
"name": "aiad-hospital-admin-web",
"version": "0.0.0",
"description": "你的项目名",
"author": "公司名",
"type": "module",
"main": "electron/main.cjs",
"scripts": {
"dev": "vite",
"build:prod": "vite build",
"build:stage": "vite build --mode staging",
"build:analyze": "cross-env ANALYZE=true vite build",
"preview": "vite preview",
// 新增以下三条
"electron:dev": "concurrently -k \"vite --port 5174\" \"wait-on http://localhost:5174 && cross-env NODE_ENV=development electron electron/main.cjs\"",
"electron:build": "vite build --mode electron && electron-builder --win",
"electron:build:preview": "vite build --mode electron && electron-builder --win --dir"
}
}
electron:build:preview用--dir参数,不打 installer 只解压到 release 目录,方便快速验证效果。
6、vite.config.js 修改
export default defineConfig(({ mode, command }) => {
const env = loadEnv(mode, process.cwd());
const { VITE_APP_ENV } = env;
const isBuild = command === "build";
const isProduction = mode === 'production';
const isElectron = mode === 'electron'; // 新增
return {
// 修改 base,electron 打包必须用相对路径
base: isElectron
? './'
: VITE_APP_ENV === "production" ? "/hospital/" : "/",
// ... 其余配置不变 ...
server: {
port: 80,
host: true,
open: !isElectron, // electron:dev 时不自动打开浏览器
proxy: { /* 不变 */ }
},
}
})
7、src/router/index.js 修改
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({
history: createWebHashHistory(
import.meta.env.VITE_APP_ENV === 'electron'
? '/'
: (process.env.NODE_ENV === 'production' ? '/hospital/' : '/')
),
routes: [...]
})
file://协议不支持 HTML History 模式,必须切换为 Hash 模式。electron 打包后从本地文件系统加载,不存在
/hospital/这个子路径的概念,base 必须是/,否则路由会找不到资源。
开发与构建命令
# 开发调试(Vite devserver + Electron 同时启动,支持热更新)
npm run electron:dev
# 打包 exe(先 vite build --mode electron,再 electron-builder)
npm run electron:build
# 快速验证(不打 installer,秒出可执行文件)
npm run electron:build:preview
Mac打包相关
各平台对应产物
| 操作系统 | 打包格式 | 必须在对应系统上打包 |
|---|---|---|
| Windows | .exe (nsis) |
✅ Windows 上打包 |
| macOS | .dmg / .app |
✅ Mac 上打包 |
| Linux | .AppImage |
✅ Linux 上打包 |
核心限制:Mac 包必须在 Mac 机器上打(electron-builder 强制要求),Windows 同理。
package.json 补充:
"scripts": {
"electron:build:mac": "vite build --mode electron && electron-builder --mac",
}
electron-builder.yml 补充 Mac 配置
mac:
target:
- target: dmg # 生成 .dmg 安装包
arch:
- x64 # Intel 芯片
- arm64 # Apple Silicon (M1/M2/M3)
icon: electron/favicon.icns # Mac 必须用 .icns 格式图标
category: public.app-category.business
dmg:
title: 你的项目名
background: resources/dmg-background.png # 可选:dmg 背景图
window:
width: 540
height: 380
Mac 图标准备
① Mac 需要 .icns 格式,可以用命令行转换:
# Mac 上执行,把 png 转成 icns
mkdir favicon.iconset
sips -z 1024 1024 public/favicon.png --out favicon.iconset/icon_512x512@2x.png
iconutil -c icns favicon.iconset -o public/favicon.icns
② 如果已经有 PNG,可以在线转换:
注意:图片需要 1024x1024.png
- 下载: icon.icns
可能出现的问题:
1、点击登录接口请求成功但没跳到首页
1)router.push 路径问题
2)getInfo 接口失败被 catch 踢回登录页
getInfo 失败的原因通常是 token 存储问题。Electron 里 localStorage / sessionStorage 和 Web 行为一致,但要确认 token 确实存进去了。
3)VITE_APP_BASE_API 导致 getInfo 接口 404
登录接口 200 了,但 getInfo 用的接口地址可能不对。在 DevTools Network 里找 /getInfo请求,确认地址和返回值。
1. F12 → Console → 看有没有红色报错
│
├─ 有报错 → 按报错内容修复
└─ 没报错 → 看 Network
2. F12 → Network → 登录后找 getInfo 请求
│
├─ getInfo 404 → VITE_APP_BASE_API 配置问题
├─ getInfo 401 → token 没带上或格式不对
├─ getInfo 200 → 看 generateRoutes 有没有报错
└─ 没有 getInfo 请求 → redirect 跳转路径异常,看原因
通常 RuoYi 的 getToken() 是从 Cookies 里读取的:
// utils/auth.js 典型写法
import Cookies from 'js-cookie'
const TokenKey = 'Admin-Token'
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token) {
return Cookies.set(TokenKey, token)
}
Electron 加载 file:// 协议时,js-cookie 的 Cookie 作用域出现异常,Cookies.set 能执行但 Cookies.get 读回来是 undefined。
✅️ 修复方案:改用 localStorage 存储 token
找到 src/utils/auth.js,修改如下:
import Cookies from 'js-cookie'
const TokenKey = 'Admin-Token'
const isElectron = import.meta.env.VITE_APP_ENV === 'electron'
export function getToken() {
// electron 环境用 localStorage,web 环境用 Cookie
if (isElectron) {
return localStorage.getItem(TokenKey)
}
return Cookies.get(TokenKey)
}
export function setToken(token) {
if (isElectron) {
return localStorage.setItem(TokenKey, token)
}
return Cookies.set(TokenKey, token)
}
export function removeToken() {
if (isElectron) {
return localStorage.removeItem(TokenKey)
}
return Cookies.remove(TokenKey)
}
2、打开新窗口 window.open 没有生效
window.open 在 Electron 里被 setWindowOpenHandler 拦截了,全部外链都被 deny 掉了。
main.cjs 里有这段:
// 把所有 window.open 都拦截转到系统浏览器了
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url) // ← 用系统浏览器打开,不是新 electron 窗口
return { action: 'deny' }
})
同时 fullUrl 在 Electron 里拼出来的地址是:
file:/// origin 部分异常,拼出来的 URL 不对
✅️ 修复方案:
electron/main.cjs 修改 setWindowOpenHandler,区分内部路由和外部链接:
function createWindow() {
mainWindow = new BrowserWindow({
// ...不变
})
// ...加载页面代码不变
// 修改:区分内部路由和外部链接
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
// 内部路由(包含 # 的 file:// 地址 或 localhost)→ 新建 electron 窗口
const isInternal = url.startsWith('file://') || url.startsWith('http://localhost')
if (isInternal) {
// 创建子窗口打开内部页面
createChildWindow(url)
return { action: 'deny' }
}
// 外部链接 → 系统浏览器打开
shell.openExternal(url)
return { action: 'deny' }
})
}
// 新增:子窗口创建方法
function createChildWindow(url) {
const childWindow = new BrowserWindow({
width: 1280,
height: 800,
title: '你的项目名',
icon: path.join(__dirname, './logo.ico'),
parent: mainWindow, // 设置父窗口
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
devTools: true,
},
})
childWindow.loadURL(url)
Menu.setApplicationMenu(null)
// F12 调试也在子窗口生效
childWindow.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12') {
childWindow.webContents.toggleDevTools()
}
})
}
因为这个项目多处用到了 window.open ,所以我封装一个统一的工具方法,全局替换 window.open 调用即可,所有页面只改一处:
① 新建 src/utils/openWindow.js:
/**
* 统一的新窗口打开方法,兼容 Web 和 Electron 环境
* 使用方式与 window.open 完全一致,直接全局替换
*/
export function openWindow(url, target = "_blank", features) {
if (!url) return;
if (import.meta.env.VITE_APP_ENV === "electron") {
// 基础 file:// 地址(index.html 路径部分,不含 hash)
// 这个值一定是正确的,如:file:///E:/.../dist/index.html
const base = location.href.split("#")[0];
// 从各种格式的 url 中提取纯路由 hash 部分
let hashPath = "";
if (url.startsWith("http://") || url.startsWith("https://")) {
// ① 线上完整地址:https://xxx.com/hospital/#/aiad/diagnosis/123
// → 提取 #后面的部分:/aiad/diagnosis/123
const hashIndex = url.indexOf("#");
if (hashIndex !== -1) {
// 内部路由,提取 hash 后跳转
hashPath = url.slice(hashIndex + 1);
window.open(`${base}#${hashPath}`, target, features);
} else {
// 没有 hash,说明是真正的外链(如 pdf、图片)→ 直接打开
window.open(url, target, features);
}
// console.log(1, url, `${base}#${hashPath}`);
return;
}
if (url.startsWith("file://")) {
// ② 已经是 file:// 地址,但需要判断是否是异常拼接的无效地址
// 正常:file:///E:/LZwork/.../dist/index.html#/aiad/diagnosis/123
// 异常:file://./#/aiad/diagnosis/123 ← location.origin 为 '.' 时拼出来的
const isInvalidPath =
url.startsWith("file://./") ||
url.startsWith("file:///./") ||
!url.includes("index.html");
if (isInvalidPath) {
// 提取 hash 部分,用正确的 base 重新拼接
const hashIndex = url.indexOf("#");
if (hashIndex !== -1) {
hashPath = url.slice(hashIndex + 1);
window.open(`${base}#${hashPath}`, target, features);
}
} else {
// 正常的 file:// 地址,直接打开
window.open(url, target, features);
}
// console.log(2, url, `${base}#${hashPath}`);
return;
}
if (url.startsWith("#")) {
// ③ 纯 hash 路径:#/aiad/upload/123
window.open(`${base}${url}`, target, features);
// console.log(3, `${base}${url}`);
return;
}
if (url.startsWith("/")) {
// ④ 绝对路径:/aiad/upload/123
window.open(`${base}#${url}`, target, features);
// console.log(4, `${base}#${url}`);
return;
}
// ⑤ 其他情况(null开头、拼接异常等)→ 尝试提取 # 后面的路由部分
const hashIndex = url.indexOf("#");
if (hashIndex !== -1) {
hashPath = url.slice(hashIndex + 1);
window.open(`${base}#${hashPath}`, target, features);
// console.log(5, `${base}#${hashPath}`);
return;
}
// console.log(6, url);
// 兜底:原样打开
window.open(url, target, features);
return;
}
// Web 环境:完全不处理,原样透传
window.open(url, target, features);
}
覆盖的所有场景
只需维护
src/utils/openWindow.js这一个文件,后续环境逻辑变化只改这里。
② 挂载到全局
在 src/main.js 里挂载到 app.config.globalProperties:
import { openWindow } from '@/utils/openWindow'
const app = createApp(App)
// 挂载全局方法,模板和 js 里都能直接用
app.config.globalProperties.$openWindow = openWindow
③ 批量替换项目里的 window.open
用编辑器的全局搜索替换:
搜索:window.open(
替换:$openWindow(
对于 setup 语法糖的组件,需要用导入方式:
搜索:window.open(
替换:openWindow(
同时在文件顶部加上导入,或者利用
unplugin-auto-import自动导入。
④ 配置 unplugin-auto-import 自动导入(推荐)
在 vite/plugins 里找到 unplugin-auto-import的配置,加上 openWindow:
// vite/plugins/auto-import.js 或类似文件
import AutoImport from 'unplugin-auto-import/vite'
export default function createAutoImport() {
return AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
dts: false,
// 加上这个,openWindow 全局自动导入,无需每个文件手动 import
dirs: ['src/utils'],
})
}
配置后
openWindow在任何组件里可以直接用,不需要 import,和ref、computed一样方便。
更多推荐


所有评论(0)