From c42cd27a64cb605dbe37a0fe132c165fd1173312 Mon Sep 17 00:00:00 2001
From: zjk <852185829@qq.com>
Date: 星期五, 31 七月 2026 14:41:16 +0800
Subject: [PATCH] feat(ruoyi-ui): 新增Electron桌面版应用支持

---
 ruoyi-ui/public/index.html                      |    2 
 ruoyi-ui/vue.config.js                          |   12 +
 ruoyi-ui/src/views/login.vue                    |   11 +
 ruoyi-ui/.gitignore                             |    1 
 ruoyi-ui/src/views/register.vue                 |    8 +
 ruoyi-ui/electron/preload.js                    |   10 ++
 ruoyi-ui/.env.production                        |    3 
 ruoyi-ui/.env.staging                           |    3 
 ruoyi-ui/package.json                           |   56 ++++++++++
 ruoyi-ui/public/logo.ico                        |    0 
 ruoyi-ui/electron/main.js                       |  131 ++++++++++++++++++++++++++
 ruoyi-ui/src/layout/components/Sidebar/Logo.vue |    7 +
 ruoyi-ui/.env.desktop                           |   19 +++
 ruoyi-ui/src/utils/static-asset.js              |   20 ++++
 14 files changed, 267 insertions(+), 16 deletions(-)

diff --git a/ruoyi-ui/.env.desktop b/ruoyi-ui/.env.desktop
new file mode 100644
index 0000000..adbbd6b
--- /dev/null
+++ b/ruoyi-ui/.env.desktop
@@ -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'
diff --git a/ruoyi-ui/.env.production b/ruoyi-ui/.env.production
index 1da49c7..ea04d15 100644
--- a/ruoyi-ui/.env.production
+++ b/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'
diff --git a/ruoyi-ui/.env.staging b/ruoyi-ui/.env.staging
index ca76db5..7bb9cd5 100644
--- a/ruoyi-ui/.env.staging
+++ b/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'
diff --git a/ruoyi-ui/.gitignore b/ruoyi-ui/.gitignore
index 32d3c6c..71820a2 100644
--- a/ruoyi-ui/.gitignore
+++ b/ruoyi-ui/.gitignore
@@ -1,6 +1,7 @@
 .DS_Store
 node_modules/
 dist/
+dist-desktop/
 release/
 npm-debug.log*
 yarn-debug.log*
diff --git a/ruoyi-ui/electron/main.js b/ruoyi-ui/electron/main.js
new file mode 100644
index 0000000..d072c00
--- /dev/null
+++ b/ruoyi-ui/electron/main.js
@@ -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()
+    }
+})
diff --git a/ruoyi-ui/electron/preload.js b/ruoyi-ui/electron/preload.js
new file mode 100644
index 0000000..2992ff1
--- /dev/null
+++ b/ruoyi-ui/electron/preload.js
@@ -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
+    }
+})
diff --git a/ruoyi-ui/package.json b/ruoyi-ui/package.json
index 7336736..00f3938 100644
--- a/ruoyi-ui/package.json
+++ b/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",
diff --git a/ruoyi-ui/public/index.html b/ruoyi-ui/public/index.html
index 925455c..60f867c 100644
--- a/ruoyi-ui/public/index.html
+++ b/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>
diff --git a/ruoyi-ui/public/logo.ico b/ruoyi-ui/public/logo.ico
new file mode 100644
index 0000000..5d7c34b
--- /dev/null
+++ b/ruoyi-ui/public/logo.ico
Binary files differ
diff --git a/ruoyi-ui/src/layout/components/Sidebar/Logo.vue b/ruoyi-ui/src/layout/components/Sidebar/Logo.vue
index d11f492..264bf38 100644
--- a/ruoyi-ui/src/layout/components/Sidebar/Logo.vue
+++ b/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
diff --git a/ruoyi-ui/src/utils/static-asset.js b/ruoyi-ui/src/utils/static-asset.js
new file mode 100644
index 0000000..b550460
--- /dev/null
+++ b/ruoyi-ui/src/utils/static-asset.js
@@ -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
+}
diff --git a/ruoyi-ui/src/views/login.vue b/ruoyi-ui/src/views/login.vue
index 1d852c8..298f235 100644
--- a/ruoyi-ui/src/views/login.vue
+++ b/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 '请输入用户名'
diff --git a/ruoyi-ui/src/views/register.vue b/ruoyi-ui/src/views/register.vue
index 396d582..ef45c6e 100644
--- a/ruoyi-ui/src/views/register.vue
+++ b/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();
   },
diff --git a/ruoyi-ui/vue.config.js b/ruoyi-ui/vue.config.js
index 6c1239d..e99b55b 100644
--- a/ruoyi-ui/vue.config.js
+++ b/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

--
Gitblit v1.7.1