对于已有 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.yml

publish:
  provider: generic
  # 填你们公司内网或公网的静态文件服务器,用来托管安装包和 latest.yml
  url: http://192.xxx.30.xxx:9090/electron-update/

        2. package.json 加入 electron-updater 依赖

npm install electron-updater

        3. 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.openElectron 里被 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,和 refcomputed 一样方便。

更多推荐