zjk
2026-07-31 c42cd27a64cb605dbe37a0fe132c165fd1173312
feat(ruoyi-ui): 新增Electron桌面版应用支持

- 新增桌面版专属环境配置文件.env.desktop,配置默认后端地址、应用标题等参数
- 新增Electron主进程脚本electron/main.js和预加载脚本preload.js,实现桌面窗口管理与上下文桥接
- 调整vue.config.js,区分桌面版/普通web版的打包输出目录、publicPath,新增压缩开关并默认关闭以兼容Node环境打包问题
- 更新.gitignore,新增忽略dist-desktop和release打包输出目录
- 新增静态资源工具函数getPublicAssetUrl,适配Electron的file://协议加载静态资源
- 替换项目默认favicon为logo.ico,调整登录页、注册页、侧边栏Logo的图标与标题逻辑,使用环境变量动态配置标题与默认logo
- 更新package.json,添加Electron相关依赖与开发/打包脚本,配置electron-builder打包参数
- 统一登录页输入框占位符和校验提示文案,修改为“请输入服务器地址”
5个文件已添加
9个文件已修改
283 ■■■■■ 已修改文件
ruoyi-ui/.env.desktop 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/.env.production 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/.env.staging 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/.gitignore 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/electron/main.js 131 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/electron/preload.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/package.json 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/public/index.html 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/public/logo.ico 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/layout/components/Sidebar/Logo.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/utils/static-asset.js 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/login.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/register.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/vue.config.js 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/.env.desktop
New file
@@ -0,0 +1,19 @@
# 页面标题
VUE_APP_TITLE = 访客机自助登记管理平台
NODE_ENV = production
ENV = 'desktop'
# 桌面版构建标记
VUE_APP_BUILD_TARGET = 'desktop'
# 桌面版默认走固定后端地址,不显示服务器地址输入框
VUE_APP_BASE_API = ''
VUE_APP_VISITOR_FRONTEND_MODE = 'cluster'
VUE_APP_VISITOR_DEPLOYMENT_MODE = 'cluster'
VUE_APP_VISITOR_REQUIRE_SERVER_IP = 'false'
VUE_APP_VISITOR_MIDDLE_PLATFORM_ENABLED = 'false'
VUE_APP_VISITOR_LOGIN_MIDDLE_PLATFORM_ENABLED = 'false'
VUE_APP_VISITOR_DEFAULT_SERVER_IP = '221.182.45.100'
VUE_APP_VISITOR_DEFAULT_BACKEND_PORT = '9098'
VUE_APP_VISITOR_API_BASE_URL = 'http://221.182.45.100:9098'
ruoyi-ui/.env.production
@@ -11,3 +11,6 @@
VUE_APP_VISITOR_MIDDLE_PLATFORM_ENABLED = 'false'
# VUE_APP_VISITOR_LOGIN_MIDDLE_PLATFORM_ENABLED 未配置时默认跟随 VUE_APP_VISITOR_MIDDLE_PLATFORM_ENABLED
VUE_APP_VISITOR_API_BASE_URL = '/prod-api'
# 默认关闭 gzip 压缩,避免 CompressionPlugin 在当前 Node 环境下打包失败
VUE_APP_ENABLE_COMPRESSION = 'false'
ruoyi-ui/.env.staging
@@ -13,3 +13,6 @@
VUE_APP_VISITOR_MIDDLE_PLATFORM_ENABLED = 'false'
# VUE_APP_VISITOR_LOGIN_MIDDLE_PLATFORM_ENABLED 未配置时默认跟随 VUE_APP_VISITOR_MIDDLE_PLATFORM_ENABLED
VUE_APP_VISITOR_API_BASE_URL = '/stage-api'
# 默认关闭 gzip 压缩,避免 CompressionPlugin 在当前 Node 环境下打包失败
VUE_APP_ENABLE_COMPRESSION = 'false'
ruoyi-ui/.gitignore
@@ -1,6 +1,7 @@
.DS_Store
node_modules/
dist/
dist-desktop/
release/
npm-debug.log*
yarn-debug.log*
ruoyi-ui/electron/main.js
New file
@@ -0,0 +1,131 @@
const path = require('path')
const fs = require('fs')
const { app, BrowserWindow, shell } = require('electron')
const APP_TITLE = process.env.ELECTRON_APP_TITLE || '访客机自助登记管理平台'
const rendererUrl = process.env.ELECTRON_RENDERER_URL || ''
const iconPath = path.join(__dirname, '../public/logo.ico')
const preloadPath = path.join(__dirname, 'preload.js')
const desktopIndexPath = path.join(__dirname, '../dist-desktop/index.html')
let mainWindow = null
app.name = APP_TITLE
function createFallbackPage() {
    return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8" />
    <title>${APP_TITLE}</title>
    <style>
        body {
            margin: 0;
            font-family: "Microsoft YaHei", Arial, sans-serif;
            background: #f5f7fb;
            color: #1f2937;
        }
        .container {
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 24px;
            box-sizing: border-box;
        }
        .card {
            width: 100%;
            max-width: 560px;
            background: #ffffff;
            border-radius: 12px;
            box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
            padding: 32px;
            box-sizing: border-box;
        }
        h1 {
            margin: 0 0 12px;
            font-size: 22px;
        }
        p {
            margin: 0 0 10px;
            line-height: 1.7;
        }
        code {
            display: inline-block;
            padding: 2px 6px;
            background: #f3f4f6;
            border-radius: 4px;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="card">
            <h1>${APP_TITLE}</h1>
            <p>当前没有找到桌面版前端资源。</p>
            <p>请先执行 <code>npm run build:desktop</code>,再运行 <code>npm run electron:start</code>。</p>
        </div>
    </div>
</body>
</html>
`
}
function createWindow() {
    mainWindow = new BrowserWindow({
        width: 1440,
        height: 900,
        minWidth: 1280,
        minHeight: 760,
        show: false,
        autoHideMenuBar: true,
        title: APP_TITLE,
        icon: iconPath,
        webPreferences: {
            preload: preloadPath,
            contextIsolation: true,
            nodeIntegration: false
        }
    })
    mainWindow.once('ready-to-show', () => {
        mainWindow.show()
        if (rendererUrl) {
            mainWindow.webContents.openDevTools({ mode: 'detach' })
        }
    })
    mainWindow.webContents.setWindowOpenHandler(({ url }) => {
        shell.openExternal(url)
        return { action: 'deny' }
    })
    if (rendererUrl) {
        mainWindow.loadURL(rendererUrl)
        return
    }
    if (fs.existsSync(desktopIndexPath)) {
        mainWindow.loadFile(desktopIndexPath)
        return
    }
    mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(createFallbackPage())}`)
}
app.whenReady().then(() => {
    createWindow()
    app.on('activate', () => {
        if (BrowserWindow.getAllWindows().length === 0) {
            createWindow()
        }
    })
})
app.on('window-all-closed', () => {
    if (process.platform !== 'darwin') {
        app.quit()
    }
})
ruoyi-ui/electron/preload.js
New file
@@ -0,0 +1,10 @@
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('desktopApp', {
    platform: process.platform,
    versions: {
        chrome: process.versions.chrome,
        electron: process.versions.electron,
        node: process.versions.node
    }
})
ruoyi-ui/package.json
@@ -1,13 +1,19 @@
{
  "name": "ruoyi",
  "main": "electron/main.js",
  "version": "3.8.6",
  "description": "若依管理系统",
  "author": "若依",
  "license": "MIT",
  "scripts": {
    "dev": "node --openssl-legacy-provider ./node_modules/@vue/cli-service/bin/vue-cli-service.js serve",
    "build:prod": "node --openssl-legacy-provider ./node_modules/@vue/cli-service/bin/vue-cli-service.js build",
    "build:stage": "node --openssl-legacy-provider ./node_modules/@vue/cli-service/bin/vue-cli-service.js build --mode staging",
    "dev": "node ./node_modules/@vue/cli-service/bin/vue-cli-service.js serve",
    "build:prod": "node ./node_modules/@vue/cli-service/bin/vue-cli-service.js build",
    "build:stage": "node ./node_modules/@vue/cli-service/bin/vue-cli-service.js build --mode staging",
    "build:desktop": "node ./node_modules/@vue/cli-service/bin/vue-cli-service.js build --mode desktop",
    "electron:start": "electron .",
    "electron:dev": "concurrently \"npm run dev -- --port 8080\" \"wait-on tcp:8080 && cross-env ELECTRON_RENDERER_URL=http://127.0.0.1:8080 electron .\"",
    "dist:win": "npm run build:desktop && cross-env ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win nsis",
    "dist:win:dir": "npm run build:desktop && cross-env ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win dir",
    "preview": "node build/index.js --preview",
    "lint": "eslint --ext .js,.vue src"
  },
@@ -34,6 +40,43 @@
  "repository": {
    "type": "git",
    "url": "https://gitee.com/y_project/RuoYi-Vue.git"
  },
  "build": {
    "appId": "com.fangkeji.visitor.desktop",
    "productName": "访客机自助登记管理平台",
    "artifactName": "${productName}-Setup-${version}.${ext}",
    "directories": {
      "output": "release",
      "buildResources": "public"
    },
    "files": [
      "dist-desktop/**/*",
      "electron/**/*",
      "package.json"
    ],
    "asar": true,
    "npmRebuild": false,
    "electronDownload": {
      "mirror": "https://npmmirror.com/mirrors/electron/"
    },
    "win": {
      "target": [
        {
          "target": "nsis",
          "arch": [
            "x64"
          ]
        }
      ],
      "icon": "public/logo.ico"
    },
    "nsis": {
      "oneClick": false,
      "allowToChangeInstallationDirectory": true,
      "createDesktopShortcut": true,
      "createStartMenuShortcut": true,
      "shortcutName": "访客机自助登记管理平台"
    }
  },
  "dependencies": {
    "@riophae/vue-treeselect": "0.4.0",
@@ -69,7 +112,11 @@
    "babel-plugin-dynamic-import-node": "2.3.3",
    "chalk": "4.1.0",
    "compression-webpack-plugin": "5.0.2",
    "concurrently": "^8.2.2",
    "connect": "3.6.6",
    "cross-env": "^7.0.3",
    "electron": "^28.3.3",
    "electron-builder": "^24.13.3",
    "eslint": "7.15.0",
    "eslint-plugin-vue": "7.2.0",
    "lint-staged": "10.5.3",
@@ -78,7 +125,8 @@
    "sass-loader": "10.1.1",
    "script-ext-html-webpack-plugin": "2.1.5",
    "svg-sprite-loader": "5.1.1",
    "vue-template-compiler": "2.6.12"
    "vue-template-compiler": "2.6.12",
    "wait-on": "^7.2.0"
  },
  "engines": {
    "node": ">=8.9",
ruoyi-ui/public/index.html
@@ -5,7 +5,7 @@
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="renderer" content="webkit">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <link rel="icon" href="<%= BASE_URL %>logo.ico">
    <title><%= webpackConfig.name %></title>
    <!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]-->
      <style>
ruoyi-ui/public/logo.ico
ruoyi-ui/src/layout/components/Sidebar/Logo.vue
@@ -14,10 +14,12 @@
</template>
<script>
import logoImg from '@/assets/logo/logo.png'
import variables from '@/assets/styles/variables.scss'
import { getVisitorBrand, getVisitorBrandChangeEvent } from '@/utils/visitor-brand'
import { getVisitorApiBaseUrl } from '@/utils/request'
import { getPublicAssetUrl } from '@/utils/static-asset'
const defaultLogoUrl = getPublicAssetUrl('logo.ico')
export default {
  name: 'SidebarLogo',
@@ -62,7 +64,8 @@
    },
    resolveLogoUrl(logoUrl) {
      if (!logoUrl) {
        return logoImg
        // 默认回退到 public 目录下的统一品牌图标
        return defaultLogoUrl
      }
      if (/^(https?:)?\/\//i.test(logoUrl) || /^(data|blob):/i.test(logoUrl)) {
        return logoUrl
ruoyi-ui/src/utils/static-asset.js
New file
@@ -0,0 +1,20 @@
export function getPublicAssetUrl(fileName) {
  const value = fileName || ''
  if (!value) {
    return ''
  }
  if (typeof window === 'undefined' || typeof document === 'undefined') {
    return (process.env.BASE_URL || '/') + value
  }
  const scripts = Array.from(document.getElementsByTagName('script'))
  const assetScript = scripts.find(script => script.src)
  if (assetScript && assetScript.src) {
    // 从已加载的脚本地址反推应用根路径,兼容 Electron 打包后的 file:// 场景
    return assetScript.src.replace(/\/static\/js\/[^/]+$/, '/') + value
  }
  return (process.env.BASE_URL || '/') + value
}
ruoyi-ui/src/views/login.vue
@@ -16,7 +16,7 @@
              <span class="field-icon" aria-hidden="true">
                <svg viewBox="0 0 24 24"><path d="M4 5h16v10H4z" /><path d="M8 19h8" /><path d="M12 15v4" /></svg>
              </span>
              <input id="serverIp" v-model.trim="loginForm.serverIp" autocomplete="off" placeholder="请输入IP地址" @blur="refreshServerCode">
              <input id="serverIp" v-model.trim="loginForm.serverIp" autocomplete="off" placeholder="请输入服务器地址" @blur="refreshServerCode">
            </label>
            <label class="input-row" for="username">
@@ -63,14 +63,17 @@
import { decrypt } from '@/utils/jsencrypt'
import visitorAdminConfig, { isServerIpRequired, normalizeApiBaseUrl } from '@/config/visitor-admin'
import { setVisitorApiBaseUrl } from '@/utils/request'
import loginAvatar from '@/assets/visitor-admin/login-avatar.svg'
import { getPublicAssetUrl } from '@/utils/static-asset'
import loginDevices from '@/assets/visitor-admin/login-devices.svg'
const defaultLogoUrl = getPublicAssetUrl('logo.ico')
export default {
  name: 'Login',
  data() {
    return {
      avatarUrl: loginAvatar,
      // 登录页品牌头像统一使用 public 目录中的桌面应用 logo
      avatarUrl: defaultLogoUrl,
      devicesUrl: loginDevices,
      visitorTitle: this.normalizeTitle(visitorAdminConfig.title),
      codeUrl: '',
@@ -164,7 +167,7 @@
    },
    validateForm() {
      if (this.serverIpRequired && !this.loginForm.serverIp) {
        return '请输入IP地址'
          return '请输入服务器地址'
      }
      if (!this.loginForm.username) {
        return '请输入用户名'
ruoyi-ui/src/views/register.vue
@@ -1,7 +1,7 @@
<template>
  <div class="register">
    <el-form ref="registerForm" :model="registerForm" :rules="registerRules" class="register-form">
      <h3 class="title">若依后台管理系统</h3>
      <h3 class="title">{{ appTitle }}</h3>
      <el-form-item prop="username">
        <el-input v-model="registerForm.username" type="text" auto-complete="off" placeholder="账号">
          <svg-icon slot="prefix" icon-class="user" class="el-input__icon input-icon" />
@@ -107,6 +107,12 @@
      captchaEnabled: true
    };
  },
  computed: {
    appTitle() {
      // 注册页标题跟随环境变量,后续修改应用名称时只改一处配置
      return process.env.VUE_APP_TITLE || '访客机自助登记管理平台'
    }
  },
  created() {
    this.getCode();
  },
ruoyi-ui/vue.config.js
@@ -8,6 +8,8 @@
const CompressionPlugin = require('compression-webpack-plugin')
const name = process.env.VUE_APP_TITLE || '若依管理系统' // 网页标题
const isDesktopBuild = process.env.VUE_APP_BUILD_TARGET === 'desktop'
const enableCompression = process.env.VUE_APP_ENABLE_COMPRESSION === 'true' && !isDesktopBuild
const port = process.env.port || process.env.npm_config_port || 80 // 端口
@@ -18,9 +20,10 @@
  // 部署生产环境和开发环境下的URL。
  // 默认情况下,Vue CLI 会假设你的应用是被部署在一个域名的根路径上
  // 例如 https://www.ruoyi.vip/。如果应用被部署在一个子路径上,你就需要用这个选项指定这个子路径。例如,如果你的应用被部署在 https://www.ruoyi.vip/admin/,则设置 baseUrl 为 /admin/。
  publicPath: process.env.NODE_ENV === "production" ? "/" : "/",
  // 桌面版通过 file:// 加载静态资源,publicPath 需要使用相对路径
  publicPath: isDesktopBuild ? './' : '/',
  // 在npm run build 或 yarn build 时 ,生成文件的目录名称(要和baseUrl的生产环境路径一致)(默认dist)
  outputDir: 'dist',
  outputDir: isDesktopBuild ? 'dist-desktop' : 'dist',
  // 用于放置生成的静态资源 (js、css、img、fonts) 的;(项目打包之后,静态资源会放在这个文件夹下)
  assetsDir: 'static',
  // 是否开启eslint保存检测,有效值:ture | false | 'error'
@@ -62,7 +65,8 @@
        '@': resolve('src')
      }
    },
    plugins: [
    plugins: enableCompression ? [
      // 默认关闭 gzip 压缩,避免旧版 webpack 在新 Node/OpenSSL 环境下打包报错
      // http://doc.ruoyi.vip/ruoyi-vue/other/faq.html#使用gzip解压缩静态文件
      new CompressionPlugin({
        cache: false,                   // 不启用文件缓存
@@ -71,7 +75,7 @@
        algorithm: 'gzip',              // 使用gzip压缩
        minRatio: 0.8                   // 压缩率小于1才会压缩
      })
    ],
    ] : [],
  },
  chainWebpack(config) {
    config.plugins.delete('preload') // TODO: need test