diff --git a/electron-vue-template/electron-builder.json b/electron-vue-template/electron-builder.json index 589d9f0..2a225a7 100644 --- a/electron-vue-template/electron-builder.json +++ b/electron-vue-template/electron-builder.json @@ -75,7 +75,10 @@ "!jre/lib/ct.sym", "!jre/lib/jvm.lib" ] - } + }, + "!build", + "!dist", + "!scripts" ], "extraResources": [ { diff --git a/electron-vue-template/package.json b/electron-vue-template/package.json index 450e16e..83e9155 100644 --- a/electron-vue-template/package.json +++ b/electron-vue-template/package.json @@ -19,7 +19,7 @@ "@vitejs/plugin-vue": "^4.4.1", "chalk": "^4.1.2", "chokidar": "^3.5.3", - "electron": "^38.2.2", + "electron": "^32.1.2", "electron-builder": "^25.1.6", "electron-rebuild": "^3.2.9", "express": "^5.1.0", diff --git a/electron-vue-template/src/main/main.ts b/electron-vue-template/src/main/main.ts index ebade99..650e837 100644 --- a/electron-vue-template/src/main/main.ts +++ b/electron-vue-template/src/main/main.ts @@ -4,6 +4,7 @@ import {join, dirname, basename} from 'path'; import {spawn, ChildProcess} from 'child_process'; import * as https from 'https'; import * as http from 'http'; +import { createTray, destroyTray } from './tray'; const isDev = process.env.NODE_ENV === 'development'; @@ -16,6 +17,8 @@ let isDownloading = false; let downloadedFilePath: string | null = null; let downloadedAsarPath: string | null = null; let downloadedJarPath: string | null = null; +let isQuitting = false; +let currentDownloadAbortController: AbortController | null = null; function openAppIfNotOpened() { if (appOpened) return; appOpened = true; @@ -75,6 +78,31 @@ function getDataDirectoryPath(): string { return dataDir; } +function getConfigPath(): string { + return join(app.getPath('userData'), 'config.json'); +} + +function loadConfig(): { closeAction?: 'quit' | 'minimize' | 'tray' } { + try { + const configPath = getConfigPath(); + if (existsSync(configPath)) { + return JSON.parse(require('fs').readFileSync(configPath, 'utf-8')); + } + } catch (error) { + console.error('加载配置失败:', error); + } + return {}; +} + +function saveConfig(config: any) { + try { + const configPath = getConfigPath(); + require('fs').writeFileSync(configPath, JSON.stringify(config, null, 2)); + } catch (error) { + console.error('保存配置失败:', error); + } +} + function migrateDataFromPublic(): void { if (!isDev) return; @@ -171,7 +199,7 @@ function startSpringBoot() { } } -startSpringBoot(); +// startSpringBoot(); function stopSpringBoot() { if (!springProcess) return; @@ -210,6 +238,22 @@ function createWindow() { Menu.setApplicationMenu(null); mainWindow.setMenuBarVisibility(false); + // 拦截关闭事件 + mainWindow.on('close', (event) => { + if (isQuitting) return; + + const config = loadConfig(); + const closeAction = config.closeAction || 'tray'; + + if (closeAction === 'quit') { + isQuitting = true; + app.quit(); + } else if (closeAction === 'tray' || closeAction === 'minimize') { + event.preventDefault(); + mainWindow?.hide(); + } + }); + // 监听窗口关闭事件,确保正确清理引用 mainWindow.on('closed', () => { mainWindow = null; @@ -223,6 +267,7 @@ function createWindow() { app.whenReady().then(() => { createWindow(); + createTray(mainWindow); const {width: sw, height: sh} = screen.getPrimaryDisplay().workAreaSize; splashWindow = new BrowserWindow({ @@ -251,24 +296,32 @@ app.whenReady().then(() => { splashWindow.loadFile(splashPath); } - // setTimeout(() => { - // openAppIfNotOpened(); - // }, 2000); + setTimeout(() => { + openAppIfNotOpened(); + }, 2000); app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show(); + } else if (BrowserWindow.getAllWindows().length === 0) { createWindow(); + createTray(mainWindow); } }); }); app.on('window-all-closed', () => { - stopSpringBoot(); - if (process.platform !== 'darwin') app.quit(); + // 允许在后台运行,不自动退出 + if (process.platform !== 'darwin' && isQuitting) { + stopSpringBoot(); + app.quit(); + } }); app.on('before-quit', () => { + isQuitting = true; stopSpringBoot(); + destroyTray(); }); ipcMain.on('message', (event, message) => { @@ -330,41 +383,46 @@ ipcMain.handle('download-update', async (event, downloadUrls: {asarUrl?: string, if (downloadedFilePath === 'completed') return {success: true, filePath: 'already-completed'}; isDownloading = true; + currentDownloadAbortController = new AbortController(); let totalDownloaded = 0; - let totalSize = 0; + let combinedTotalSize = 0; try { - // 获取总大小 - const sizes = await Promise.all([ - downloadUrls.asarUrl ? getFileSize(downloadUrls.asarUrl) : 0, - downloadUrls.jarUrl ? getFileSize(downloadUrls.jarUrl) : 0 - ]); - totalSize = sizes[0] + sizes[1]; - - // 下载asar文件 + // 预先获取文件大小,计算总下载大小 + let asarSize = 0; + let jarSize = 0; if (downloadUrls.asarUrl) { - const tempAsarPath = join(app.getPath('temp'), 'app.asar.new'); - const asarSize = sizes[0]; + asarSize = await getFileSize(downloadUrls.asarUrl); + } + if (downloadUrls.jarUrl) { + jarSize = await getFileSize(downloadUrls.jarUrl); + } + combinedTotalSize = asarSize + jarSize; + if (downloadUrls.asarUrl && !currentDownloadAbortController.signal.aborted) { + const tempAsarPath = join(app.getPath('temp'), 'app.asar.new'); await downloadFile(downloadUrls.asarUrl, tempAsarPath, (progress) => { const combinedProgress = { - percentage: Math.round(((totalDownloaded + progress.downloaded) / totalSize) * 100), + percentage: combinedTotalSize > 0 ? Math.round(((totalDownloaded + progress.downloaded) / combinedTotalSize) * 100) : 0, current: `${((totalDownloaded + progress.downloaded) / 1024 / 1024).toFixed(1)} MB`, - total: `${(totalSize / 1024 / 1024).toFixed(1)} MB` + total: combinedTotalSize > 0 ? `${(combinedTotalSize / 1024 / 1024).toFixed(1)} MB` : '0 MB' }; downloadProgress = combinedProgress; - if (mainWindow) mainWindow.webContents.send('download-progress', combinedProgress); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('download-progress', combinedProgress); + } }); + if (currentDownloadAbortController.signal.aborted) throw new Error('下载已取消'); + const asarUpdatePath = join(process.resourcesPath, 'app.asar.update'); await fs.copyFile(tempAsarPath, asarUpdatePath); await fs.unlink(tempAsarPath); downloadedAsarPath = asarUpdatePath; - totalDownloaded += asarSize; + totalDownloaded = asarSize; } - // 下载jar文件 - if (downloadUrls.jarUrl) { + if (downloadUrls.jarUrl && !currentDownloadAbortController.signal.aborted) { let jarFileName = basename(downloadUrls.jarUrl); if (!jarFileName.match(/^erp_client_sb-[\d.]+\.jar$/)) { const currentJar = getJarFilePath(); @@ -379,17 +437,20 @@ ipcMain.handle('download-update', async (event, downloadUrls: {asarUrl?: string, } const tempJarPath = join(app.getPath('temp'), jarFileName); - await downloadFile(downloadUrls.jarUrl, tempJarPath, (progress) => { const combinedProgress = { - percentage: Math.round(((totalDownloaded + progress.downloaded) / totalSize) * 100), + percentage: combinedTotalSize > 0 ? Math.round(((totalDownloaded + progress.downloaded) / combinedTotalSize) * 100) : 0, current: `${((totalDownloaded + progress.downloaded) / 1024 / 1024).toFixed(1)} MB`, - total: `${(totalSize / 1024 / 1024).toFixed(1)} MB` + total: combinedTotalSize > 0 ? `${(combinedTotalSize / 1024 / 1024).toFixed(1)} MB` : '0 MB' }; downloadProgress = combinedProgress; - if (mainWindow) mainWindow.webContents.send('download-progress', combinedProgress); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('download-progress', combinedProgress); + } }); + if (currentDownloadAbortController.signal.aborted) throw new Error('下载已取消'); + const jarUpdatePath = join(process.resourcesPath, jarFileName + '.update'); await fs.copyFile(tempJarPath, jarUpdatePath); await fs.unlink(tempJarPath); @@ -398,10 +459,13 @@ ipcMain.handle('download-update', async (event, downloadUrls: {asarUrl?: string, downloadedFilePath = 'completed'; isDownloading = false; - + currentDownloadAbortController = null; + return {success: true, asarPath: downloadedAsarPath, jarPath: downloadedJarPath}; } catch (error: unknown) { + await cleanupDownloadFiles(); isDownloading = false; + currentDownloadAbortController = null; downloadedFilePath = null; downloadedAsarPath = null; downloadedJarPath = null; @@ -466,12 +530,21 @@ WshShell.Run Chr(34) & "${helperPath.replace(/\\/g, '\\\\')}" & Chr(34) & " " & } }); -ipcMain.handle('cancel-download', () => { +ipcMain.handle('cancel-download', async () => { + if (currentDownloadAbortController) { + currentDownloadAbortController.abort(); + currentDownloadAbortController = null; + } + + await new Promise(resolve => setTimeout(resolve, 500)); + await cleanupDownloadFiles(); + isDownloading = false; downloadProgress = {percentage: 0, current: '0 MB', total: '0 MB'}; downloadedFilePath = null; downloadedAsarPath = null; downloadedJarPath = null; + return {success: true}; }); @@ -479,6 +552,77 @@ ipcMain.handle('get-update-status', () => { return {downloadedFilePath, isDownloading, downloadProgress, isPackaged: app.isPackaged}; }); +ipcMain.handle('check-pending-update', () => { + try { + const asarUpdatePath = join(process.resourcesPath, 'app.asar.update'); + const hasAsarUpdate = existsSync(asarUpdatePath); + + // 查找jar更新文件 + let jarUpdatePath = ''; + if (process.resourcesPath && existsSync(process.resourcesPath)) { + const files = readdirSync(process.resourcesPath); + const jarUpdateFile = files.find(f => f.startsWith('erp_client_sb-') && f.endsWith('.jar.update')); + if (jarUpdateFile) { + jarUpdatePath = join(process.resourcesPath, jarUpdateFile); + } + } + + return { + hasPendingUpdate: hasAsarUpdate || !!jarUpdatePath, + asarUpdatePath: hasAsarUpdate ? asarUpdatePath : null, + jarUpdatePath: jarUpdatePath || null + }; + } catch (error) { + console.error('检查待安装更新失败:', error); + return { + hasPendingUpdate: false, + asarUpdatePath: null, + jarUpdatePath: null + }; + } +}); + +ipcMain.handle('clear-update-files', async () => { + try { + await cleanupDownloadFiles(); + + // 重置下载状态 + downloadedFilePath = null; + downloadedAsarPath = null; + downloadedJarPath = null; + isDownloading = false; + downloadProgress = {percentage: 0, current: '0 MB', total: '0 MB'}; + + return {success: true}; + } catch (error: unknown) { + return {success: false, error: error instanceof Error ? error.message : '清除失败'}; + } +}); + +async function cleanupDownloadFiles() { + try { + const tempAsarPath = join(app.getPath('temp'), 'app.asar.new'); + if (existsSync(tempAsarPath)) await fs.unlink(tempAsarPath).catch(() => {}); + + const asarUpdatePath = join(process.resourcesPath, 'app.asar.update'); + if (existsSync(asarUpdatePath)) await fs.unlink(asarUpdatePath).catch(() => {}); + + if (process.resourcesPath && existsSync(process.resourcesPath)) { + const files = readdirSync(process.resourcesPath); + const jarUpdateFiles = files.filter(f => f.startsWith('erp_client_sb-') && f.endsWith('.jar.update')); + for (const file of jarUpdateFiles) { + await fs.unlink(join(process.resourcesPath, file)).catch(() => {}); + } + + const tempJarFiles = files.filter(f => f.startsWith('erp_client_sb-') && f.endsWith('.jar') && !f.includes('update')); + for (const file of tempJarFiles) { + const tempJarPath = join(app.getPath('temp'), file); + if (existsSync(tempJarPath)) await fs.unlink(tempJarPath).catch(() => {}); + } + } + } catch (error) {} +} + // 添加文件保存对话框处理器 ipcMain.handle('show-save-dialog', async (event, options) => { return await dialog.showSaveDialog(mainWindow!, options); @@ -552,33 +696,79 @@ ipcMain.handle('read-log-file', async (event, logDate: string) => { } }); +// 获取关闭行为配置 +ipcMain.handle('get-close-action', () => { + const config = loadConfig(); + return config.closeAction || 'tray'; +}); + +// 保存关闭行为配置 +ipcMain.handle('set-close-action', (event, action: 'quit' | 'minimize' | 'tray') => { + const config = loadConfig(); + config.closeAction = action; + saveConfig(config); + return { success: true }; +}); + async function getFileSize(url: string): Promise { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { const protocol = url.startsWith('https') ? https : http; - protocol.get(url, {method: 'HEAD'}, (response) => { + + const request = protocol.get(url, {method: 'HEAD'}, (response) => { + if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307) { + const redirectUrl = response.headers.location; + if (redirectUrl) { + getFileSize(redirectUrl).then(resolve).catch(() => resolve(0)); + return; + } + } + const size = parseInt(response.headers['content-length'] || '0', 10); resolve(size); }).on('error', () => resolve(0)); + + request.setTimeout(10000, () => { + request.destroy(); + resolve(0); + }); }); } -async function downloadFile(url: string, filePath: string, onProgress: (progress: {downloaded: number}) => void): Promise { +async function downloadFile(url: string, filePath: string, onProgress: (progress: {downloaded: number, total: number}) => void): Promise { return new Promise((resolve, reject) => { const protocol = url.startsWith('https') ? https : http; - protocol.get(url, (response) => { + const request = protocol.get(url, (response) => { + if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307) { + const redirectUrl = response.headers.location; + if (redirectUrl) { + downloadFile(redirectUrl, filePath, onProgress).then(resolve).catch(reject); + return; + } + } + if (response.statusCode !== 200) { reject(new Error(`HTTP ${response.statusCode}`)); return; } + const totalSize = parseInt(response.headers['content-length'] || '0', 10); let downloadedBytes = 0; const fileStream = createWriteStream(filePath); + if (currentDownloadAbortController) { + currentDownloadAbortController.signal.addEventListener('abort', () => { + request.destroy(); + response.destroy(); + fileStream.destroy(); + reject(new Error('下载已取消')); + }); + } + response.on('data', (chunk) => { downloadedBytes += chunk.length; - onProgress({downloaded: downloadedBytes}); + onProgress({downloaded: downloadedBytes, total: totalSize}); }); response.pipe(fileStream); diff --git a/electron-vue-template/src/main/preload.ts b/electron-vue-template/src/main/preload.ts index 3194283..71c81f8 100644 --- a/electron-vue-template/src/main/preload.ts +++ b/electron-vue-template/src/main/preload.ts @@ -10,6 +10,8 @@ const electronAPI = { installUpdate: () => ipcRenderer.invoke('install-update'), cancelDownload: () => ipcRenderer.invoke('cancel-download'), getUpdateStatus: () => ipcRenderer.invoke('get-update-status'), + checkPendingUpdate: () => ipcRenderer.invoke('check-pending-update'), + clearUpdateFiles: () => ipcRenderer.invoke('clear-update-files'), // 添加文件保存对话框 API showSaveDialog: (options: any) => ipcRenderer.invoke('show-save-dialog', options), @@ -21,7 +23,12 @@ const electronAPI = { getLogDates: () => ipcRenderer.invoke('get-log-dates'), readLogFile: (logDate: string) => ipcRenderer.invoke('read-log-file', logDate), + // 关闭行为配置 API + getCloseAction: () => ipcRenderer.invoke('get-close-action'), + setCloseAction: (action: 'quit' | 'minimize' | 'tray') => ipcRenderer.invoke('set-close-action', action), + onDownloadProgress: (callback: (progress: any) => void) => { + ipcRenderer.removeAllListeners('download-progress') ipcRenderer.on('download-progress', (event, progress) => callback(progress)) }, removeDownloadProgressListener: () => { diff --git a/electron-vue-template/src/main/tray.ts b/electron-vue-template/src/main/tray.ts new file mode 100644 index 0000000..effc853 --- /dev/null +++ b/electron-vue-template/src/main/tray.ts @@ -0,0 +1,75 @@ +import { app, Tray, Menu, BrowserWindow, nativeImage } from 'electron' +import { join } from 'path' +import { existsSync } from 'fs' + +let tray: Tray | null = null + +function getIconPath(): string { + const isDev = process.env.NODE_ENV === 'development' + if (isDev) { + return join(__dirname, '../../public/icon/icon.png') + } + const bundledPath = join(process.resourcesPath, 'app.asar.unpacked', 'public/icon/icon.png') + if (existsSync(bundledPath)) return bundledPath + return join(__dirname, '../renderer/icon/icon.png') +} + +export function createTray(mainWindow: BrowserWindow | null) { + if (tray) return tray + + const iconPath = getIconPath() + const icon = nativeImage.createFromPath(iconPath) + tray = new Tray(icon.resize({ width: 16, height: 16 })) + + tray.setToolTip('ERP客户端 - 后台运行中') + + // 左键点击显示窗口 + tray.on('click', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isVisible()) { + mainWindow.hide() + } else { + mainWindow.show() + mainWindow.focus() + } + } + }) + + // 右键菜单 + updateTrayMenu(mainWindow) + + return tray +} + +export function updateTrayMenu(mainWindow: BrowserWindow | null) { + if (!tray) return + + const contextMenu = Menu.buildFromTemplate([ + { + label: '显示窗口', + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show() + mainWindow.focus() + } + } + }, + { type: 'separator' }, + { + label: '退出应用', + click: () => { + app.quit() + } + } + ]) + + tray.setContextMenu(contextMenu) +} + +export function destroyTray() { + if (tray) { + tray.destroy() + tray = null + } +} + diff --git a/electron-vue-template/src/renderer/api/http.ts b/electron-vue-template/src/renderer/api/http.ts index 492d790..a0d7ceb 100644 --- a/electron-vue-template/src/renderer/api/http.ts +++ b/electron-vue-template/src/renderer/api/http.ts @@ -27,12 +27,22 @@ function buildQuery(params?: Record): string { } async function request(path: string, options: RequestInit): Promise { + // 获取token + let token = ''; + try { + const tokenModule = await import('../utils/token'); + token = tokenModule.getToken() || ''; + } catch (e) { + console.warn('获取token失败:', e); + } + const res = await fetch(`${resolveBase(path)}${path}`, { credentials: 'omit', cache: 'no-store', ...options, headers: { 'Content-Type': 'application/json', + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), ...options.headers } }); @@ -72,12 +82,27 @@ export const http = { return request(path, { method: 'DELETE' }); }, - upload(path: string, form: FormData) { + async upload(path: string, form: FormData) { + // 获取token + let token = ''; + try { + const tokenModule = await import('../utils/token'); + token = tokenModule.getToken() || ''; + } catch (e) { + console.warn('获取token失败:', e); + } + + const headers: Record = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + return fetch(`${resolveBase(path)}${path}`, { method: 'POST', body: form, credentials: 'omit', - cache: 'no-store' + cache: 'no-store', + headers }).then(async res => { if (!res.ok) { const text = await res.text().catch(() => ''); diff --git a/electron-vue-template/src/renderer/api/zebra.ts b/electron-vue-template/src/renderer/api/zebra.ts index f83ec96..dedb771 100644 --- a/electron-vue-template/src/renderer/api/zebra.ts +++ b/electron-vue-template/src/renderer/api/zebra.ts @@ -1,12 +1,13 @@ import { http } from './http' export const zebraApi = { - getAccounts() { - return http.get('/tool/banma/accounts') + getAccounts(name?: string) { + return http.get('/tool/banma/accounts', name ? { name } : undefined) }, - saveAccount(body: any) { - return http.post('/tool/banma/accounts', body) + saveAccount(body: any, name?: string) { + const url = name ? `/tool/banma/accounts?name=${encodeURIComponent(name)}` : '/tool/banma/accounts' + return http.post(url, body) }, removeAccount(id: number) { diff --git a/electron-vue-template/src/renderer/components/auth/LoginDialog.vue b/electron-vue-template/src/renderer/components/auth/LoginDialog.vue index f42777a..e4b1fd3 100644 --- a/electron-vue-template/src/renderer/components/auth/LoginDialog.vue +++ b/electron-vue-template/src/renderer/components/auth/LoginDialog.vue @@ -12,7 +12,7 @@ interface Props { interface Emits { (e: 'update:modelValue', value: boolean): void - (e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string }): void + (e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string; accountType?: string; deviceTrialExpired?: boolean }): void (e: 'showRegister'): void } @@ -51,7 +51,9 @@ async function handleAuth() { emit('loginSuccess', { token: loginRes.data.accessToken || loginRes.data.token, permissions: loginRes.data.permissions, - expireTime: loginRes.data.expireTime + expireTime: loginRes.data.expireTime, + accountType: loginRes.data.accountType, + deviceTrialExpired: loginRes.data.deviceTrialExpired || false }) ElMessage.success('登录成功') resetForm() diff --git a/electron-vue-template/src/renderer/components/auth/RegisterDialog.vue b/electron-vue-template/src/renderer/components/auth/RegisterDialog.vue index 10cfeff..2cd4334 100644 --- a/electron-vue-template/src/renderer/components/auth/RegisterDialog.vue +++ b/electron-vue-template/src/renderer/components/auth/RegisterDialog.vue @@ -11,7 +11,7 @@ interface Props { interface Emits { (e: 'update:modelValue', value: boolean): void - (e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string }): void + (e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string; accountType?: string; deviceTrialExpired?: boolean }): void (e: 'backToLogin'): void } @@ -65,24 +65,20 @@ async function handleRegister() { deviceId: deviceId }) - // 显示注册成功和VIP信息 - if (registerRes.data.expireTime) { - const expireDate = new Date(registerRes.data.expireTime) - const now = new Date() - const daysLeft = Math.ceil((expireDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) - - if (daysLeft > 0) { - ElMessage.success(`注册成功!您获得了 ${daysLeft} 天VIP体验`) - } else { - ElMessage.warning('注册成功!该设备已使用过新人福利,请联系管理员续费') - } + // 显示注册成功提示 + if (registerRes.data.deviceTrialExpired) { + ElMessage.warning('注册成功!您获得了3天VIP体验,但该设备试用期已过,请更换设备或联系管理员续费') + } else { + ElMessage.success('注册成功!您获得了3天VIP体验') } // 使用注册返回的token直接登录 emit('loginSuccess', { token: registerRes.data.accessToken || registerRes.data.token, permissions: registerRes.data.permissions, - expireTime: registerRes.data.expireTime + expireTime: registerRes.data.expireTime, + accountType: registerRes.data.accountType, + deviceTrialExpired: registerRes.data.deviceTrialExpired || false }) resetForm() } catch (err) { diff --git a/electron-vue-template/src/renderer/components/common/SettingsDialog.vue b/electron-vue-template/src/renderer/components/common/SettingsDialog.vue index 4b6a312..0e98eda 100644 --- a/electron-vue-template/src/renderer/components/common/SettingsDialog.vue +++ b/electron-vue-template/src/renderer/components/common/SettingsDialog.vue @@ -11,6 +11,7 @@ import { import { feedbackApi } from '../../api/feedback' import { getToken, getUsernameFromToken } from '../../utils/token' import { getOrCreateDeviceId } from '../../utils/deviceId' +import { updateApi } from '../../api/update' interface Props { modelValue: boolean @@ -18,6 +19,7 @@ interface Props { interface Emits { (e: 'update:modelValue', value: boolean): void + (e: 'autoUpdateChanged', value: boolean): void } const props = defineProps() @@ -37,7 +39,7 @@ const platformSettings = ref>({ zebra: { exportPath: '' } }) -const activeTab = ref('amazon') +const activeTab = ref('export') const settingsMainRef = ref(null) const isScrolling = ref(false) @@ -47,6 +49,17 @@ const selectedLogDate = ref('') const feedbackSubmitting = ref(false) const logDates = ref([]) +// 关闭行为配置 +const closeAction = ref<'quit' | 'minimize' | 'tray'>('tray') + +// 更新相关 +const currentVersion = ref('') +const latestVersion = ref('') +const updateNotes = ref('') +const checkingUpdate = ref(false) +const hasUpdate = ref(false) +const autoUpdate = ref(false) + const show = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) @@ -66,15 +79,29 @@ async function selectExportPath(platform: Platform) { } // 保存设置 -function saveAllSettings() { +async function saveAllSettings() { Object.keys(platformSettings.value).forEach(platformKey => { const platform = platformKey as Platform const platformConfig = platformSettings.value[platform] savePlatformSettings(platform, platformConfig) }) + // 保存自动更新配置 + const oldSettings = getSettings() + const autoUpdateChanged = oldSettings.autoUpdate !== autoUpdate.value + + saveSettings({ autoUpdate: autoUpdate.value }) + + // 保存关闭行为配置 + await (window as any).electronAPI.setCloseAction(closeAction.value) + ElMessage({ message: '设置已保存', type: 'success' }) show.value = false + + // 如果自动更新配置改变了,通知父组件 + if (autoUpdateChanged) { + emit('autoUpdateChanged', autoUpdate.value) + } } // 加载设置 @@ -85,6 +112,7 @@ function loadAllSettings() { rakuten: { ...settings.platforms.rakuten }, zebra: { ...settings.platforms.zebra } } + autoUpdate.value = settings.autoUpdate ?? false } // 重置单个平台设置 @@ -110,33 +138,65 @@ function scrollToSection(sectionKey: string) { isScrolling.value = true activeTab.value = sectionKey - settingsMainRef.value.scrollTo({ - top: element.offsetTop - 20, - behavior: 'smooth' - }) + const container = settingsMainRef.value + // 计算元素相对于容器的位置 + const containerRect = container.getBoundingClientRect() + const elementRect = element.getBoundingClientRect() + const relativeTop = elementRect.top - containerRect.top + const targetTop = container.scrollTop + relativeTop - 14 // 14是容器的padding + const startTop = container.scrollTop + const distance = targetTop - startTop + const duration = 800 // 增加持续时间,使滚动更慢更平滑 + let start: number | null = null - setTimeout(() => { - isScrolling.value = false - }, 500) + function animation(currentTime: number) { + if (start === null) start = currentTime + const timeElapsed = currentTime - start + const progress = Math.min(timeElapsed / duration, 1) + + // 使用 easeInOutCubic 缓动函数 + const easing = progress < 0.5 + ? 4 * progress * progress * progress + : 1 - Math.pow(-2 * progress + 2, 3) / 2 + + container.scrollTop = startTop + distance * easing + + if (progress < 1) { + requestAnimationFrame(animation) + } else { + isScrolling.value = false + } + } + + requestAnimationFrame(animation) } } // 监听滚动更新高亮 function handleScroll() { if (isScrolling.value) return - - const sections = ['amazon', 'rakuten', 'zebra', 'feedback'] - const scrollTop = settingsMainRef.value?.scrollTop || 0 - + + const container = settingsMainRef.value + if (!container) return + + const sections = ['export', 'update', 'feedback', 'general'] + const scrollTop = container.scrollTop + const containerHeight = container.clientHeight + const scrollHeight = container.scrollHeight + + // 如果滚动到底部(留10px的误差),高亮最后一项 + if (scrollTop + containerHeight >= scrollHeight - 10) { + activeTab.value = sections[sections.length - 1] + return + } + + // 否则根据可见区域判断(找到最靠上的可见section) for (const key of sections) { const element = document.getElementById(`section-${key}`) if (element) { const offsetTop = element.offsetTop - 50 - const offsetBottom = offsetTop + element.offsetHeight - - if (scrollTop >= offsetTop && scrollTop < offsetBottom) { + if (scrollTop >= offsetTop) { activeTab.value = key - break } } } @@ -154,6 +214,50 @@ async function loadLogDates() { } } +// 加载关闭行为配置 +async function loadCloseAction() { + try { + const action = await (window as any).electronAPI.getCloseAction() + closeAction.value = action || 'tray' + } catch (error) { + console.warn('获取关闭行为配置失败:', error) + } +} + +// 检查更新 +async function checkForUpdates() { + try { + checkingUpdate.value = true + currentVersion.value = await (window as any).electronAPI.getJarVersion() + const checkRes: any = await updateApi.checkUpdate(currentVersion.value) + const result = checkRes?.data || checkRes + + if (!result.needUpdate) { + hasUpdate.value = false + updateNotes.value = '' + ElMessage.success('当前已是最新版本') + } else { + hasUpdate.value = true + latestVersion.value = result.latestVersion + updateNotes.value = result.updateNotes || '' + ElMessage.success('发现新版本:' + result.latestVersion) + } + } catch (error: any) { + ElMessage.error('检查更新失败:' + (error?.message || '网络错误')) + } finally { + checkingUpdate.value = false + } +} + +// 加载当前版本 +async function loadCurrentVersion() { + try { + currentVersion.value = await (window as any).electronAPI.getJarVersion() + } catch (error) { + console.warn('获取当前版本失败:', error) + } +} + // 提交反馈 async function submitFeedback() { if (!feedbackContent.value.trim()) { @@ -209,246 +313,311 @@ async function submitFeedback() { onMounted(() => { loadAllSettings() loadLogDates() + loadCloseAction() + loadCurrentVersion() }) + + + + + diff --git a/electron-vue-template/src/renderer/components/common/UpdateDialog.vue b/electron-vue-template/src/renderer/components/common/UpdateDialog.vue index dd9ba63..9fa19ca 100644 --- a/electron-vue-template/src/renderer/components/common/UpdateDialog.vue +++ b/electron-vue-template/src/renderer/components/common/UpdateDialog.vue @@ -84,6 +84,7 @@ {{ prog.current }} / {{ prog.total }} 下载完成
+ 清除下载 立即重启
@@ -99,10 +100,18 @@ import {ref, computed, onMounted, onUnmounted, watch} from 'vue' import {ElMessage, ElMessageBox} from 'element-plus' import {updateApi} from '../../api/update' +import {getSettings} from '../../utils/settings' -const props = defineProps<{ modelValue: boolean }>() +const props = defineProps<{ + modelValue: boolean +}>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() +// 暴露方法给父组件调用 +defineExpose({ + checkForUpdatesNow +}) + const show = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) @@ -119,7 +128,7 @@ const info = ref({ downloadUrl: '', asarUrl: '', jarUrl: '', - updateNotes: '• 优化了用户界面体验\n• 修复了已知问题\n• 提升了系统稳定性', + updateNotes: '', currentVersion: '', hasUpdate: false }) @@ -135,12 +144,10 @@ async function autoCheck(silent = false) { if (!result.needUpdate) { hasNewVersion.value = false - if (!silent) { - ElMessage.info('当前已是最新版本') - } + if (!silent) ElMessage.info('当前已是最新版本') return } - + // 发现新版本,更新信息并显示小红点 info.value = { currentVersion: result.currentVersion, @@ -148,52 +155,50 @@ async function autoCheck(silent = false) { downloadUrl: result.downloadUrl || '', asarUrl: result.asarUrl || '', jarUrl: result.jarUrl || '', - updateNotes: '• 优化了用户界面体验\n• 修复了已知问题\n• 提升了系统稳定性\n• 同步更新前端和后端', + updateNotes: result.updateNotes || '', hasUpdate: true } hasNewVersion.value = true - // 检查是否跳过此版本 const skippedVersion = localStorage.getItem(SKIP_VERSION_KEY) - if (skippedVersion === result.latestVersion) { - // 跳过的版本:显示小红点,但不弹框 - return - } + if (skippedVersion === result.latestVersion) return - // 检查是否在稍后提醒时间内 const remindLater = localStorage.getItem(REMIND_LATER_KEY) - if (remindLater && Date.now() < parseInt(remindLater)) { - // 稍后提醒期间:显示小红点,但不弹框 + if (remindLater && Date.now() < parseInt(remindLater)) return + + const settings = getSettings() + if (settings.autoUpdate) { + await startAutoDownload() return } - // 首次发现新版本:显示小红点并弹框 show.value = true stage.value = 'check' - if (!silent) { - ElMessage.success('发现新版本') - } + if (!silent) ElMessage.success('发现新版本') } catch (error) { - console.error('检查更新失败:', error) - if (!silent) { - ElMessage.error('检查更新失败') - } + if (!silent) ElMessage.error('检查更新失败') } } function handleVersionClick() { - // 如果有新版本,直接显示更新对话框 + if (stage.value === 'downloading' || stage.value === 'completed') { + show.value = true + return + } + if (hasNewVersion.value) { - // 重置状态确保从检查阶段开始 stage.value = 'check' - prog.value = {percentage: 0, current: '0 MB', total: '0 MB'} show.value = true } else { - // 没有新版本,执行检查更新 - autoCheck(false) + checkForUpdatesNow() } } +// 立即检查更新(供外部调用) +async function checkForUpdatesNow() { + await autoCheck(false) +} + function skipVersion() { localStorage.setItem(SKIP_VERSION_KEY, info.value.latestVersion) show.value = false @@ -206,14 +211,27 @@ function remindLater() { } async function start() { + // 如果已经在下载或已完成,不重复执行 + if (stage.value === 'downloading') { + show.value = true + return + } + + if (stage.value === 'completed') { + show.value = true + return + } + if (!info.value.asarUrl && !info.value.jarUrl) { ElMessage.error('下载链接不可用') return } - + stage.value = 'downloading' + show.value = true prog.value = {percentage: 0, current: '0 MB', total: '0 MB'} + // 设置新的进度监听器(会自动清理旧的) ;(window as any).electronAPI.onDownloadProgress((progress: any) => { prog.value = { percentage: progress.percentage || 0, @@ -231,32 +249,73 @@ async function start() { if (response.success) { stage.value = 'completed' prog.value.percentage = 100 - // 如果没有有效的进度信息,设置默认值 - if (prog.value.current === '0 MB' && prog.value.total === '0 MB') { - // 保持原有的"0 MB"值,让模板中的条件判断来处理显示 - } ElMessage.success('下载完成') + show.value = true } else { ElMessage.error('下载失败: ' + (response.error || '未知错误')) stage.value = 'check' + prog.value = {percentage: 0, current: '0 MB', total: '0 MB'} + ;(window as any).electronAPI.removeDownloadProgressListener() } } catch (error) { - console.error('下载失败:', error) ElMessage.error('下载失败') stage.value = 'check' + prog.value = {percentage: 0, current: '0 MB', total: '0 MB'} + ;(window as any).electronAPI.removeDownloadProgressListener() + } +} + +async function startAutoDownload() { + if (!info.value.asarUrl && !info.value.jarUrl) return + + stage.value = 'downloading' + prog.value = {percentage: 0, current: '0 MB', total: '0 MB'} + + ;(window as any).electronAPI.onDownloadProgress((progress: any) => { + prog.value = { + percentage: progress.percentage || 0, + current: progress.current || '0 MB', + total: progress.total || '0 MB' + } + }) + + try { + const response = await (window as any).electronAPI.downloadUpdate({ + asarUrl: info.value.asarUrl, + jarUrl: info.value.jarUrl + }) + + if (response.success) { + stage.value = 'completed' + prog.value.percentage = 100 + show.value = true + ElMessage.success('更新已下载完成,可以安装了') + } else { + stage.value = 'check' + ;(window as any).electronAPI.removeDownloadProgressListener() + } + } catch (error) { + stage.value = 'check' + ;(window as any).electronAPI.removeDownloadProgressListener() } } async function cancelDownload() { try { - (window as any).electronAPI.removeDownloadProgressListener() + ;(window as any).electronAPI.removeDownloadProgressListener() await (window as any).electronAPI.cancelDownload() - show.value = false + stage.value = 'check' + prog.value = {percentage: 0, current: '0 MB', total: '0 MB'} + hasNewVersion.value = false + show.value = false + + ElMessage.info('已取消下载') } catch (error) { - console.error('取消下载失败:', error) - show.value = false stage.value = 'check' + prog.value = {percentage: 0, current: '0 MB', total: '0 MB'} + hasNewVersion.value = false + show.value = false } } @@ -281,18 +340,46 @@ async function installUpdate() { } } +async function clearDownloadedFiles() { + try { + await ElMessageBox.confirm( + '确定要清除已下载的更新文件吗?清除后需要重新下载。', + '确认清除', + { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + } + ) + + const response = await (window as any).electronAPI.clearUpdateFiles() + + if (response.success) { + ElMessage.success('已清除下载文件') + // 重置状态 + stage.value = 'check' + prog.value = {percentage: 0, current: '0 MB', total: '0 MB'} + hasNewVersion.value = false + show.value = false + } else { + ElMessage.error('清除失败: ' + (response.error || '未知错误')) + } + } catch (error) { + if (error !== 'cancel') ElMessage.error('清除失败') + } +} + onMounted(async () => { version.value = await (window as any).electronAPI.getJarVersion() - await autoCheck(true) -}) - -// 监听对话框关闭,重置状态 -watch(show, (newValue) => { - if (!newValue) { - // 对话框关闭时重置状态 - stage.value = 'check' - prog.value = {percentage: 0, current: '0 MB', total: '0 MB'} + const pendingUpdate = await (window as any).electronAPI.checkPendingUpdate() + + if (pendingUpdate && pendingUpdate.hasPendingUpdate) { + stage.value = 'completed' + prog.value.percentage = 100 + return } + + await autoCheck(true) }) onUnmounted(() => { @@ -315,8 +402,10 @@ onUnmounted(() => { z-index: 1000; cursor: pointer; user-select: none; + transition: all 0.3s ease; } + .update-badge { position: absolute; top: -2px; diff --git a/electron-vue-template/src/renderer/components/rakuten/RakutenDashboard.vue b/electron-vue-template/src/renderer/components/rakuten/RakutenDashboard.vue index 94e2f22..00af1dc 100644 --- a/electron-vue-template/src/renderer/components/rakuten/RakutenDashboard.vue +++ b/electron-vue-template/src/renderer/components/rakuten/RakutenDashboard.vue @@ -1,10 +1,14 @@