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