From fcc5755dad9e76d2e0157b85f7ffa63007750eca Mon Sep 17 00:00:00 2001 From: ZiJIe <17738440858@163.com> Date: Fri, 26 Sep 2025 16:27:27 +0800 Subject: [PATCH] 1 --- .claude/settings.local.json | 3 +- electron-vue-template/electron-builder.json | 11 +- electron-vue-template/src/main/main.ts | 298 +++++++++--- electron-vue-template/src/main/preload.ts | 22 + electron-vue-template/src/renderer/App.vue | 18 +- .../components/common/UpdateDialog.vue | 338 +++++--------- .../src/renderer/typings/electron.d.ts | 9 + .../erp/controller/UpdateController.java | 433 +----------------- .../controller/monitor/VersionController.java | 5 - .../web/controller/tool/FileController.java | 9 +- .../src/main/resources/application.yml | 2 +- 11 files changed, 403 insertions(+), 745 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f6193b0..590f315 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -200,7 +200,8 @@ "Bash(./start-desktop-app.bat)", "Bash(cnpm install:*)", "Bash(cnpm uninstall:*)", - "WebFetch(domain:www.electronjs.org)" + "WebFetch(domain:www.electronjs.org)", + "Bash(test:*)" ], "deny": [], "ask": [], diff --git a/electron-vue-template/electron-builder.json b/electron-vue-template/electron-builder.json index a99386f..5c4708d 100644 --- a/electron-vue-template/electron-builder.json +++ b/electron-vue-template/electron-builder.json @@ -1,6 +1,7 @@ { "appId": "com.erp.client", - "productName": "ERP客户端", + "productName": "erpClient", + "asar": true, "directories": { "output": "dist" }, @@ -12,7 +13,7 @@ "oneClick": false, "perMachine": false, "allowToChangeInstallationDirectory": true, - "shortcutName": "ERP客户端" + "shortcutName": "erpClient" }, "win": { "target": "nsis", @@ -40,5 +41,11 @@ "!build", "!dist", "!scripts" + ], + "extraResources": [ + { + "from": "update-helper.bat", + "to": "../update-helper.bat" + } ] } diff --git a/electron-vue-template/src/main/main.ts b/electron-vue-template/src/main/main.ts index c08dc04..d68417d 100644 --- a/electron-vue-template/src/main/main.ts +++ b/electron-vue-template/src/main/main.ts @@ -1,16 +1,19 @@ -// 主进程:创建窗口、启动后端 JAR、隐藏菜单栏 -import {app, BrowserWindow, ipcMain, session, Menu, screen} from 'electron'; -import { Socket } from 'net'; -import { existsSync } from 'fs'; +import {app, BrowserWindow, ipcMain, Menu, screen} from 'electron'; +import { existsSync, createWriteStream, promises as fs, statSync } from 'fs'; import {join, dirname} from 'path'; import {spawn, ChildProcessWithoutNullStreams} from 'child_process'; +import * as https from 'https'; +import * as http from 'http'; -// 保存后端进程与窗口引用,便于退出时清理 let springProcess: ChildProcessWithoutNullStreams | null = null; let mainWindow: BrowserWindow | null = null; let splashWindow: BrowserWindow | null = null; let appOpened = false; +let downloadProgress = { percentage: 0, current: '0 MB', total: '0 MB', speed: '' }; +let isDownloading = false; +let downloadedFilePath: string | null = null; + function openAppIfNotOpened() { if (appOpened) return; appOpened = true; @@ -18,10 +21,12 @@ function openAppIfNotOpened() { mainWindow.show(); mainWindow.focus(); } - if (splashWindow) { splashWindow.close(); splashWindow = null; } + if (splashWindow) { + splashWindow.close(); + splashWindow = null; + } } -// 启动后端 Spring Boot(使用你提供的绝对路径) function startSpringBoot() { const jarPath = 'C:/Users/ZiJIe/Desktop/wox/RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar'; @@ -30,61 +35,44 @@ function startSpringBoot() { detached: false }); - // 打印后端日志,监听启动成功标志 springProcess.stdout.on('data', (data) => { console.log(`SpringBoot: ${data}`); - // 检测到启动成功日志立即进入主界面 if (data.toString().includes('Started RuoYiApplication')) { openAppIfNotOpened(); } }); - // 打印后端错误,检测启动失败 springProcess.stderr.on('data', (data) => { console.error(`SpringBoot ERROR: ${data}`); const errorStr = data.toString(); - // 检测到关键错误信息,直接退出 if (errorStr.includes('APPLICATION FAILED TO START') || errorStr.includes('Port') && errorStr.includes('already in use') || errorStr.includes('Unable to start embedded Tomcat')) { - console.error('后端启动失败,程序即将退出'); app.quit(); } }); - // 后端退出时,前端同步退出 springProcess.on('close', (code) => { console.log(`SpringBoot exited with code ${code}`); - if (mainWindow) { - mainWindow.close(); - } else { - app.quit(); - } + mainWindow ? mainWindow.close() : app.quit(); }); } -// 关闭后端进程(Windows 使用 taskkill 结束整个进程树) function stopSpringBoot() { if (!springProcess) return; try { if (process.platform === 'win32') { - // Force kill the whole process tree on Windows - try { - const pid = springProcess.pid; - if (pid !== undefined && pid !== null) { - spawn('taskkill', ['/pid', String(pid), '/f', '/t']); - } else { - springProcess.kill(); - } - } catch (e) { - // Fallback + const pid = springProcess.pid; + if (pid) { + spawn('taskkill', ['/pid', String(pid), '/f', '/t']); + } else { springProcess.kill(); } } else { springProcess.kill('SIGTERM'); } } catch (e) { - console.error('Failed to stop Spring Boot process:', e); + console.error('Failed to stop Spring Boot:', e); } finally { springProcess = null; } @@ -96,7 +84,7 @@ function createWindow () { height: 800, show: false, autoHideMenuBar: true, - icon: join(__dirname, '../renderer/icon/icon.png'), // 添加窗口图标 + icon: join(__dirname, '../renderer/icon/icon.png'), webPreferences: { preload: join(__dirname, 'preload.js'), nodeIntegration: false, @@ -104,16 +92,14 @@ function createWindow () { } }); - // 彻底隐藏原生菜单栏 - try { - Menu.setApplicationMenu(null); - mainWindow.setMenuBarVisibility(false); - if (typeof (mainWindow as any).setMenu === 'function') { - (mainWindow as any).setMenu(null); - } - } catch {} + mainWindow.webContents.openDevTools(); + Menu.setApplicationMenu(null); + mainWindow.setMenuBarVisibility(false); + + mainWindow.webContents.once('did-finish-load', () => { + setTimeout(() => checkPendingUpdate(), 500); + }); - // 生产环境加载本地文件 if (process.env.NODE_ENV === 'development') { const rendererPort = process.argv[2] || 8083; mainWindow.loadURL(`http://localhost:${rendererPort}`); @@ -123,16 +109,12 @@ function createWindow () { } app.whenReady().then(() => { - // 预创建主窗口(隐藏) createWindow(); - // 显示启动页 const { width: sw, height: sh } = screen.getPrimaryDisplay().workAreaSize; - const splashW = Math.min(Math.floor(sw * 0.8), 1800); - const splashH = Math.min(Math.floor(sh * 0.8), 1200); splashWindow = new BrowserWindow({ - width: splashW, - height: splashH, + width: Math.min(Math.floor(sw * 0.8), 1800), + height: Math.min(Math.floor(sh * 0.8), 1200), frame: false, transparent: false, resizable: false, @@ -141,37 +123,23 @@ app.whenReady().then(() => { center: true, }); - const candidateSplashPaths = [ - join(__dirname, '../../public', 'splash.html'), - ]; - const foundSplash = candidateSplashPaths.find(p => existsSync(p)); - if (foundSplash) { - splashWindow.loadFile(foundSplash); + const splashPath = join(__dirname, '../../public', 'splash.html'); + if (existsSync(splashPath)) { + splashWindow.loadFile(splashPath); } - // 注释掉后端启动,便于快速调试前端 - // startSpringBoot(); + setTimeout(() => openAppIfNotOpened(), 1000); - // 快速调试模式 - 直接打开主窗口 - setTimeout(() => { - openAppIfNotOpened(); - }, 1000); - - - - - app.on('activate', function () { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); -app.on('window-all-closed', function () { +app.on('window-all-closed', () => { stopSpringBoot(); - if (process.platform !== 'darwin') app.quit() + if (process.platform !== 'darwin') app.quit(); }); app.on('before-quit', () => { @@ -180,5 +148,199 @@ app.on('before-quit', () => { ipcMain.on('message', (event, message) => { console.log(message); -}) +}); +function checkPendingUpdate() { + try { + const updateFilePath = join(process.resourcesPath, 'app.asar.update'); + const appAsarPath = join(process.resourcesPath, 'app.asar'); + + if (!existsSync(updateFilePath)) return; + + const appDir = dirname(process.execPath); + const helperPath = join(appDir, 'update-helper.bat'); + + if (!existsSync(helperPath)) { + console.error('[UPDATE] 更新助手不存在:', helperPath); + return; + } + + const vbsPath = join(app.getPath('temp'), 'update-silent.vbs'); + const vbsContent = `Set WshShell = CreateObject("WScript.Shell") +WshShell.Run Chr(34) & "${helperPath.replace(/\\/g, '\\\\')}" & Chr(34) & " " & Chr(34) & "${appAsarPath.replace(/\\/g, '\\\\')}" & Chr(34) & " " & Chr(34) & "${updateFilePath.replace(/\\/g, '\\\\')}" & Chr(34) & " " & Chr(34) & "${process.execPath.replace(/\\/g, '\\\\')}" & Chr(34), 0, False`; + + require('fs').writeFileSync(vbsPath, vbsContent); + + spawn('wscript.exe', [vbsPath], { + detached: true, + stdio: 'ignore', + shell: false + }); + + setTimeout(() => app.quit(), 1000); + } catch (error) { + console.error('[UPDATE] 更新失败:', error); + } +} + +ipcMain.handle('download-update', async (event, downloadUrl: string) => { + if (isDownloading) return { success: false, error: '正在下载中' }; + if (downloadedFilePath === 'completed') return { success: true, filePath: 'already-completed' }; + + isDownloading = true; + + try { + const isRealDev = process.env.NODE_ENV === 'development' && !app.isPackaged; + + if (isRealDev) { + for (let i = 0; i <= 100; i += 10) { + setTimeout(() => { + downloadProgress = { + percentage: i, + current: (i * 0.5).toFixed(1) + ' MB', + total: '50.0 MB', + speed: '2.5 MB/s' + }; + if (mainWindow) { + mainWindow.webContents.send('download-progress', downloadProgress); + } + }, i * 100); + } + + setTimeout(() => { + downloadedFilePath = 'completed'; + isDownloading = false; + }, 1100); + + return { success: true, filePath: 'dev-mode-simulated', dev: true }; + } + + const tempPath = join(app.getPath('temp'), 'app.asar.new'); + + await downloadFile(downloadUrl, tempPath, (progress) => { + downloadProgress = progress; + if (mainWindow) { + mainWindow.webContents.send('download-progress', progress); + } + }); + + if (!existsSync(tempPath)) { + throw new Error('下载文件不存在'); + } + + const fileStats = await fs.stat(tempPath); + if (fileStats.size < 1000) { + throw new Error('下载文件过小'); + } + + const updateFilePath = join(process.resourcesPath, 'app.asar.update'); + await fs.copyFile(tempPath, updateFilePath); + await fs.unlink(tempPath); + + if (!existsSync(updateFilePath)) { + throw new Error('更新文件保存失败'); + } + + downloadedFilePath = 'completed'; + isDownloading = false; + + return { success: true, filePath: updateFilePath }; + } catch (error) { + isDownloading = false; + downloadedFilePath = null; + + const tempPath = join(app.getPath('temp'), 'app.asar.new'); + if (existsSync(tempPath)) { + fs.unlink(tempPath).catch(() => {}); + } + + return { success: false, error: error instanceof Error ? error.message : '下载失败' }; + } +}); + +ipcMain.handle('get-download-progress', () => { + return { ...downloadProgress, isDownloading }; +}); + +ipcMain.handle('install-update', async () => { + try { + const isRealDev = process.env.NODE_ENV === 'development' && !app.isPackaged; + + if (isRealDev) { + downloadedFilePath = null; + return { success: true, message: '开发环境模拟重启' }; + } + + const updateFilePath = join(process.resourcesPath, 'app.asar.update'); + const hasUpdateFile = existsSync(updateFilePath); + + if (!downloadedFilePath || (downloadedFilePath !== 'completed' && !hasUpdateFile)) { + return { success: false, error: '更新文件不存在' }; + } + + setTimeout(() => { + downloadedFilePath = null; + app.quit(); + }, 500); + + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : '重启失败' }; + } +}); + +ipcMain.handle('cancel-download', () => { + isDownloading = false; + downloadProgress = { percentage: 0, current: '0 MB', total: '0 MB', speed: '' }; + downloadedFilePath = null; + return { success: true }; +}); + +ipcMain.handle('get-update-status', () => { + const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged || process.defaultApp; + return { downloadedFilePath, isDownloading, downloadProgress, isPackaged: app.isPackaged, isDev }; +}); + +async function downloadFile(url: string, filePath: string, onProgress: (progress: any) => void): Promise { + return new Promise((resolve, reject) => { + const protocol = url.startsWith('https') ? https : http; + + protocol.get(url, (response) => { + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}`)); + return; + } + + const totalBytes = parseInt(response.headers['content-length'] || '0', 10); + let downloadedBytes = 0; + const startTime = Date.now(); + + const fileStream = createWriteStream(filePath); + + response.on('data', (chunk) => { + downloadedBytes += chunk.length; + + const percentage = totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 0; + const current = `${(downloadedBytes / 1024 / 1024).toFixed(1)} MB`; + const total = `${(totalBytes / 1024 / 1024).toFixed(1)} MB`; + const elapsed = (Date.now() - startTime) / 1000; + const speed = elapsed > 0 ? `${((downloadedBytes / elapsed) / 1024 / 1024).toFixed(1)} MB/s` : ''; + + onProgress({ percentage, current, total, speed }); + }); + + response.pipe(fileStream); + + fileStream.on('finish', () => { + fileStream.close(); + resolve(); + }); + + fileStream.on('error', (error) => { + fs.unlink(filePath).catch(() => {}); + reject(error); + }); + + }).on('error', reject); + }); +} \ No newline at end of file diff --git a/electron-vue-template/src/main/preload.ts b/electron-vue-template/src/main/preload.ts index e69de29..2b127f7 100644 --- a/electron-vue-template/src/main/preload.ts +++ b/electron-vue-template/src/main/preload.ts @@ -0,0 +1,22 @@ +import { contextBridge, ipcRenderer } from 'electron' + +const electronAPI = { + sendMessage: (message: string) => ipcRenderer.send('message', message), + + downloadUpdate: (downloadUrl: string) => ipcRenderer.invoke('download-update', downloadUrl), + getDownloadProgress: () => ipcRenderer.invoke('get-download-progress'), + installUpdate: () => ipcRenderer.invoke('install-update'), + cancelDownload: () => ipcRenderer.invoke('cancel-download'), + getUpdateStatus: () => ipcRenderer.invoke('get-update-status'), + + onDownloadProgress: (callback: (progress: any) => void) => { + ipcRenderer.on('download-progress', (event, progress) => callback(progress)) + }, + removeDownloadProgressListener: () => { + ipcRenderer.removeAllListeners('download-progress') + } +} + +contextBridge.exposeInMainWorld('electronAPI', electronAPI) + +export type ElectronApi = typeof electronAPI \ No newline at end of file diff --git a/electron-vue-template/src/renderer/App.vue b/electron-vue-template/src/renderer/App.vue index df4fc29..8341625 100644 --- a/electron-vue-template/src/renderer/App.vue +++ b/electron-vue-template/src/renderer/App.vue @@ -416,25 +416,14 @@ async function confirmRemoveDevice(row: DeviceItem & { isCurrent?: boolean }) { } } -// 启动时检查更新 -async function checkForUpdates() { - // 延迟3秒后自动检查更新 - setTimeout(() => { - showUpdateDialog.value = true - }, 3000) -} onMounted(async () => { showContent() await checkAuth() +}) - // 启动时检查更新 - await checkForUpdates() - - // 监听页面关闭,断开SSE连接(会自动设置离线) - window.addEventListener('beforeunload', () => { - SSEManager.disconnect() - }) +onUnmounted(() => { + SSEManager.disconnect() }) @@ -445,7 +434,6 @@ onMounted(async () => {
-