北城笑笑头像
Vue 107 ,下篇:若依管理系统桌面端应用( Electron 桌面应用实战,Vue + Electron )封面图

Vue 107 ,下篇:若依管理系统桌面端应用( Electron 桌面应用实战,Vue + Electron )

前言

这篇是实战篇:把上一篇《前端桌面端应用实现方案(Electron 框架详细介绍)》定下的方案,在真实的若依 Vue3 项目里完整落地到可交付状态——自定义协议加载、登录链路打通、应用图标、electron-builder 打包、四种安装格式、双击就能用的安装包。

读完这篇你能带走四样东西:一套可以照抄的 app:// 协议主进程实现(含两个会白屏的坑的真实报错);桌面端应用图标的生成方法(多尺寸 .ico,附可直接复用的脚本);electron-builder 打包配置逐项解释与四种安装格式的取舍;从打包到安装到运行的完整实测记录。

同样,这里咱们先看下最终实现的效果:

在这里插入图片描述

在这里插入图片描述

实践对象与上篇同一个项目:Vue 3.5.26 + Element Plus 2.13.1 + Vite 6.4.1 的若依 Vue3 管理系统,后端在内网 192.168.x.x:8040(生产由 Nginx 托管 /IPESAgent/ 静态与 /IPESAgent-api/ 接口)。上篇结束时它已经有了 electron/ 骨架、5173 固定端口和能跑通的 dev 链路;本篇所有改动都在这个基础上继续,前端 src/ 依旧一行不动。文中所有报错、体积、耗时均为本机实测。

在这里插入图片描述

图1 封面图

一、起点与目标:从 dev 骨架到可交付应用

1.1 上篇留下的家底

上篇结束时,项目里多了三层东西:

家底内容状态
electron/main.js + electron/preload.cjs最小骨架,dev 链路加载 http://localhost:5173已跑通
vite.config.js5173 固定端口 + VITE_ELECTRON 开关 + vite-plugin-electron 0.29.1已跑通
package.jsonmain 指向 dist-electron/main.js、dev:electron 脚本、Node 24.12.0 + .npmrc 镜像已就绪

这套东西开发期没问题,交付期缺三块:打包后加载什么(dev server 不存在了)、接口转发谁来做(Vite 代理不存在了)、产物怎么分发(还没装 electron-builder)。本篇就填这三块。

1.2 生产链路要复刻的契约

生产环境的前端一直由 Nginx 托管,配置就是两条 location:

location /IPESAgent/ {
    try_files $uri $uri/ /IPESAgent/index.html;   # 静态 + SPA fallback
}
location /IPESAgent-api/ {
    proxy_pass http://127.0.0.1:8040/;            # API 转发 + 剥前缀
}

桌面端要做的,就是让 Electron 主进程把这两条 location 用代码复刻一遍——这就是第二章的全部内容。目标架构如图2所示。

在这里插入图片描述

图2 app:// 协议双分支处理流程图

1.3 三个前置决策

动手前定三件事,后面每一步都顺着它们走:

  • 加载协议:注册 app:// 特权协议,页面 URL 与生产 Nginx 完全同构——这样前端的 base、history 路由、相对 API 前缀、Cookie 四项全部零改动;
  • 后端地址:生产包把内网后端地址写进主进程常量(可用环境变量 DESKTOP_BACKEND_URL 覆盖),dev 模式继续走 Vite 代理互不干扰——两条链路的后端配置物理隔离,改一处不伤另一处;
  • 打包工具:electron-builder 26.18.0,版本锁死,四格式(dir / portable / nsis / zip)一次产出——dir 用于快速验证,nsis/portable/zip 对应三种分发场景。

三个决策的共性:凡是不改前端代码能解决的,就不改前端代码。前端零改动不只是偷懒,它意味着浏览器版和桌面版共享同一份产物,将来浏览器版升级、桌面端重新打包即可,不会出现两套代码分叉维护的地狱。

二、app:// 自定义协议完整实现

2.1 特权协议注册

协议必须在 app.ready 之前注册,四个特权项一个都不能少:

protocol.registerSchemesAsPrivileged([
  {
    scheme: 'app',
    privileges: {
      standard: true,          // 按 http 语义解析路径(URL 规范化、相对地址解析都靠它)
      secure: true,            // 安全上下文:Cookie、剪贴板等 API 依赖
      supportFetchAPI: true,   // 渲染进程的 fetch/axios 才能走该协议
      stream: true             // 支持流式响应
    }
  }
])

一个不起眼但会咬人的细节:standard 生效后,host 会被自动小写化——app://IPESAgent/... 进到处理器里 new URL() 之后 hostname 是 ipesagent。写分支判断别用 === 'IPESAgent' 去比大小写敏感的值,实测日志里 URL 就是小写形态(见 6.4)。

四个特权项"没有它会怎样",值得各说一句:缺 standard,URL 解析退化成不透明字符串,相对地址解析、pathname/hostname 全都错乱,等于协议废了;缺 secure,页面不是安全上下文,Cookie 大概率写不进去、剪贴板等 API 直接被禁,若依的登录态会"记不住";缺 supportFetchAPI,页面里的 fetch/axios 请求根本到不了你的 handler,Network 面板一片红;缺 stream,大文件响应不能流式返回,只能整块读进内存再吐。四个特权项一起注册,一个都别省。

2.2 静态分支:dist 读取 + 剥前缀 + SPA fallback

const DIST_DIR = path.join(app.getAppPath(), 'dist')

// MIME 表:协议处理器必须自己报 Content-Type,浏览器不会猜
const MIME = {
  '.html': 'text/html; charset=utf-8',
  '.js': 'text/javascript; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.map': 'application/json; charset=utf-8',
  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
  '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
  '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
  '.txt': 'text/plain; charset=utf-8'
}

