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打包参数
- 统一登录页输入框占位符和校验提示文案,修改为“请输入服务器地址”
| New file |
| | |
| | | # 页面标题 |
| | | 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' |
| | |
| | | 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' |
| | |
| | | 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' |
| | |
| | | .DS_Store |
| | | node_modules/ |
| | | dist/ |
| | | dist-desktop/ |
| | | release/ |
| | | npm-debug.log* |
| | | yarn-debug.log* |
| New file |
| | |
| | | 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() |
| | | } |
| | | }) |
| New file |
| | |
| | | const { contextBridge } = require('electron') |
| | | |
| | | contextBridge.exposeInMainWorld('desktopApp', { |
| | | platform: process.platform, |
| | | versions: { |
| | | chrome: process.versions.chrome, |
| | | electron: process.versions.electron, |
| | | node: process.versions.node |
| | | } |
| | | }) |
| | |
| | | { |
| | | "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" |
| | | }, |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | <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> |
| | |
| | | </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', |
| | |
| | | }, |
| | | resolveLogoUrl(logoUrl) { |
| | | if (!logoUrl) { |
| | | return logoImg |
| | | // 默认回退到 public 目录下的统一品牌图标 |
| | | return defaultLogoUrl |
| | | } |
| | | if (/^(https?:)?\/\//i.test(logoUrl) || /^(data|blob):/i.test(logoUrl)) { |
| | | return logoUrl |
| New file |
| | |
| | | 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 |
| | | } |
| | |
| | | <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"> |
| | |
| | | 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: '', |
| | |
| | | }, |
| | | validateForm() { |
| | | if (this.serverIpRequired && !this.loginForm.serverIp) { |
| | | return '请输入IP地址' |
| | | return '请输入服务器地址' |
| | | } |
| | | if (!this.loginForm.username) { |
| | | return '请输入用户名' |
| | |
| | | <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" /> |
| | |
| | | captchaEnabled: true |
| | | }; |
| | | }, |
| | | computed: { |
| | | appTitle() { |
| | | // 注册页标题跟随环境变量,后续修改应用名称时只改一处配置 |
| | | return process.env.VUE_APP_TITLE || '访客机自助登记管理平台' |
| | | } |
| | | }, |
| | | created() { |
| | | this.getCode(); |
| | | }, |
| | |
| | | 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 // 端口 |
| | | |
| | |
| | | // 部署生产环境和开发环境下的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' |
| | |
| | | '@': resolve('src') |
| | | } |
| | | }, |
| | | plugins: [ |
| | | plugins: enableCompression ? [ |
| | | // 默认关闭 gzip 压缩,避免旧版 webpack 在新 Node/OpenSSL 环境下打包报错 |
| | | // http://doc.ruoyi.vip/ruoyi-vue/other/faq.html#使用gzip解压缩静态文件 |
| | | new CompressionPlugin({ |
| | | cache: false, // 不启用文件缓存 |
| | |
| | | algorithm: 'gzip', // 使用gzip压缩 |
| | | minRatio: 0.8 // 压缩率小于1才会压缩 |
| | | }) |
| | | ], |
| | | ] : [], |
| | | }, |
| | | chainWebpack(config) { |
| | | config.plugins.delete('preload') // TODO: need test |