// 静态分支(在 protocol.handle('app') 内):
let pathname = decodeURIComponent(url.pathname)
// URL 为满足 history 路由 base 而带 /IPESAgent/ 前缀,映射 dist 前必须剥掉
pathname = pathname.replace(/^\/IPESAgent/, '')
if (pathname === '' || pathname === '/') pathname = '/index.html'
let filePath = path.normalize(path.join(DIST_DIR, pathname))
// 防目录穿越 + 不存在 + 目录请求 → 回退 index.html(history 路由兜底)
if (!filePath.startsWith(DIST_DIR) || !fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
  filePath = path.join(DIST_DIR, 'index.html')
}
const ext = path.extname(filePath).toLowerCase()
return new Response(fs.readFileSync(filePath), {
  headers: { 'content-type': MIME[ext] || 'application/octet-stream' }
})

静态分支的路径安全细节,写代码时容易被"能用就行"带过去,这里钉死三点:decodeURIComponent 要包 try/catch(恶意或异常的百分号编码会直接抛异常,把整个 handler 打挂);filePath 必须用 path.normalize 并校验 startsWith(DIST_DIR)(防 .. 目录穿越,app://IPESAgent/IPESAgent/../../windows/system32/... 这类请求真实存在);目录请求与不存在文件一律回退 index.html(history 路由的兜底,也是 nginx try_files 的同义实现)。三点加起来不到十行,但少一点都是一颗雷。

2.3 API 分支:前缀判断 + net.fetch 转发 + 502 兜底

// 前端 axios baseURL='/IPESAgent-api' 是相对前缀,实际请求形如
//   app://IPESAgent/IPESAgent-api/login
// 所以用 pathname 前缀判断(同时兼容 hostname === 'IPESAgent-api' 的直接地址)
const isApi = url.hostname === 'IPESAgent-api' || url.pathname.startsWith('/IPESAgent-api')
if (isApi) {
  const apiPath = url.pathname.replace(/^\/IPESAgent-api/, '') + url.search
  return net.fetch(BACKEND_URL + apiPath, {
    method: request.method,
    headers: request.headers,
    body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
    redirect: 'follow',
    // 关键:绕过自定义协议,否则这个请求会再进一次本 handler,接口一直 pending
    bypassCustomProtocolHandlers: true
  }).catch(err => {
    return new Response(JSON.stringify({ code: 500, msg: '后端接口连接异常' }), {
      status: 502,
      headers: { 'content-type': 'application/json; charset=utf-8' }
    })
  })
}

三个要点:bypassCustomProtocolHandlers 不传就是接口无限 pending(上篇第七章提过,这里落地);502 兜底让前端 axios 能拿到结构化的若依信封而不是网络错误;转发后端地址从环境变量 DESKTOP_BACKEND_URL 读取,内网换后端不用改代码。

请求转发还有一个容易漏的细节:method、headers、body 三个都要透传。GET/HEAD 不带 body(axios 的 GET 无 body,但协议层面给 net.fetch 塞了空流反而可能报错,所以显式判空);POST 登录、PUT 改密码这类请求的 body 是 ReadableStream,直接透传给 net.fetch 即可。只转 URL 不转 body,登录这类 POST 接口就是"后端收到空参数"的疑难杂症。

2.4 dev / prod 切换、单实例锁与日志

// dev / prod 双链切换:只差"加载什么 URL"这一行
if (app.isPackaged) {
  // 关键:必须带 /IPESAgent/ 路径前缀(等价 nginx 访问入口 http://host/IPESAgent/)
  mainWindow.loadURL('app://IPESAgent/IPESAgent/')
} else {
  mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL || 'http://localhost:5173')
}

// 单实例锁:双击图标两次也只保留一个实例
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) {
  app.quit()
} else {
  app.on('second-instance', () => { /* 聚焦已有窗口 */ })
}

另外给主进程加了一个落盘日志(userData/logs/main.log):打包后的应用没有控制台,白屏排查全靠它。渲染进程的 console-message、did-fail-load、render-process-gone 三个事件全部转发进日志,再加一个延迟 3 秒的 DOM 检查(#app 子元素数、当前页面类型)——这是桌面应用"看不见的现场"里最值钱的四个探针。

窗口本身的配置也一并交代(完整代码见 6.1 的项目文件):show: false 创建、ready-to-show 再显示(上篇 2.1 的启动防闪烁);webPreferences 里 contextIsolation: true、nodeIntegration: false、preload 指向 app.getAppPath() + electron/preload.cjs;宽高 1280×800。这些和上篇骨架一致,本篇没有新增花活——桌面应用的窗口层到这里就定型了,后续演进(托盘、菜单)都在这层往上叠。

2.5 两个白屏坑:实测报错与修复

这一节是本篇最有原创价值的部分,两个坑都真实踩过、真实修好。

坑一:加载 URL 少了 /IPESAgent/ 前缀,页面落进路由 404。 第一版写的是 loadURL('app://IPESAgent/index.html'),窗口能开、标题也正常,但页面永远停在登录前的状态。原因:createWebHistory('/IPESAgent/') 的 base 是 /IPESAgent/,URL pathname /index.html 不匹配 base,路由直接落进兜底 404。加载 URL 必须等价 nginx 的访问入口:app://IPESAgent/IPESAgent/。

坑二:静态分支没剥前缀,JS 被当 HTML 喂给模块加载器。 带上 /IPESAgent/ 路径后,静态资源请求变成了 app://IPESAgent/IPESAgent/static/js/xxx.js,处理器把整个 pathname 拼进 dist 路径 → 文件不存在 → fallback 回 index.html → JS 拿到 text/html。实测报错:

Failed to load module script: Expected a JavaScript-or-Wasm module script but
the server responded with a MIME type of "text/html". Strict MIME type checking
is enforced for module scripts per HTML spec.

静态分支映射 dist 前必须先剥掉 /IPESAgent 前缀。两个坑合起来看就一句话:URL 的"对外形态"要和 nginx 一致(带前缀),文件的"磁盘路径"要剥掉前缀——一个管路由匹配,一个管文件定位,别混在一起。

排查工具也交代清楚,下次你踩白屏坑能直接上手:打包版窗口里 Ctrl+Shift+I 一样能开 DevTools(开发阶段可保留),Network 面板看 JS 请求的响应头 Content-Type——text/html 就是剥前缀问题,404 就是路径映射问题,Failed to load module script 就是 MIME 问题;再配合主进程日志里的 页面诊断 行(页面类型 + URL),日志 + DevTools 两条腿走路,白屏没有查不出来的。

三、桌面端的登录与菜单链路

3.1 Cookie 与 Admin-Token:app:// 下实测可用

若依的 token 存在 Cookie(Admin-Token,js-cookie 写 document.cookie)。app:// 协议注册了 standard + secure 后属于安全上下文,Cookie 读写与 http 下行为一致,前端零改动直接可用——这是选自定义协议路线的直接回报,本机实测登录页 Cookie 读写正常。验证方式很简单:打包版窗口里按 Ctrl+Shift+I 打开 DevTools,Application → Cookies 面板能看到 app://ipesagent 域下的 Admin-Token,登录成功后会写入值——这一步是"登录态能不能记住"的最终裁决,比任何理论分析都硬。

顺带验证上篇埋的 IPC 链路在打包版里同样活着:DevTools Console 执行 await window.desktopAPI.getVersions(),返回 {electron:"45.0.0", chromium:"...", node:"..."}。preload 从 app.asar 里加载、contextBridge 白名单暴露、ipcRenderer.invoke 穿透到主进程——整条桥在打包形态下原样工作,这说明上篇 2.2/2.3 的安全模型不是开发期摆设,打包后同样成立。

3.2 验证码与后端联调:502 兜底的实测证据

登录页挂载即请求 /captchaImage。实测时内网后端恰好未开机,整条链路给出了教科书级的证据:

API 转发: GET /captchaImage
API 转发失败: /captchaImage net::ERR_CONNECTION_REFUSED
[renderer console] AxiosError: Request failed with status code 502
页面诊断: 子元素数=1 页面=登录页 URL=app://ipesagent/IPESAgent/login?redirect=/index

四行日志串起完整链路:axios 请求 → 协议处理器截获 → 转发后端失败 → 502 兜底 JSON → 前端正常报错处理。后端恢复后刷新即可正常登录,验证码开关(sys.account.captchaEnabled)是后端配置,桌面端代码不受影响。补充一种易混场景:后端本身是通的、只是换过地址,桌面端却一直 502——那是 dev 与打包版两套后端配置不一致的问题,完整事故复盘与修复流程见第七章"后端地址的两套配置与一次真实 502 事故"。

联调期的一个实用建议:让后端在 sys_config 里临时关掉 sys.account.captchaEnabled,开发阶段每次登录省一次看验证码,联调效率肉眼可见地提升,上线前再开回。登录实测的正确姿势是 “先 502 后 200”:故意先停后端确认兜底链路,再开后端确认正常链路——两条腿都验过,交付才有底气。

完整登录链路的预期日志(后端正常时)也列出来,验收时对着看:

API 转发: GET /captchaImage                    ← 登录页拉验证码
API 转发: POST /login                          ← 提交登录(body 透传验证)
API 转发: GET /getInfo                         ← 拉用户信息(携带 Cookie 里的 Admin-Token)
API 转发: GET /getRouters                      ← 拉动态菜单
页面诊断: 页面=主框架                            ← 登录成功后进入若依主框架

这五行就是"桌面端登录和菜单显示打通"的完整证据——每一行对应若依前端登录流程的一个环节,且全部经过 app:// 协议的 API 分支。菜单能出来几条、哪些页面点开是空白,接着看 3.3 的排查。

3.3 动态菜单:桌面端不改变任何机制

菜单依旧由后端 getRouters 下发、前端 loadView 按路径匹配 src/views。桌面端没有引入任何新机制,菜单空白问题在桌面端和浏览器端的表现完全一致——排查方法也一样:确认后端菜单表的 component 字段能在 src/views/** 找到对应 .vue。

这里必须把本项目的实际风险摆出来:这套若依代码是"IPES 业务前端"的拷贝裁剪版,对比同机原始工程,缺了 16 个业务页面(views/home、innovate、protect、evaluate、valueAppraisal 等)和 8 个 API 模块。后端菜单如果指向这些组件,loadView() 会静默返回 undefined,点菜单进去就是空白页。两条解法任选:把缺失的页面从原始工程补回 src/views(业务菜单完整可用);或让后端裁剪掉对应菜单(只保留若依原生模块)。这个坑与桌面端无关,但桌面端交付验收时一定会撞上,提前排掉比交付后挨个解释强。

四、桌面端应用图标:从无到有

4.1 Windows 图标格式:.ico 到底是什么

.ico 是一个容器格式:一个文件里可以装多张不同尺寸的图(16/24/32/48/64/128/256 px),系统按显示场景自动取用——资源管理器小图标视图用 16,大图标用 48/256,任务栏再各取所需。只放一张大图不是不行,但多尺寸齐全时小尺寸下才不会糊成一团。

格式上有个"两代"之分值得知道:老式 ico 条目存的是 BMP 位图(带 1 位透明掩码),Vista 之后标准做法是在 ico 容器里直接塞 PNG 压缩条目(带完整 alpha 透明通道)——现代 Windows 全部支持,electron-builder 也只认这种。所以"生成 ico"本质就是两步:画一张大图 → 缩放出多个尺寸 → 按 ICONDIR 目录结构把 PNG 字节拼进容器。

electron-builder 对 win.icon 的要求:ico 必须包含至少一个 256×256 条目,否则打包时直接报错或回退到 Electron 默认图标。

4.2 生成方式对比

方式成本适用场景
设计工具(PS / Figma / 在线生成器)学习成本 + 时间有设计师或对视觉有要求
AI 生图 + 工具转多尺寸中等快速出有设计感的图标
脚本生成(本文实测)一次性写脚本,终身复用内网系统、批量项目、无设计资源

本文选脚本路线的原因很实在:内网管理系统的图标诉求是"不丑、不糊、是自己的 Logo",不是拿视觉设计奖;脚本一次写好,换项目改个字母重跑一次就行,而且生成过程可审计(尺寸、格式、像素自检都有断言)。有设计资源时先用设计工具出 1024 主图,再拿脚本后半段转多尺寸 ico,两种方式还能组合。

4.3 脚本实测:PowerShell 画出多尺寸图标

本项目在 build/gen-icon.ps1 用 System.Drawing 画了一个"科技蓝→紫渐变圆角底 + 白色 R 字母"的图标,并合成 7 个尺寸的 .ico(PNG 压缩条目,Vista+ 标准格式)。核心逻辑:

# 1) 1024 主图:渐变圆角底 + 内圈描边 + 字母
$bmp = [System.Drawing.Bitmap]::new(1024, 1024)
$brush = [System.Drawing.Drawing2D.LinearGradientBrush]::new(
  (0,0), (1024,1024), [Color]::FromArgb(255,31,78,121), [Color]::FromArgb(255,108,63,181))
# ...绘制后按 16/24/32/48/64/128/256 缩放,每个尺寸存成 PNG 字节
# 2) 拼 ICO 容器:ICONDIR 头(6 字节) + 每条 16 字节目录项 + PNG 数据
#    (256 尺寸在目录项里宽高字段写 0)

实测产物:build/icon.png(1024 主图 103.9KB)+ build/icon.ico(45KB,含 16~256 七档)。脚本还带程序化自检(中心像素应为字母白色、角落应为渐变蓝),图标这种"一眼错但没人一眼看"的产物,用像素断言兜底最省心。

两个脚本层面的坑提前说:256 尺寸在 ICO 目录项里宽高字段必须写 0(0 代表 256,直接写 256 会被当成 0 解析);画小尺寸时用 HighQualityBicubic 而不是 NearestNeighbor,否则 16px 档糊成色块。生成结果如图3所示。

在这里插入图片描述

图3 生成的桌面端应用图标

五、electron-builder 打包配置与四种安装格式

5.1 安装与镜像

npm install --save-dev electron-builder@26.18.0

# 打包时需要下载两类资源,国内都要配镜像:
# 1) electron 二进制 zip(158MB)—— 本项目实测华为云镜像直连约 2MB/s,npmmirror 被限速到 0.02MB/s
set ELECTRON_MIRROR=https://repo.huaweicloud.com/electron/
# 2) winCodeSign / nsis / 7zip 等打包器资源 —— 官方只提供 npmmirror 镜像
set ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/

一条省时间的实测技巧:electron 的 zip 会缓存到 %LOCALAPPDATA%\electron-builder\Cache\electron\,可以手动把 zip 下载好预置进缓存,打包时直接跳过 158MB 的下载(本项目实测:预置缓存后整包打包 307 秒,其中下载耗时归零)。打包器资源(winCodeSign/nsis/7zip,各几 MB)第一次会从镜像下载进缓存,之后全部命中。另外 electron-builder 26 对 Node 版本有要求(本项目 Node 24.12.0 通过),装之前先把 Node 切好,省得在打包日志里看到与项目代码无关的版本报错。

5.2 build 字段逐项解释

{
  "build": {
    "appId": "com.ipes.ruoyi.desktop",       // 应用唯一标识:注册表、卸载项、更新都认它
    "productName": "若依桌面端",              // 用户可见名称:exe 名、快捷方式名、安装目录显示名
    "directories": {
      "output": "release",                    // 产物输出目录
      "buildResources": "build"               // 图标等构建资源目录(gen-icon.ps1 输出到这里)
    },
    "files": ["dist/**", "dist-electron/**", "electron/**"],  // 打进 asar 的内容
    "asar": true,                             // 打包成 app.asar 单文件
    "win": {
      "icon": "build/icon.ico",
      "target": [
        { "target": "dir", "arch": ["x64"] },
        { "target": "portable", "arch": ["x64"] },
        { "target": "nsis", "arch": ["x64"] },
        { "target": "zip", "arch": ["x64"] }
      ]
    },
    "nsis": {
      "oneClick": false,                      // 关闭一键安装,走向导
      "perMachine": false,                    // 按用户安装,不需要管理员权限
      "allowToChangeInstallationDirectory": true,
      "createDesktopShortcut": true,
      "createStartMenuShortcut": true,
      "shortcutName": "若依桌面端"
    }
  }
}

files 是踩坑重灾区:默认会把整个项目目录(含 node_modules)打进去。本项目三个入口都要在:dist/**(前端产物)、dist-electron/**(构建后的主进程)、electron/**(preload.cjs 源文件,主进程按 app.getAppPath() 引用它)。漏掉任何一个入口,打包版就是白屏或主进程起不来。

打完包值得花一分钟验证 asar 内容:npx asar list release\win-unpacked\resources\app.asar(或装 @electron/asar)看三个入口是否都在、package.json 的 main 字段指向的文件是否在包里。files 配错的表现全是"打包成功、运行秒退",打包日志一点异常都没有——包前核对文件清单,是这类故障唯一的低成本防线。

5.3 四种安装格式对比

release/ 是 electron-builder 的输出目录,可删可重建、不入库;产物文件名里的版本号取自 package.json 的 version。 下表是 release 目录里四种安装格式的对比。

格式产物形态体积(本项目实测)安装卸载适用场景
dirwin-unpacked 目录约 400MB(解压态)免安装,拷贝即用删目录内网共享盘分发、快速验证
portable单个 exe116.9MB免安装,双击即用删文件U 盘分发、临时使用
nsisSetup 安装向导136.9MB向导安装,写注册表设置→应用卸载正式交付首选
zip压缩包174.8MB解压后运行删目录配合内部推送工具

四个格式本项目全部实测产出(见 6.3)。机制上各说一句:

  1. dir 就是解压态的完整应用目录,electron-builder 的所有其它格式都以它为中间产物;
  2. portable 是一个自解压启动器,双击后把应用解到临时目录运行、退出时清理,不写注册表不建快捷方式,适合"用完即走";
  3. nsis 是正式的安装向导(本项目配置 oneClick: false),写卸载注册表项、建桌面与开始菜单快捷方式,按用户安装(perMachine: false)连管理员权限都不用要;
  4. zip 只是 win-unpacked 的压缩版,无任何安装语义。

这四行是 build.win.target 的四个打包目标名,不是文件名——electron-builder 按目标生成不同名字。

build.win.target 是 electron-builder 的配置字段,用来声明"在 Windows 上要打出哪几种安装包"——它是个数组,写了几项就出几种产物。

在这里插入图片描述

位置:项目根目录 package.json 的 "build" → "win" → "target" 里,长期配置就写在这个字段里**。

  1. dir = win-unpacked\ 文件夹、
  2. portable = 若依桌面端 3.9.2.exe(不带 Setup)
  3. nsis = 若依桌面端 Setup 3.9.2.exe(带 Setup)
  4. zip = 若依桌面端-3.9.2-win.zip

在这里插入图片描述

另外两个不属于任何一种格式,是 electron-builder 的附属产物:.blockmap(增量更新索引)、builder-debug.yml(本次打包配置快照)。

选型口诀:内部正式交付用 nsis;测试和零散分发用 portable;需要压缩传输用 zip;dir 是中间产物也是"绿色版"。另外说明:msi 格式 electron-builder 不直接支持,需要 WiX 或第三方工具,管理系统场景 nsis 完全够用。

5.4 打包实测记录

npx electron-builder --win
→ electron-builder version=26.18.0 os=10.0.22000
→ packaging platform=win32 arch=x64 electron=45.0.0
→ building target=zip / portable / nsis ... 全部成功
→ 完整打包耗时 307 秒(首次 524 秒,差在 electron zip 与打包器资源的下载)

一个 Windows 上高频出现但无害的插曲:directory rename failed, retrying ... code=EPERM / EBUSY——win-unpacked 目录被占用(杀毒扫描、资源管理器预览都会占)时 electron-builder 会自动重试并最终 fallback 到"复制+删除",看到重试日志不用管,只要最后 exit 0。另一个确定性日志是 no code signing certificate configured, signing is skipped——未配置签名证书的必然提示,正式分发前申请代码签名证书即可(见第七章)。

常用 CLI 参数也记一下:--win dir 只出免安装目录(改完主进程代码后的快速验证就靠它,本项目迭代期用它一分钟一包);--win portable / --win nsis 单出某格式;--publish never 关闭发布检测(内网打包必须加,否则它去找 GitHub Releases 会拖时间或报错)。

六、打包、安装、运行全流程实测

6.1 项目实际改动清单

本篇在项目里新增/修改的文件:

文件改动对应章节
electron/main.jsapp:// 协议双分支完整实现、dev/prod 切换、单实例锁、日志系统、DOM 诊断二、2.x
package.jsonbuild 字段(electron-builder 配置)、desktop:build 脚本、electron-builder@26.18.0 依赖五、5.2
build/gen-icon.ps1新增:图标生成脚本(可复用)四、4.3
build/icon.png + build/icon.ico新增:生成的应用图标四、4.3
release/新增:打包产物目录(4 种格式)五、六
src/**零改动全程

6.2 踩坑复盘:从白屏到打通的完整时间线

这一段把本篇开发过程里真实的排错顺序复盘出来——它比任何"解决方案"都值钱,因为下次你遇到的不一定是这两个坑,但排查的走法是一样的:

  1. 第一版打包 → 窗口能开、标题正常、但卡在启动加载动画。标题来自 index.html 静态值,证明不了页面渲染成功——这是"标题正常≠页面正常"的第一课;
  2. 给主进程加诊断探针(console-message / did-fail-load / DOM 检查)→ 重打包 → 日志显示渲染进程报了 Failed to load module script... MIME type of "text/html" → 定位到 JS 被当成 HTML 返回;
  3. 排查中又发现一个隐蔽坑:改了 electron/main.js 源文件后直接打包,但打包用的是旧的 dist-electron/main.js——改了源码没重建构建产物,日志和代码对不上,浪费了一轮打包时间;
  4. 修复剥前缀问题后 → JS 正常执行 → 但页面还是不对,DOM 诊断显示"未知页面" → 顺藤摸到路由 base 不匹配(URL 少了 /IPESAgent/ 前缀)→ 修复;
  5. 最终打通:日志出现完整的"登录页渲染 + API 转发 + 502 兜底"证据链(见 6.4)。

复盘一句话:打包版的 bug 都藏在"看不见的控制台"里,先把日志探针埋好,再动手打包——探针成本十分钟,省的是后面每一次盲猜的小时数。

6.3 三种运行形态实测

形态实测结果
win-unpacked 直跑窗口标题=生产环境标题,登录页渲染成功
portable 单文件直跑双击即用,窗口标题正确,登录页渲染成功
nsis 静默安装(Setup /S)6 秒装完,安装目录 %LOCALAPPDATA%\Programs\ruoyi,桌面快捷方式 若依桌面端.lnk 创建成功,安装版运行正常

安装后的目录结构看一眼就明白 nsis 干了什么:安装目录里是 若依桌面端.exe + locales/ + resources/app.asar 三件套,卸载信息写在注册表(所以"设置→应用"里能卸载),快捷方式指向安装目录的 exe。卸载 = 删目录 + 清注册表项,没有别的残留;portable 则连注册表都不碰,删文件即卸载。

两个实测细节:安装目录名用的是 package.json 的 name(ruoyi),不是 productName——想改安装目录名就改 name 字段或 nsis 配置;userData 目录同理是 %APPDATA%\ruoyi(日志、Cookie、本地缓存都在这里),排查打包版问题先来这个目录。另外提一句:nsis 静默安装用 Setup /S 参数即可(本项目实测 6 秒装完),适合内部批量部署脚本;想要安装日志再加 /LOG=路径。

这次实测还暴露了一个有意思的现象:dev 模式与打包版共用 userData 后,单实例锁会互相排斥——dev 窗口开着时双击打包版,后者拿不到锁、静默退出(exit 0、零日志),第一反应绝对是"打包坏了"。测试打包产物前先确认 dev 窗口关了,这条已经写进第七章注意事项,这里用实测过程再强调一遍。

6.4 运行证据链:几行日志定乾坤

打包版没有控制台,本篇给主进程加的日志系统派上了用场。后端未开机时的实测日志(%APPDATA%\ruoyi\logs\main.log):

[12:18:19] 生产模式:加载 app://IPESAgent/IPESAgent/
[12:18:19] 页面加载完成: app://ipesagent/IPESAgent/
[12:18:20] API 转发: GET /captchaImage
[12:18:22] API 转发失败: /captchaImage net::ERR_CONNECTION_REFUSED
[12:18:22] 页面诊断: 子元素数=1 页面=登录页 URL=app://ipesagent/IPESAgent/login?redirect=/index

这五行日志证明了整条生产链路是通的:协议加载 → 路由匹配 → 登录页渲染 → axios 请求被 API 分支截获并转发——后端恢复后,这条链路上最后一个环节(登录、getRouters 菜单)就能完整跑起来。运行效果如图4所示。

在这里插入图片描述

图4 打包版运行效果 + 主进程日志

6.5 分发前的人工验证清单

把安装包发给用户之前,自己先过一遍,每条都是一票否决项:

  • 双击 portable → 窗口弹出、登录页渲染(3 秒内),不是停在加载动画;
  • Setup 安装 → 桌面快捷方式出现 → 从快捷方式启动正常 → 任务栏图标是本项目图标而不是 Electron 默认图标;
  • 卸载 → 设置→应用 里能找到"若依桌面端"并卸载干净(nsis 版);
  • 断网双击 → 页面仍能出登录页(静态资源全在本地),只是验证码区域报后端异常——这恰好验证了"离线可用"这个桌面化的核心卖点;
  • 日志落盘 → %APPDATA%\ruoyi\logs\main.log 有"生产模式:加载 app://IPESAgent/IPESAgent/"开头的启动记录。

验收通过后,给使用方/运维的交接物就三样:Setup 安装包(正式分发)、portable 单文件(备用)、一份两句话的说明(后端地址在哪配、日志在哪看)。内网系统交付最怕"能用但没人说得清怎么装怎么查",把日志路径和后端配置边界写进交接说明,等于把 6.4 的排查能力平移给了现场。

七、注意事项

两个白屏坑的排查顺序。 打包版白屏(或卡在启动加载动画),按"URL 形态 → 文件路径"两步查:先看日志里 页面加载完成 的 URL 是否带 /IPESAgent/ 前缀(坑一);再开 DevTools 看 Network 里 JS 是否拿到 text/html(坑二)。加载动画卡死比白屏更常见——那是 index.html 的静态 loader 还在转,JS 根本没执行,别被"页面有内容"骗了。

改了 electron/main.js 必须重建 dist-electron。 打包进 asar 的是 dist-electron/main.js(构建产物),不是 electron/main.js(源文件)。vite-plugin-electron 只在 vite 跑的时候构建它,所以打包脚本必须写成 set VITE_ELECTRON=true&& npm run build:prod && electron-builder --win——本项目最初的 desktop:build 脚本漏了开关,结果"改了代码打出来的还是旧包",日志对不上才暴露。每次打包前确认 dist-electron/main.js 的修改时间。

ELECTRON_RUN_AS_NODE 会让打包产物也"变傻"。 某些 CI/自动化环境会设置 ELECTRON_RUN_AS_NODE=1,此时连打包出来的 exe 都会以纯 Node 模式运行(实测:exe 秒退、报 bad option 或 electron 无命名导出)。测试打包产物前先 echo $env:ELECTRON_RUN_AS_NODE 确认是空的——资源管理器双击不受影响,因为普通用户环境没有这个变量。

单实例锁会互斥 dev 与打包版。 dev 模式和打包版共用同一个 userData(%APPDATA%\ruoyi),锁也共用:dev 窗口开着时双击打包版会静默秒退(exit 0,无任何报错)。这是特性不是 bug,但排查"为什么打包版打不开"时先看看 dev 窗口关了没有。

EPERM 重试日志不用慌。 打包时 directory rename failed, retrying ... code=EPERM/EBUSY 是 Windows 上 win-unpacked 被占用(杀软扫描、资源管理器预览)的常态,electron-builder 会自动重试并 fallback 复制,exit 0 就是成功。真出问题的是杀软把打包进程卡死,那是另一个话题。

未签名产物的三件套。 没配置代码签名时打包日志必现 signing is skipped,产物会依次触发:SmartScreen"未知发布者"提示、部分国产安全软件误报、公司安全策略拦截。正式分发前申请代码签名证书;内网环境至少提前把产物路径报备加白。

asar 只读与运行期落盘。 打包后应用资源在 app.asar 里是只读区,运行期要写的文件(日志、配置、缓存)统一走 app.getPath('userData')——本篇的日志系统就是这么写的。想改应用目录下的任何东西,都是在跟打包机制对着干。

ESM 主进程在 asar 里的两个前提。 本项目 package.json 是 "type": "module",打包后的主进程 dist-electron/main.js 以 ESM 形式跑在 app.asar 里——这要求两点都满足:dist-electron/ 必须写进 files 打包进去**,且 main 字段路径与 asar 内实际路径一致**。任何一个不满足,双击产物就是"秒退 + 无日志",而日志系统又还没起来,属于最难查的一类故障——所以文件清单那一步一定要对着 asar 内容核对一遍。

中文 productName 的产物命名。 本项目 productName: "若依桌面端" 实测全流程可用:产物名是"若依桌面端 Setup 3.9.2.exe"、快捷方式名"若依桌面端.lnk"都正常。但要提醒:安装目录名仍取自 package.json 的 name(英文 ruoyi),中文只出现在用户可见层。如果内网推送脚本要精确引用产物文件名,注意文件名里的空格(“若依桌面端 3.9.2.exe”)——写脚本时加引号,别在 CI 里被空格咬一口。

版本号就是升级协议。 产物名里的 3.9.2 来自 package.json 的 version。升级流程固定为:改 version → 重新打包 → nsis 覆盖安装(同一 appId 会走升级路径,用户数据不动)。改版本号不是走形式,它是内网"你装的是哪版"的唯一答案——日志、关于页、故障上报全靠它定位,别让三个环境共用同一个版本号。

桌面端没有刷新按钮,刷新靠 Ctrl+R。 第一次用桌面版的用户十有八九会找"浏览器那个刷新按钮"——桌面窗口没有工具栏,这个按钮根本不存在。替代方案按优先级:Ctrl+R 刷新(Electron 默认菜单 View → Reload 的快捷键,只要没禁用菜单就始终可用);Ctrl+Shift+R 强制刷新(忽略缓存);Ctrl+Shift+I 开 DevTools 后用里面的刷新;F5 默认无效——那是浏览器习惯按键,Electron 没有绑定。想彻底自定义(隐藏菜单、重绑快捷键)就用 Menu.setApplicationMenu(null) 加 globalShortcut 处理,但内网系统建议保留默认菜单:Ctrl+R 就是零成本的"刷新按钮"。完整组合键速查见第八章总结。

后端地址的两套配置与一次真实 502 事故。 桌面端提示"系统接口502异常",最常见的原因不是代码坏了,而是打包版连的后端和 dev 版不是同一个配置——本项目就真实发生过一次完整事故,值得从头讲清楚。

事故背景:团队把后端从 192.168.110.90:8040 换到了 192.168.110.14:8080,只改了 .env.development 里的 VITE_APP_LOCAL_URL。结果浏览器版立刻正常,双击桌面图标却一直弹 502,验证码出不来。原因一句话:.env.development 是 Vite 开发服务器的代理配置,打包后 Vite 根本不存在,打包版压根不读它——打包版的后端地址写在 electron/main.js 的 BACKEND_URL 常量里。两套配置各管各的链路,改了 A 处却指望 B 处生效,就是这次事故的全部真相:

运行形态后端地址的配置位置备注
浏览器 / npm run dev.env.development 的 VITE_APP_LOCAL_URLVite 代理转发,改动即时生效
双击桌面图标(打包版)electron/main.js 的 BACKEND_URL可用环境变量 DESKTOP_BACKEND_URL 覆盖

修改后的完整流程四步,一步都不能少:

# ① 改 electron/main.js:BACKEND_URL 换成新地址
# ② 重建:VITE_ELECTRON=true 才能同步产出 dist + dist-electron(只改源码不重建等于白改)
set VITE_ELECTRON=true&& npm run build:prod
# ③ 重打包:四格式全部重新产出
npx electron-builder --win
# ④ 重新分发:正式安装走 nsis 覆盖安装(同一 appId 走升级路径,用户数据不动);
#    测试用 portable 直接覆盖运行即可

验证只看一行日志:%APPDATA%\ruoyi\logs\main.log 里出现 API 转发: GET /captchaImage 且后面没有 API 转发失败,502 即消失(本项目实测:按此流程修复后,验证码立即正常显示、可正常登录)。记住这条铁律:换后端 = 两处配置 + 重建 + 重打包 + 重新分发,漏任何一步都是"浏览器好了桌面端没好"。如果后端地址会经常变,就把地址挪到 userData 下的配置文件里,每次改配置免重打包。

安装包体积的预期管理。 portable 116.9MB / nsis 136.9MB / zip 174.8MB,这是 Electron 应用含 Chromium 的正常量级,不是配置错了。想显著缩小只能换 Tauri 路线(上篇 1.2 的取舍),管理系统场景没必要。

八、本文总结

把这两篇连起来,整个"若依 → 桌面端"的路径可以压缩成一张地图:

  • 方案层(上篇):Electron 是管理系统桌面化的首选;加载方式选自定义协议,零改动复刻 Nginx 契约;
  • 实现层(本篇):主进程用 protocol.handle 分两个分支——静态分支管文件与路由兜底,API 分支管转发与 502 兜底;URL 形态对齐 nginx(带前缀)、磁盘路径剥前缀,这一条理解了,白屏坑永远绕着你走;
  • 交付层(本篇):图标多尺寸 .ico + electron-builder 四格式一次产出,nsis 正式交付、portable 零散分发;未签名产物的 SmartScreen 与杀软问题是分发阶段唯一绕不开的非技术环节。

在这里插入图片描述

带走三个判断:打包版的问题都能在 userData 日志里定位(所以日志系统不是可选项);dev 与打包版共用 userData,测试前先关 dev;electron-builder 的 EPERM 重试和 signing skipped 都是正常日志,别被吓到。还有一个贯穿两篇的最终结论:前端一行没改,桌面端交付了四种格式的安装包——这就是"方案选对"给工程省下的最大成本。

最后留一张 Vue + Electron 桌面端的常用组合键速查表(dev 窗口与打包版通用,前提是未禁用默认菜单,本项目即是):

组合键作用备注
Ctrl+R刷新页面桌面端没有刷新按钮,这就是刷新(默认菜单 Reload)
Ctrl+Shift+R强制刷新(忽略缓存)排查"改了代码没生效"先用它
Ctrl+Shift+I打开/关闭 DevTools打包版排查白屏的唯一窗口
Ctrl+0 / Ctrl+= / Ctrl±缩放复位 / 放大 / 缩小高分屏下页面显示过大过小时用
F11全屏切换默认菜单 Toggle Full Screen
Ctrl+W关闭当前窗口关闭行为受 window-all-closed 逻辑控制

日常记住 Ctrl+R 和 Ctrl+Shift+I 就够:一个管刷新,一个管排查。

下次照这个顺序做:一份可复用的落地清单

这套流程跑通之后,在别的若依 Vue3 项目上照做,能一次绕开本篇踩过的全部坑。把它压成一条线,下次从第一条往下走即可。

第一步:只加三个文件,不碰 src/。

新增文件干什么关键点
electron/main.js主进程:窗口 + app:// 协议双分支 + 落盘日志 + 单实例锁加载 URL 带 /IPESAgent/;静态分支剥前缀;BACKEND_URL 写后端地址
electron/preload.cjs桥:contextBridge 白名单 APICJS、不进构建、主进程按 app.getAppPath() 引用
.npmrcElectron 二进制镜像electron_mirror=https://repo.huaweicloud.com/electron/

第二步:只改两个配置。 vite.config.js 固定 port: 5173 加 strictPort: true(Electron 按端口 loadURL,端口漂移即白屏),再用 VITE_ELECTRON 开关条件挂载 vite-plugin-electron 0.29.1;package.json 里 main 指向 dist-electron/main.js,加 dev:electron 和 desktop:build 两个脚本,外加 electron-builder 的 build 字段——files 必须同时含 dist/**、dist-electron/**、electron/** 三个入口。前端 src/ 一行不动:这不是偷懒,而是这条方案的核心收益。

第三步:生成图标。 先出 1024 主图 → 缩放出 16/24/32/48/64/128/256 七档 → 拼 ICO 容器(ICONDIR 头 6 字节 + 每条目录项 16 字节 + PNG 数据,256 尺寸的宽高字段写 0)。本项目用 build/gen-icon.ps1 一条命令产出 icon.png 与 icon.ico;ico 必须含 256 条目,electron-builder 的 win.icon 指向它。

第四步:打包,一条命令。 npm run desktop:build(内部就是 set VITE_ELECTRON=true&& npm run build:prod && electron-builder --win)→ release/ 出四份产物:dir(验证用)、portable 116.9MB(U 盘分发)、nsis 136.9MB(正式交付)、zip 174.8MB。改完 main.js 必须重建 dist-electron,只改源码不重建,打出来的还是旧包——这是本篇代价最高的一课。

第五步:让用户桌面上出现图标。 把 若依桌面端 Setup 3.9.2.exe 发给用户,双击走安装向导(perMachine: false,不需要管理员权限),装完桌面自动生成快捷方式、开始菜单有项、任务栏显示本项目图标;卸载走"设置→应用"。发出去之前必须确认两件事:main.js 的 BACKEND_URL 是用户能访问到的那个后端;未签名产物会触发 SmartScreen"未知发布者",内网提前报备加白。

最后一步:验收只看一行日志。 %APPDATA%\ruoyi\logs\main.log 里出现 生产模式:加载 app://IPESAgent/IPESAgent/ 与 API 转发: GET /captchaImage,且没有 API 转发失败,桌面端就是通的。

换到别的若依项目,真正要改的只有三处:协议里的 /IPESAgent 命名空间、BACKEND_URL、productName 与图标——其余步骤原样照搬,一次打包 307 秒。

九、更多操作

更多前端实战内容,请看 Vue 个人专栏。

本文属于 Vue 企业级实战系列,持续更新 Vue2/Vue3、工程化、性能优化、跨域解决方案等干货,欢迎关注我的 CSDN 专栏:

👉 Vue Develop 实战专栏

本专栏聚焦 Vue(Vue2/Vue3)企业级实战开发。涵盖组件封装、组合式 API、前端工程化、状态管理、性能优化、项目架构,搭配 Element、ECharts 业务实战。汇集真实项目开发经验与避坑方案,帮你搭建完整 Vue 技术体系,持续更新硬核实战内容。

本系列还可以继续深挖的方向:

  • 后端地址配置化:把 DESKTOP_BACKEND_URL 挪到 userData 配置文件 + 托盘菜单切换环境,内网/测试环境一键切换;
  • 自动更新:electron-builder 的 blockmap(打包时已生成)+ electron-updater,内网系统用私有更新源;
  • 代码签名与分发:申请代码签名证书,把 SmartScreen 与杀软误报彻底解决;
  • Tauri 迁移实验:同一套前端换 Tauri 壳,对比体积与内存的实测差异;
  • macOS / Linux 打包:跨平台 target 的配置与 CI(GitHub Actions)流水线。

延伸阅读与官方文档:

如果本文对你有帮助,欢迎点赞、收藏、评论,你的支持是我持续输出实战干货的动力!

如果你在项目中也遇到类似问题,欢迎留言交流,分享你的场景与解决方案。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_65793170/article/details/169456978

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--