feat(electron): 实现系统托盘和关闭行为配置功能

- 添加系统托盘创建和销毁逻辑- 实现窗口关闭行为配置(退出/最小化/托盘)
- 添加配置文件读写功能
- 实现下载取消和清理功能
- 添加待更新文件检查机制
- 优化文件下载进度和错误处理
- 添加自动更新配置选项- 实现平滑滚动动画效果
- 添加试用期过期类型检查
-优化VIP状态刷新逻辑
This commit is contained in:
2025-10-17 14:17:47 +08:00
parent 6e1b4d00de
commit 07e34c35c8
19 changed files with 1545 additions and 467 deletions

View File

@@ -75,7 +75,10 @@
"!jre/lib/ct.sym", "!jre/lib/ct.sym",
"!jre/lib/jvm.lib" "!jre/lib/jvm.lib"
] ]
} },
"!build",
"!dist",
"!scripts"
], ],
"extraResources": [ "extraResources": [
{ {

View File

@@ -19,7 +19,7 @@
"@vitejs/plugin-vue": "^4.4.1", "@vitejs/plugin-vue": "^4.4.1",
"chalk": "^4.1.2", "chalk": "^4.1.2",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"electron": "^38.2.2", "electron": "^32.1.2",
"electron-builder": "^25.1.6", "electron-builder": "^25.1.6",
"electron-rebuild": "^3.2.9", "electron-rebuild": "^3.2.9",
"express": "^5.1.0", "express": "^5.1.0",

View File

@@ -4,6 +4,7 @@ import {join, dirname, basename} from 'path';
import {spawn, ChildProcess} from 'child_process'; import {spawn, ChildProcess} from 'child_process';
import * as https from 'https'; import * as https from 'https';
import * as http from 'http'; import * as http from 'http';
import { createTray, destroyTray } from './tray';
const isDev = process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === 'development';
@@ -16,6 +17,8 @@ let isDownloading = false;
let downloadedFilePath: string | null = null; let downloadedFilePath: string | null = null;
let downloadedAsarPath: string | null = null; let downloadedAsarPath: string | null = null;
let downloadedJarPath: string | null = null; let downloadedJarPath: string | null = null;
let isQuitting = false;
let currentDownloadAbortController: AbortController | null = null;
function openAppIfNotOpened() { function openAppIfNotOpened() {
if (appOpened) return; if (appOpened) return;
appOpened = true; appOpened = true;
@@ -75,6 +78,31 @@ function getDataDirectoryPath(): string {
return dataDir; 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 { function migrateDataFromPublic(): void {
if (!isDev) return; if (!isDev) return;
@@ -171,7 +199,7 @@ function startSpringBoot() {
} }
} }
startSpringBoot(); // startSpringBoot();
function stopSpringBoot() { function stopSpringBoot() {
if (!springProcess) return; if (!springProcess) return;
@@ -210,6 +238,22 @@ function createWindow() {
Menu.setApplicationMenu(null); Menu.setApplicationMenu(null);
mainWindow.setMenuBarVisibility(false); 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.on('closed', () => {
mainWindow = null; mainWindow = null;
@@ -223,6 +267,7 @@ function createWindow() {
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow(); createWindow();
createTray(mainWindow);
const {width: sw, height: sh} = screen.getPrimaryDisplay().workAreaSize; const {width: sw, height: sh} = screen.getPrimaryDisplay().workAreaSize;
splashWindow = new BrowserWindow({ splashWindow = new BrowserWindow({
@@ -251,24 +296,32 @@ app.whenReady().then(() => {
splashWindow.loadFile(splashPath); splashWindow.loadFile(splashPath);
} }
// setTimeout(() => { setTimeout(() => {
// openAppIfNotOpened(); openAppIfNotOpened();
// }, 2000); }, 2000);
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show();
} else if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow();
createTray(mainWindow);
} }
}); });
}); });
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
stopSpringBoot(); // 允许在后台运行,不自动退出
if (process.platform !== 'darwin') app.quit(); if (process.platform !== 'darwin' && isQuitting) {
stopSpringBoot();
app.quit();
}
}); });
app.on('before-quit', () => { app.on('before-quit', () => {
isQuitting = true;
stopSpringBoot(); stopSpringBoot();
destroyTray();
}); });
ipcMain.on('message', (event, message) => { 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'}; if (downloadedFilePath === 'completed') return {success: true, filePath: 'already-completed'};
isDownloading = true; isDownloading = true;
currentDownloadAbortController = new AbortController();
let totalDownloaded = 0; let totalDownloaded = 0;
let totalSize = 0; let combinedTotalSize = 0;
try { try {
// 获取总大小 // 预先获取文件大小,计算总下载大小
const sizes = await Promise.all([ let asarSize = 0;
downloadUrls.asarUrl ? getFileSize(downloadUrls.asarUrl) : 0, let jarSize = 0;
downloadUrls.jarUrl ? getFileSize(downloadUrls.jarUrl) : 0
]);
totalSize = sizes[0] + sizes[1];
// 下载asar文件
if (downloadUrls.asarUrl) { if (downloadUrls.asarUrl) {
const tempAsarPath = join(app.getPath('temp'), 'app.asar.new'); asarSize = await getFileSize(downloadUrls.asarUrl);
const asarSize = sizes[0]; }
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) => { await downloadFile(downloadUrls.asarUrl, tempAsarPath, (progress) => {
const combinedProgress = { 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`, 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; 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'); const asarUpdatePath = join(process.resourcesPath, 'app.asar.update');
await fs.copyFile(tempAsarPath, asarUpdatePath); await fs.copyFile(tempAsarPath, asarUpdatePath);
await fs.unlink(tempAsarPath); await fs.unlink(tempAsarPath);
downloadedAsarPath = asarUpdatePath; downloadedAsarPath = asarUpdatePath;
totalDownloaded += asarSize; totalDownloaded = asarSize;
} }
// 下载jar文件 if (downloadUrls.jarUrl && !currentDownloadAbortController.signal.aborted) {
if (downloadUrls.jarUrl) {
let jarFileName = basename(downloadUrls.jarUrl); let jarFileName = basename(downloadUrls.jarUrl);
if (!jarFileName.match(/^erp_client_sb-[\d.]+\.jar$/)) { if (!jarFileName.match(/^erp_client_sb-[\d.]+\.jar$/)) {
const currentJar = getJarFilePath(); const currentJar = getJarFilePath();
@@ -379,17 +437,20 @@ ipcMain.handle('download-update', async (event, downloadUrls: {asarUrl?: string,
} }
const tempJarPath = join(app.getPath('temp'), jarFileName); const tempJarPath = join(app.getPath('temp'), jarFileName);
await downloadFile(downloadUrls.jarUrl, tempJarPath, (progress) => { await downloadFile(downloadUrls.jarUrl, tempJarPath, (progress) => {
const combinedProgress = { 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`, 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; 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'); const jarUpdatePath = join(process.resourcesPath, jarFileName + '.update');
await fs.copyFile(tempJarPath, jarUpdatePath); await fs.copyFile(tempJarPath, jarUpdatePath);
await fs.unlink(tempJarPath); await fs.unlink(tempJarPath);
@@ -398,10 +459,13 @@ ipcMain.handle('download-update', async (event, downloadUrls: {asarUrl?: string,
downloadedFilePath = 'completed'; downloadedFilePath = 'completed';
isDownloading = false; isDownloading = false;
currentDownloadAbortController = null;
return {success: true, asarPath: downloadedAsarPath, jarPath: downloadedJarPath}; return {success: true, asarPath: downloadedAsarPath, jarPath: downloadedJarPath};
} catch (error: unknown) { } catch (error: unknown) {
await cleanupDownloadFiles();
isDownloading = false; isDownloading = false;
currentDownloadAbortController = null;
downloadedFilePath = null; downloadedFilePath = null;
downloadedAsarPath = null; downloadedAsarPath = null;
downloadedJarPath = 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; isDownloading = false;
downloadProgress = {percentage: 0, current: '0 MB', total: '0 MB'}; downloadProgress = {percentage: 0, current: '0 MB', total: '0 MB'};
downloadedFilePath = null; downloadedFilePath = null;
downloadedAsarPath = null; downloadedAsarPath = null;
downloadedJarPath = null; downloadedJarPath = null;
return {success: true}; return {success: true};
}); });
@@ -479,6 +552,77 @@ ipcMain.handle('get-update-status', () => {
return {downloadedFilePath, isDownloading, downloadProgress, isPackaged: app.isPackaged}; 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) => { ipcMain.handle('show-save-dialog', async (event, options) => {
return await dialog.showSaveDialog(mainWindow!, 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<number> { async function getFileSize(url: string): Promise<number> {
return new Promise((resolve, reject) => { return new Promise((resolve) => {
const protocol = url.startsWith('https') ? https : http; 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); const size = parseInt(response.headers['content-length'] || '0', 10);
resolve(size); resolve(size);
}).on('error', () => resolve(0)); }).on('error', () => resolve(0));
request.setTimeout(10000, () => {
request.destroy();
resolve(0);
});
}); });
} }
async function downloadFile(url: string, filePath: string, onProgress: (progress: {downloaded: number}) => void): Promise<void> { async function downloadFile(url: string, filePath: string, onProgress: (progress: {downloaded: number, total: number}) => void): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http; 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) { if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`)); reject(new Error(`HTTP ${response.statusCode}`));
return; return;
} }
const totalSize = parseInt(response.headers['content-length'] || '0', 10);
let downloadedBytes = 0; let downloadedBytes = 0;
const fileStream = createWriteStream(filePath); const fileStream = createWriteStream(filePath);
if (currentDownloadAbortController) {
currentDownloadAbortController.signal.addEventListener('abort', () => {
request.destroy();
response.destroy();
fileStream.destroy();
reject(new Error('下载已取消'));
});
}
response.on('data', (chunk) => { response.on('data', (chunk) => {
downloadedBytes += chunk.length; downloadedBytes += chunk.length;
onProgress({downloaded: downloadedBytes}); onProgress({downloaded: downloadedBytes, total: totalSize});
}); });
response.pipe(fileStream); response.pipe(fileStream);

View File

@@ -10,6 +10,8 @@ const electronAPI = {
installUpdate: () => ipcRenderer.invoke('install-update'), installUpdate: () => ipcRenderer.invoke('install-update'),
cancelDownload: () => ipcRenderer.invoke('cancel-download'), cancelDownload: () => ipcRenderer.invoke('cancel-download'),
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'), getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
checkPendingUpdate: () => ipcRenderer.invoke('check-pending-update'),
clearUpdateFiles: () => ipcRenderer.invoke('clear-update-files'),
// 添加文件保存对话框 API // 添加文件保存对话框 API
showSaveDialog: (options: any) => ipcRenderer.invoke('show-save-dialog', options), showSaveDialog: (options: any) => ipcRenderer.invoke('show-save-dialog', options),
@@ -21,7 +23,12 @@ const electronAPI = {
getLogDates: () => ipcRenderer.invoke('get-log-dates'), getLogDates: () => ipcRenderer.invoke('get-log-dates'),
readLogFile: (logDate: string) => ipcRenderer.invoke('read-log-file', logDate), 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) => { onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.removeAllListeners('download-progress')
ipcRenderer.on('download-progress', (event, progress) => callback(progress)) ipcRenderer.on('download-progress', (event, progress) => callback(progress))
}, },
removeDownloadProgressListener: () => { removeDownloadProgressListener: () => {

View File

@@ -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
}
}

View File

@@ -27,12 +27,22 @@ function buildQuery(params?: Record<string, unknown>): string {
} }
async function request<T>(path: string, options: RequestInit): Promise<T> { async function request<T>(path: string, options: RequestInit): Promise<T> {
// 获取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}`, { const res = await fetch(`${resolveBase(path)}${path}`, {
credentials: 'omit', credentials: 'omit',
cache: 'no-store', cache: 'no-store',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...options.headers ...options.headers
} }
}); });
@@ -72,12 +82,27 @@ export const http = {
return request<T>(path, { method: 'DELETE' }); return request<T>(path, { method: 'DELETE' });
}, },
upload<T>(path: string, form: FormData) { async upload<T>(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<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return fetch(`${resolveBase(path)}${path}`, { return fetch(`${resolveBase(path)}${path}`, {
method: 'POST', method: 'POST',
body: form, body: form,
credentials: 'omit', credentials: 'omit',
cache: 'no-store' cache: 'no-store',
headers
}).then(async res => { }).then(async res => {
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ''); const text = await res.text().catch(() => '');

View File

@@ -1,12 +1,13 @@
import { http } from './http' import { http } from './http'
export const zebraApi = { export const zebraApi = {
getAccounts() { getAccounts(name?: string) {
return http.get('/tool/banma/accounts') return http.get('/tool/banma/accounts', name ? { name } : undefined)
}, },
saveAccount(body: any) { saveAccount(body: any, name?: string) {
return http.post('/tool/banma/accounts', body) const url = name ? `/tool/banma/accounts?name=${encodeURIComponent(name)}` : '/tool/banma/accounts'
return http.post(url, body)
}, },
removeAccount(id: number) { removeAccount(id: number) {

View File

@@ -12,7 +12,7 @@ interface Props {
interface Emits { interface Emits {
(e: 'update:modelValue', value: boolean): void (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 (e: 'showRegister'): void
} }
@@ -51,7 +51,9 @@ async function handleAuth() {
emit('loginSuccess', { emit('loginSuccess', {
token: loginRes.data.accessToken || loginRes.data.token, token: loginRes.data.accessToken || loginRes.data.token,
permissions: loginRes.data.permissions, permissions: loginRes.data.permissions,
expireTime: loginRes.data.expireTime expireTime: loginRes.data.expireTime,
accountType: loginRes.data.accountType,
deviceTrialExpired: loginRes.data.deviceTrialExpired || false
}) })
ElMessage.success('登录成功') ElMessage.success('登录成功')
resetForm() resetForm()

View File

@@ -11,7 +11,7 @@ interface Props {
interface Emits { interface Emits {
(e: 'update:modelValue', value: boolean): void (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 (e: 'backToLogin'): void
} }
@@ -65,24 +65,20 @@ async function handleRegister() {
deviceId: deviceId deviceId: deviceId
}) })
// 显示注册成功和VIP信息 // 显示注册成功提示
if (registerRes.data.expireTime) { if (registerRes.data.deviceTrialExpired) {
const expireDate = new Date(registerRes.data.expireTime) ElMessage.warning('注册成功您获得了3天VIP体验但该设备试用期已过请更换设备或联系管理员续费')
const now = new Date() } else {
const daysLeft = Math.ceil((expireDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) ElMessage.success('注册成功您获得了3天VIP体验')
if (daysLeft > 0) {
ElMessage.success(`注册成功!您获得了 ${daysLeft} 天VIP体验`)
} else {
ElMessage.warning('注册成功!该设备已使用过新人福利,请联系管理员续费')
}
} }
// 使用注册返回的token直接登录 // 使用注册返回的token直接登录
emit('loginSuccess', { emit('loginSuccess', {
token: registerRes.data.accessToken || registerRes.data.token, token: registerRes.data.accessToken || registerRes.data.token,
permissions: registerRes.data.permissions, permissions: registerRes.data.permissions,
expireTime: registerRes.data.expireTime expireTime: registerRes.data.expireTime,
accountType: registerRes.data.accountType,
deviceTrialExpired: registerRes.data.deviceTrialExpired || false
}) })
resetForm() resetForm()
} catch (err) { } catch (err) {

View File

@@ -0,0 +1,187 @@
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
modelValue: boolean
expiredType: 'device' | 'account' | 'both' // 设备过期、账号过期、都过期
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const titleText = computed(() => {
if (props.expiredType === 'both') return '试用已到期'
if (props.expiredType === 'account') return '账号试用已到期'
return '设备试用已到期'
})
const subtitleText = computed(() => {
if (props.expiredType === 'both') return '试用已到期,请联系客服订阅以获取完整服务'
if (props.expiredType === 'account') return '账号试用已到期,请联系客服订阅'
return '当前设备试用已到期,请更换新设备体验或联系客服订阅'
})
function handleConfirm() {
visible.value = false
}
function copyWechat() {
navigator.clipboard.writeText('_linhong')
// ElMessage.success('微信号已复制')
}
</script>
<template>
<el-dialog
v-model="visible"
:close-on-click-modal="false"
:show-close="true"
width="380px"
center
class="trial-expired-dialog">
<div class="expired-content">
<!-- Logo -->
<div style="text-align: center; margin-bottom: 16px;">
<img src="/icon/image.png" alt="logo" class="expired-logo" />
</div>
<!-- 标题 -->
<h2 class="expired-title">{{ titleText }}</h2>
<!-- 副标题 -->
<p class="expired-subtitle">{{ subtitleText }}</p>
<!-- 客服微信 -->
<div class="wechat-card" @click="copyWechat">
<div class="wechat-icon">
<svg viewBox="0 0 1024 1024">
<path d="M664.250054 368.541681c10.015098 0 19.892049 0.732687 29.67281 1.795902-26.647917-122.810047-159.358451-214.077703-310.826188-214.077703-169.353083 0-308.085774 114.232694-308.085774 259.274068 0 83.708494 46.165436 152.460344 123.281791 205.78483l-30.80868 91.730191 107.688651-53.455469c38.558178 7.53665 69.459978 15.308661 107.924012 15.308661 9.66308 0 19.230993-0.470721 28.752858-1.225921-6.025227-20.36584-9.521864-41.723264-9.521864-63.94508C402.328693 476.632491 517.908058 368.541681 664.250054 368.541681zM498.62897 285.87389c23.200398 0 38.557154 15.120372 38.557154 38.061874 0 22.846334-15.356756 38.298144-38.557154 38.298144-23.107277 0-46.260603-15.45181-46.260603-38.298144C452.368366 300.994262 475.522716 285.87389 498.62897 285.87389zM283.016307 362.23394c-23.107277 0-46.402843-15.45181-46.402843-38.298144 0-22.941502 23.295566-38.061874 46.402843-38.061874 23.081695 0 38.46301 15.120372 38.46301 38.061874C321.479317 346.78213 306.098002 362.23394 283.016307 362.23394zM945.448458 606.151333c0-121.888048-123.258255-221.236753-261.683954-221.236753-146.57838 0-262.015505 99.348706-262.015505 221.236753 0 122.06508 115.437126 221.200938 262.015505 221.200938 30.66644 0 61.617359-7.609305 92.423993-15.262612l84.513836 45.786813-23.178909-76.17082C899.379213 735.776599 945.448458 674.90216 945.448458 606.151333zM598.803483 567.994292c-15.332197 0-30.807656-15.096836-30.807656-30.501688 0-15.190981 15.47546-30.477129 30.807656-30.477129 23.295566 0 38.558178 15.286148 38.558178 30.477129C637.361661 552.897456 622.099049 567.994292 598.803483 567.994292zM768.25071 567.994292c-15.213493 0-30.594809-15.096836-30.594809-30.501688 0-15.190981 15.381315-30.477129 30.594809-30.477129 23.107277 0 38.558178 15.286148 38.558178 30.477129C806.808888 552.897456 791.357987 567.994292 768.25071 567.994292z" fill="#09BB07"/>
</svg>
</div>
<div class="wechat-info">
<div class="wechat-label">客服微信</div>
<div class="wechat-id">_linhong</div>
</div>
</div>
<!-- 按钮 -->
<el-button
type="primary"
class="confirm-btn"
@click="handleConfirm"
style="width: 100%;">
我知道了
</el-button>
</div>
</el-dialog>
</template>
<style scoped>
.trial-expired-dialog :deep(.el-dialog) {
border-radius: 16px;
}
.trial-expired-dialog :deep(.el-dialog__header) {
padding: 0;
margin: 0;
}
.trial-expired-dialog :deep(.el-dialog__body) {
padding: 20px;
}
.expired-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px 0;
}
.expired-logo {
width: 160px;
height: auto;
}
.expired-title {
font-size: 18px;
font-weight: 700;
color: #1f1f1f;
margin: 0 0 8px 0;
text-align: center;
}
.expired-subtitle {
font-size: 12px;
color: #8c8c8c;
margin: 0 0 20px 0;
text-align: center;
line-height: 1.5;
}
.wechat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: #f5f5f5;
border-radius: 6px;
margin-bottom: 20px;
width: 90%;
cursor: pointer;
transition: background 0.3s;
}
.wechat-card:hover {
background: #ebebeb;
}
.wechat-icon {
flex-shrink: 0;
}
.wechat-icon svg {
width: 36px;
height: 36px;
}
.wechat-info {
flex: 1;
text-align: left;
}
.wechat-label {
font-size: 12px;
color: #666;
margin-bottom: 2px;
}
.wechat-id {
font-size: 15px;
font-weight: 500;
color: #1f1f1f;
}
.confirm-btn {
height: 40px;
font-size: 14px;
font-weight: 500;
background: #1677FF;
border-color: #1677FF;
border-radius: 6px;
}
.confirm-btn:hover {
background: #4096ff;
border-color: #4096ff;
}
</style>

View File

@@ -84,6 +84,7 @@
<span style="font-weight: 500" v-if="prog.current !== '0 MB' && prog.total !== '0 MB'">{{ prog.current }} / {{ prog.total }}</span> <span style="font-weight: 500" v-if="prog.current !== '0 MB' && prog.total !== '0 MB'">{{ prog.current }} / {{ prog.total }}</span>
<span style="font-weight: 500" v-else>下载完成</span> <span style="font-weight: 500" v-else>下载完成</span>
<div class="action-buttons"> <div class="action-buttons">
<el-button size="small" @click="clearDownloadedFiles">清除下载</el-button>
<el-button size="small" type="primary" @click="installUpdate">立即重启</el-button> <el-button size="small" type="primary" @click="installUpdate">立即重启</el-button>
</div> </div>
</div> </div>
@@ -99,10 +100,18 @@
import {ref, computed, onMounted, onUnmounted, watch} from 'vue' import {ref, computed, onMounted, onUnmounted, watch} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus' import {ElMessage, ElMessageBox} from 'element-plus'
import {updateApi} from '../../api/update' 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] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
// 暴露方法给父组件调用
defineExpose({
checkForUpdatesNow
})
const show = computed({ const show = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (value) => emit('update:modelValue', value) set: (value) => emit('update:modelValue', value)
@@ -119,7 +128,7 @@ const info = ref({
downloadUrl: '', downloadUrl: '',
asarUrl: '', asarUrl: '',
jarUrl: '', jarUrl: '',
updateNotes: '• 优化了用户界面体验\n• 修复了已知问题\n• 提升了系统稳定性', updateNotes: '',
currentVersion: '', currentVersion: '',
hasUpdate: false hasUpdate: false
}) })
@@ -135,12 +144,10 @@ async function autoCheck(silent = false) {
if (!result.needUpdate) { if (!result.needUpdate) {
hasNewVersion.value = false hasNewVersion.value = false
if (!silent) { if (!silent) ElMessage.info('当前已是最新版本')
ElMessage.info('当前已是最新版本')
}
return return
} }
// 发现新版本,更新信息并显示小红点 // 发现新版本,更新信息并显示小红点
info.value = { info.value = {
currentVersion: result.currentVersion, currentVersion: result.currentVersion,
@@ -148,52 +155,50 @@ async function autoCheck(silent = false) {
downloadUrl: result.downloadUrl || '', downloadUrl: result.downloadUrl || '',
asarUrl: result.asarUrl || '', asarUrl: result.asarUrl || '',
jarUrl: result.jarUrl || '', jarUrl: result.jarUrl || '',
updateNotes: '• 优化了用户界面体验\n• 修复了已知问题\n• 提升了系统稳定性\n• 同步更新前端和后端', updateNotes: result.updateNotes || '',
hasUpdate: true hasUpdate: true
} }
hasNewVersion.value = true hasNewVersion.value = true
// 检查是否跳过此版本
const skippedVersion = localStorage.getItem(SKIP_VERSION_KEY) const skippedVersion = localStorage.getItem(SKIP_VERSION_KEY)
if (skippedVersion === result.latestVersion) { if (skippedVersion === result.latestVersion) return
// 跳过的版本:显示小红点,但不弹框
return
}
// 检查是否在稍后提醒时间内
const remindLater = localStorage.getItem(REMIND_LATER_KEY) 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 return
} }
// 首次发现新版本:显示小红点并弹框
show.value = true show.value = true
stage.value = 'check' stage.value = 'check'
if (!silent) { if (!silent) ElMessage.success('发现新版本')
ElMessage.success('发现新版本')
}
} catch (error) { } catch (error) {
console.error('检查更新失败:', error) if (!silent) ElMessage.error('检查更新失败')
if (!silent) {
ElMessage.error('检查更新失败')
}
} }
} }
function handleVersionClick() { function handleVersionClick() {
// 如果有新版本,直接显示更新对话框 if (stage.value === 'downloading' || stage.value === 'completed') {
show.value = true
return
}
if (hasNewVersion.value) { if (hasNewVersion.value) {
// 重置状态确保从检查阶段开始
stage.value = 'check' stage.value = 'check'
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
show.value = true show.value = true
} else { } else {
// 没有新版本,执行检查更新 checkForUpdatesNow()
autoCheck(false)
} }
} }
// 立即检查更新(供外部调用)
async function checkForUpdatesNow() {
await autoCheck(false)
}
function skipVersion() { function skipVersion() {
localStorage.setItem(SKIP_VERSION_KEY, info.value.latestVersion) localStorage.setItem(SKIP_VERSION_KEY, info.value.latestVersion)
show.value = false show.value = false
@@ -206,14 +211,27 @@ function remindLater() {
} }
async function start() { 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) { if (!info.value.asarUrl && !info.value.jarUrl) {
ElMessage.error('下载链接不可用') ElMessage.error('下载链接不可用')
return return
} }
stage.value = 'downloading' stage.value = 'downloading'
show.value = true
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'} prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
// 设置新的进度监听器(会自动清理旧的)
;(window as any).electronAPI.onDownloadProgress((progress: any) => { ;(window as any).electronAPI.onDownloadProgress((progress: any) => {
prog.value = { prog.value = {
percentage: progress.percentage || 0, percentage: progress.percentage || 0,
@@ -231,32 +249,73 @@ async function start() {
if (response.success) { if (response.success) {
stage.value = 'completed' stage.value = 'completed'
prog.value.percentage = 100 prog.value.percentage = 100
// 如果没有有效的进度信息,设置默认值
if (prog.value.current === '0 MB' && prog.value.total === '0 MB') {
// 保持原有的"0 MB"值,让模板中的条件判断来处理显示
}
ElMessage.success('下载完成') ElMessage.success('下载完成')
show.value = true
} else { } else {
ElMessage.error('下载失败: ' + (response.error || '未知错误')) ElMessage.error('下载失败: ' + (response.error || '未知错误'))
stage.value = 'check' stage.value = 'check'
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
;(window as any).electronAPI.removeDownloadProgressListener()
} }
} catch (error) { } catch (error) {
console.error('下载失败:', error)
ElMessage.error('下载失败') ElMessage.error('下载失败')
stage.value = 'check' 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() { async function cancelDownload() {
try { try {
(window as any).electronAPI.removeDownloadProgressListener() ;(window as any).electronAPI.removeDownloadProgressListener()
await (window as any).electronAPI.cancelDownload() await (window as any).electronAPI.cancelDownload()
show.value = false
stage.value = 'check' stage.value = 'check'
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
hasNewVersion.value = false
show.value = false
ElMessage.info('已取消下载')
} catch (error) { } catch (error) {
console.error('取消下载失败:', error)
show.value = false
stage.value = 'check' 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 () => { onMounted(async () => {
version.value = await (window as any).electronAPI.getJarVersion() version.value = await (window as any).electronAPI.getJarVersion()
await autoCheck(true) const pendingUpdate = await (window as any).electronAPI.checkPendingUpdate()
})
if (pendingUpdate && pendingUpdate.hasPendingUpdate) {
// 监听对话框关闭,重置状态 stage.value = 'completed'
watch(show, (newValue) => { prog.value.percentage = 100
if (!newValue) { return
// 对话框关闭时重置状态
stage.value = 'check'
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
} }
await autoCheck(true)
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -315,8 +402,10 @@ onUnmounted(() => {
z-index: 1000; z-index: 1000;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
transition: all 0.3s ease;
} }
.update-badge { .update-badge {
position: absolute; position: absolute;
top: -2px; top: -2px;

View File

@@ -1,10 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import {ref, computed, onMounted} from 'vue' import {ref, computed, onMounted, defineAsyncComponent, inject} from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import {rakutenApi} from '../../api/rakuten' import {rakutenApi} from '../../api/rakuten'
import { batchConvertImages } from '../../utils/imageProxy' import { batchConvertImages } from '../../utils/imageProxy'
import { handlePlatformFileExport } from '../../utils/settings' import { handlePlatformFileExport } from '../../utils/settings'
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
const refreshVipStatus = inject<() => Promise<boolean>>('refreshVipStatus')
// 接收VIP状态 // 接收VIP状态
const props = defineProps<{ const props = defineProps<{
isVip: boolean isVip: boolean
@@ -49,6 +53,12 @@ const activeStep = computed(() => {
return 2 return 2
}) })
// 试用期过期弹框
const showTrialExpiredDialog = ref(false)
const trialExpiredType = ref<'device' | 'account' | 'both'>('account')
const checkExpiredType = inject<() => 'device' | 'account' | 'both'>('checkExpiredType')
// 左侧:上传文件名与地区 // 左侧:上传文件名与地区
const selectedFileName = ref('') const selectedFileName = ref('')
const pendingFile = ref<File | null>(null) const pendingFile = ref<File | null>(null)
@@ -128,7 +138,8 @@ async function searchProductInternal(product: any) {
if (!product || !product.imgUrl) return if (!product || !product.imgUrl) return
if (!needsSearch(product)) return if (!needsSearch(product)) return
if (!props.isVip) { if (!props.isVip) {
ElMessage.warning('VIP已过期1688识图功能受限') if (checkExpiredType) trialExpiredType.value = checkExpiredType()
showTrialExpiredDialog.value = true
return return
} }
@@ -198,19 +209,13 @@ async function onDrop(e: DragEvent) {
// 点击"获取数据 // 点击"获取数据
async function handleStartSearch() { async function handleStartSearch() {
// 刷新VIP状态
if (refreshVipStatus) await refreshVipStatus()
// VIP检查 // VIP检查
if (!props.isVip) { if (!props.isVip) {
try { if (checkExpiredType) trialExpiredType.value = checkExpiredType()
await ElMessageBox.confirm( showTrialExpiredDialog.value = true
'VIP已过期数据采集功能受限。请联系管理员续费后继续使用。',
'VIP功能限制',
{
confirmButtonText: '我知道了',
showCancelButton: false,
type: 'warning'
}
)
} catch {}
return return
} }
@@ -509,6 +514,9 @@ onMounted(loadLatest)
<el-button type="primary" class="btn-blue" @click="rakutenExampleVisible = false">我知道了</el-button> <el-button type="primary" class="btn-blue" @click="rakutenExampleVisible = false">我知道了</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 试用期过期弹框 -->
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType" />
<!-- 数据显示区域 --> <!-- 数据显示区域 -->
<div class="table-container"> <div class="table-container">
<div class="table-section"> <div class="table-section">

View File

@@ -1,10 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, defineAsyncComponent, inject } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { zebraApi, type ZebraOrder, type BanmaAccount } from '../../api/zebra' import { zebraApi, type ZebraOrder, type BanmaAccount } from '../../api/zebra'
import AccountManager from '../common/AccountManager.vue' import AccountManager from '../common/AccountManager.vue'
import { batchConvertImages } from '../../utils/imageProxy' import { batchConvertImages } from '../../utils/imageProxy'
import { handlePlatformFileExport } from '../../utils/settings' import { handlePlatformFileExport } from '../../utils/settings'
import { getUsernameFromToken } from '../../utils/token'
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
const refreshVipStatus = inject<() => Promise<boolean>>('refreshVipStatus')
// 接收VIP状态 // 接收VIP状态
const props = defineProps<{ const props = defineProps<{
@@ -35,6 +40,12 @@ const fetchCurrentPage = ref(1)
const fetchTotalPages = ref(0) const fetchTotalPages = ref(0)
const fetchTotalItems = ref(0) const fetchTotalItems = ref(0)
const isFetching = ref(false) const isFetching = ref(false)
// 试用期过期弹框
const showTrialExpiredDialog = ref(false)
const trialExpiredType = ref<'device' | 'account' | 'both'>('account')
const checkExpiredType = inject<() => 'device' | 'account' | 'both'>('checkExpiredType')
function selectAccount(id: number) { function selectAccount(id: number) {
accountId.value = id accountId.value = id
loadShops() loadShops()
@@ -68,7 +79,8 @@ async function loadShops() {
async function loadAccounts() { async function loadAccounts() {
try { try {
const res = await zebraApi.getAccounts() const username = getUsernameFromToken()
const res = await zebraApi.getAccounts(username)
const list = (res as any)?.data ?? res const list = (res as any)?.data ?? res
accounts.value = Array.isArray(list) ? list : [] accounts.value = Array.isArray(list) ? list : []
const def = accounts.value.find(a => a.isDefault === 1) || accounts.value[0] const def = accounts.value.find(a => a.isDefault === 1) || accounts.value[0]
@@ -91,19 +103,13 @@ function handleCurrentChange(page: number) {
async function fetchData() { async function fetchData() {
if (isFetching.value) return if (isFetching.value) return
// 刷新VIP状态
if (refreshVipStatus) await refreshVipStatus()
// VIP检查 // VIP检查
if (!props.isVip) { if (!props.isVip) {
try { if (checkExpiredType) trialExpiredType.value = checkExpiredType()
await ElMessageBox.confirm( showTrialExpiredDialog.value = true
'VIP已过期数据采集功能受限。请联系管理员续费后继续使用。',
'VIP功能限制',
{
confirmButtonText: '我知道了',
showCancelButton: false,
type: 'warning'
}
)
} catch {}
return return
} }
@@ -318,7 +324,8 @@ async function submitAccount() {
status: accountForm.value.status || 1, status: accountForm.value.status || 1,
} }
try { try {
const res = await zebraApi.saveAccount(payload) const username = getUsernameFromToken()
const res = await zebraApi.saveAccount(payload, username)
const id = (res as any)?.data?.id || (res as any)?.id const id = (res as any)?.data?.id || (res as any)?.id
if (!id) throw new Error((res as any)?.msg || '保存失败') if (!id) throw new Error((res as any)?.msg || '保存失败')
accountDialogVisible.value = false accountDialogVisible.value = false
@@ -368,7 +375,6 @@ async function removeCurrentAccount() {
<span :class="['status-dot', a.status === 1 ? 'on' : 'off']"></span> <span :class="['status-dot', a.status === 1 ? 'on' : 'off']"></span>
<img class="avatar" src="/image/img_v3_02qd_052605f0-4be3-44db-9691-35ee5ff6201g.jpg" alt="avatar" /> <img class="avatar" src="/image/img_v3_02qd_052605f0-4be3-44db-9691-35ee5ff6201g.jpg" alt="avatar" />
<span class="acct-text">{{ a.name || a.username }}</span> <span class="acct-text">{{ a.name || a.username }}</span>
<span v-if="a.isDefault===1" class="tag">默认</span>
<span v-if="accountId === a.id" class="acct-check"></span> <span v-if="accountId === a.id" class="acct-check"></span>
</span> </span>
</div> </div>
@@ -544,6 +550,10 @@ async function removeCurrentAccount() {
<el-button type="primary" class="btn-blue" style="width: 100%" @click="submitAccount">登录</el-button> <el-button type="primary" class="btn-blue" style="width: 100%" @click="submitAccount">登录</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 试用期过期弹框 -->
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType" />
<AccountManager ref="accountManagerRef" v-model="managerVisible" platform="zebra" @add="openAddAccount" @refresh="loadAccounts" /> <AccountManager ref="accountManagerRef" v-model="managerVisible" platform="zebra" @add="openAddAccount" @refresh="loadAccounts" />
</div> </div>
</template> </template>

View File

@@ -15,6 +15,8 @@ export interface AppSettings {
rakuten: PlatformExportSettings rakuten: PlatformExportSettings
zebra: PlatformExportSettings zebra: PlatformExportSettings
} }
// 更新设置
autoUpdate?: boolean
} }
const SETTINGS_KEY = 'app-settings' const SETTINGS_KEY = 'app-settings'
@@ -31,7 +33,8 @@ const defaultSettings: AppSettings = {
amazon: { ...defaultPlatformSettings }, amazon: { ...defaultPlatformSettings },
rakuten: { ...defaultPlatformSettings }, rakuten: { ...defaultPlatformSettings },
zebra: { ...defaultPlatformSettings } zebra: { ...defaultPlatformSettings }
} },
autoUpdate: false
} }
// 获取设置 // 获取设置
@@ -45,7 +48,8 @@ export function getSettings(): AppSettings {
amazon: { ...defaultSettings.platforms.amazon, ...settings.platforms?.amazon }, amazon: { ...defaultSettings.platforms.amazon, ...settings.platforms?.amazon },
rakuten: { ...defaultSettings.platforms.rakuten, ...settings.platforms?.rakuten }, rakuten: { ...defaultSettings.platforms.rakuten, ...settings.platforms?.rakuten },
zebra: { ...defaultSettings.platforms.zebra, ...settings.platforms?.zebra } zebra: { ...defaultSettings.platforms.zebra, ...settings.platforms?.zebra }
} },
autoUpdate: settings.autoUpdate ?? defaultSettings.autoUpdate
} }
} }
return defaultSettings return defaultSettings
@@ -60,7 +64,8 @@ export function saveSettings(settings: Partial<AppSettings>): void {
amazon: { ...current.platforms.amazon, ...settings.platforms?.amazon }, amazon: { ...current.platforms.amazon, ...settings.platforms?.amazon },
rakuten: { ...current.platforms.rakuten, ...settings.platforms?.rakuten }, rakuten: { ...current.platforms.rakuten, ...settings.platforms?.rakuten },
zebra: { ...current.platforms.zebra, ...settings.platforms?.zebra } zebra: { ...current.platforms.zebra, ...settings.platforms?.zebra }
} },
autoUpdate: settings.autoUpdate ?? current.autoUpdate
} }
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated)) localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated))
} }

View File

@@ -38,9 +38,6 @@ public class SystemController {
@Value("${api.server.base-url}") @Value("${api.server.base-url}")
private String serverBaseUrl; private String serverBaseUrl;
// ==================== 认证管理 ====================
/** /**
* 保存认证密钥 * 保存认证密钥
*/ */

View File

@@ -15,20 +15,21 @@ import java.util.Map;
/** /**
* 版本管理控制器 * 版本管理控制器
* *
* @author ruoyi * @author ruoyi
*/ */
@RestController @RestController
@RequestMapping("/system/version") @RequestMapping("/system/version")
@Anonymous @Anonymous
public class VersionController extends BaseController { public class VersionController extends BaseController {
@Autowired @Autowired
private RedisTemplate<String, String> redisTemplate; private RedisTemplate<String, String> redisTemplate;
private static final String VERSION_REDIS_KEY = "erp:client:version"; private static final String VERSION_REDIS_KEY = "erp:client:version";
private static final String ASAR_URL_REDIS_KEY = "erp:client:asar_url"; private static final String ASAR_URL_REDIS_KEY = "erp:client:asar_url";
private static final String JAR_URL_REDIS_KEY = "erp:client:jar_url"; private static final String JAR_URL_REDIS_KEY = "erp:client:jar_url";
private static final String UPDATE_NOTES_REDIS_KEY = "erp:client:update_notes";
/** /**
* 检查版本更新 * 检查版本更新
*/ */
@@ -36,14 +37,15 @@ public class VersionController extends BaseController {
public AjaxResult checkVersion(@RequestParam String currentVersion) { public AjaxResult checkVersion(@RequestParam String currentVersion) {
String latestVersion = redisTemplate.opsForValue().get(VERSION_REDIS_KEY); String latestVersion = redisTemplate.opsForValue().get(VERSION_REDIS_KEY);
boolean needUpdate = compareVersions(currentVersion, latestVersion) < 0; boolean needUpdate = compareVersions(currentVersion, latestVersion) < 0;
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("currentVersion", currentVersion); data.put("currentVersion", currentVersion);
data.put("latestVersion", latestVersion); data.put("latestVersion", latestVersion);
data.put("needUpdate", needUpdate); data.put("needUpdate", needUpdate);
data.put("asarUrl", redisTemplate.opsForValue().get(ASAR_URL_REDIS_KEY)); data.put("asarUrl", redisTemplate.opsForValue().get(ASAR_URL_REDIS_KEY));
data.put("jarUrl", redisTemplate.opsForValue().get(JAR_URL_REDIS_KEY)); data.put("jarUrl", redisTemplate.opsForValue().get(JAR_URL_REDIS_KEY));
data.put("updateNotes", redisTemplate.opsForValue().get(UPDATE_NOTES_REDIS_KEY));
return AjaxResult.success(data); return AjaxResult.success(data);
} }
/** /**
@@ -56,16 +58,17 @@ public class VersionController extends BaseController {
if (StringUtils.isEmpty(currentVersion)) { if (StringUtils.isEmpty(currentVersion)) {
currentVersion = "2.0.0"; currentVersion = "2.0.0";
} }
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("currentVersion", currentVersion); data.put("currentVersion", currentVersion);
data.put("asarUrl", redisTemplate.opsForValue().get(ASAR_URL_REDIS_KEY)); data.put("asarUrl", redisTemplate.opsForValue().get(ASAR_URL_REDIS_KEY));
data.put("jarUrl", redisTemplate.opsForValue().get(JAR_URL_REDIS_KEY)); data.put("jarUrl", redisTemplate.opsForValue().get(JAR_URL_REDIS_KEY));
data.put("updateNotes", redisTemplate.opsForValue().get(UPDATE_NOTES_REDIS_KEY));
data.put("updateTime", System.currentTimeMillis()); data.put("updateTime", System.currentTimeMillis());
return AjaxResult.success(data); return AjaxResult.success(data);
} }
/** /**
* 设置版本信息和下载链接 * 设置版本信息和下载链接
*/ */
@@ -73,8 +76,9 @@ public class VersionController extends BaseController {
@PreAuthorize("@ss.hasPermi('system:version:update')") @PreAuthorize("@ss.hasPermi('system:version:update')")
@PostMapping("/update") @PostMapping("/update")
public AjaxResult updateVersionInfo(@RequestParam("version") String version, public AjaxResult updateVersionInfo(@RequestParam("version") String version,
@RequestParam(value = "asarUrl", required = false) String asarUrl, @RequestParam(value = "asarUrl", required = false) String asarUrl,
@RequestParam(value = "jarUrl", required = false) String jarUrl) { @RequestParam(value = "jarUrl", required = false) String jarUrl,
@RequestParam("updateNotes") String updateNotes) {
redisTemplate.opsForValue().set(VERSION_REDIS_KEY, version); redisTemplate.opsForValue().set(VERSION_REDIS_KEY, version);
if (StringUtils.isNotEmpty(asarUrl)) { if (StringUtils.isNotEmpty(asarUrl)) {
redisTemplate.opsForValue().set(ASAR_URL_REDIS_KEY, asarUrl); redisTemplate.opsForValue().set(ASAR_URL_REDIS_KEY, asarUrl);
@@ -82,15 +86,17 @@ public class VersionController extends BaseController {
if (StringUtils.isNotEmpty(jarUrl)) { if (StringUtils.isNotEmpty(jarUrl)) {
redisTemplate.opsForValue().set(JAR_URL_REDIS_KEY, jarUrl); redisTemplate.opsForValue().set(JAR_URL_REDIS_KEY, jarUrl);
} }
redisTemplate.opsForValue().set(UPDATE_NOTES_REDIS_KEY, updateNotes);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("version", version); data.put("version", version);
data.put("asarUrl", asarUrl); data.put("asarUrl", asarUrl);
data.put("jarUrl", jarUrl); data.put("jarUrl", jarUrl);
data.put("updateNotes", updateNotes);
data.put("updateTime", System.currentTimeMillis()); data.put("updateTime", System.currentTimeMillis());
return AjaxResult.success(data); return AjaxResult.success(data);
} }
/** /**
* 比较版本号 * 比较版本号
* @param version1 版本1 * @param version1 版本1
@@ -103,18 +109,18 @@ public class VersionController extends BaseController {
} }
String[] v1Parts = version1.split("\\."); String[] v1Parts = version1.split("\\.");
String[] v2Parts = version2.split("\\."); String[] v2Parts = version2.split("\\.");
int maxLength = Math.max(v1Parts.length, v2Parts.length); int maxLength = Math.max(v1Parts.length, v2Parts.length);
for (int i = 0; i < maxLength; i++) { for (int i = 0; i < maxLength; i++) {
int v1Part = i < v1Parts.length ? Integer.parseInt(v1Parts[i]) : 0; int v1Part = i < v1Parts.length ? Integer.parseInt(v1Parts[i]) : 0;
int v2Part = i < v2Parts.length ? Integer.parseInt(v2Parts[i]) : 0; int v2Part = i < v2Parts.length ? Integer.parseInt(v2Parts[i]) : 0;
if (v1Part != v2Part) { if (v1Part != v2Part) {
return Integer.compare(v1Part, v2Part); return Integer.compare(v1Part, v2Part);
} }
} }
return 0; return 0;
} }
} }

View File

@@ -1,5 +1,4 @@
package com.ruoyi.system.service; package com.ruoyi.system.service;
import java.util.List; import java.util.List;
import com.ruoyi.system.domain.BanmaAccount; import com.ruoyi.system.domain.BanmaAccount;
@@ -8,10 +7,13 @@ import com.ruoyi.system.domain.BanmaAccount;
*/ */
public interface IBanmaAccountService { public interface IBanmaAccountService {
List<BanmaAccount> listSimple(); List<BanmaAccount> listSimple();
List<BanmaAccount> listSimple(String clientUsername);
Long saveOrUpdate(BanmaAccount entity); Long saveOrUpdate(BanmaAccount entity);
Long saveOrUpdate(BanmaAccount entity, String clientUsername);
void remove(Long id); void remove(Long id);
boolean refreshToken(Long id); boolean refreshToken(Long id);
void refreshAllTokens(); void refreshAllTokens();
String validateAndGetToken(String username, String password);
} }

View File

@@ -82,19 +82,25 @@
<el-table-column label="ID" align="center" prop="id" width="80" /> <el-table-column label="ID" align="center" prop="id" width="80" />
<el-table-column label="账号名称" align="center" prop="accountName" :show-overflow-tooltip="true" /> <el-table-column label="账号名称" align="center" prop="accountName" :show-overflow-tooltip="true" />
<el-table-column label="用户名" align="center" prop="username" :show-overflow-tooltip="true" /> <el-table-column label="用户名" align="center" prop="username" :show-overflow-tooltip="true" />
<el-table-column label="状态" align="center" prop="status"> <el-table-column label="状态" align="center" prop="status" width="80">
<template slot-scope="scope"> <template slot-scope="scope">
<dict-tag :options="statusOptions" :value="scope.row.status" /> <dict-tag :options="statusOptions" :value="scope.row.status" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="过期时间" align="center" prop="expireTime" width="180"> <el-table-column label="账号类型" align="center" prop="accountType" width="90">
<template slot-scope="scope">
<el-tag :type="scope.row.accountType === 'paid' ? 'success' : 'warning'" size="small">
{{ scope.row.accountType === 'paid' ? '付费' : '试用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="过期时间" align="center" prop="expireTime" width="130">
<template slot-scope="scope"> <template slot-scope="scope">
<el-tag <el-tag
:type="getRemainingDays(scope.row).type" :type="getRemainingDays(scope.row.expireTime).type"
size="small" size="small"
style="margin-top: 5px;"
> >
{{ getRemainingDays(scope.row).text }} {{ getRemainingDays(scope.row.expireTime).text }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
@@ -104,7 +110,7 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="180" /> <el-table-column label="创建时间" align="center" prop="createTime" width="180" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="150"> <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="200">
<template slot-scope="scope"> <template slot-scope="scope">
<el-button <el-button
size="mini" size="mini"
@@ -113,6 +119,12 @@
@click="handleUpdate(scope.row)" @click="handleUpdate(scope.row)"
v-hasPermi="['monitor:account:edit']" v-hasPermi="['monitor:account:edit']"
>修改</el-button> >修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-mobile-phone"
@click="viewDevices(scope.row)"
>设备</el-button>
<el-button <el-button
size="mini" size="mini"
type="text" type="text"
@@ -206,6 +218,67 @@
</div> </div>
</el-dialog> </el-dialog>
<!-- 设备列表对话框 -->
<el-dialog :title="'账号【' + currentAccountName + '】的设备列表'" :visible.sync="deviceListVisible" width="800px" append-to-body>
<el-table :data="deviceList" v-loading="deviceListLoading">
<el-table-column label="设备名称" prop="name" :show-overflow-tooltip="true" />
<el-table-column label="操作系统" prop="os" width="120" />
<el-table-column label="状态" prop="status" width="80" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.status === 'online' ? 'success' : 'info'" size="small">
{{ scope.row.status === 'online' ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="设备试用期" prop="trialExpireTime" width="130" align="center">
<template slot-scope="scope">
<el-tag
:type="getRemainingDays(scope.row.trialExpireTime).type"
size="small"
>
{{ getRemainingDays(scope.row.trialExpireTime).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最近在线" prop="lastActiveAt" width="160" />
<el-table-column label="操作" align="center" width="100">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-edit"
@click="editDeviceExpire(scope.row)"
>修改</el-button>
</template>
</el-table-column>
</el-table>
<div slot="footer" class="dialog-footer">
<el-button @click="deviceListVisible = false"> </el-button>
</div>
</el-dialog>
<!-- 修改设备过期时间对话框 -->
<el-dialog title="修改设备试用期" :visible.sync="deviceExpireDialogVisible" width="400px" append-to-body>
<el-form label-width="120px">
<el-form-item label="设备名称">
<el-input v-model="currentDevice.name" disabled />
</el-form-item>
<el-form-item label="试用期过期时间">
<el-date-picker
v-model="currentDevice.trialExpireTime"
type="datetime"
placeholder="选择过期时间"
value-format="yyyy-MM-dd HH:mm:ss"
style="width: 100%;"
></el-date-picker>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitDeviceExpire"> </el-button>
<el-button @click="deviceExpireDialogVisible = false"> </el-button>
</div>
</el-dialog>
<!-- 注册对话框 --> <!-- 注册对话框 -->
<el-dialog title="客户端账号注册" :visible.sync="registerOpen" width="400px" append-to-body> <el-dialog title="客户端账号注册" :visible.sync="registerOpen" width="400px" append-to-body>
<el-form ref="registerForm" :model="registerForm" :rules="registerRules" label-width="80px"> <el-form ref="registerForm" :model="registerForm" :rules="registerRules" label-width="80px">
@@ -231,7 +304,7 @@
</template> </template>
<script> <script>
import { listAccount, getAccount, delAccount, addAccount, updateAccount, registerAccount, checkUsername, renewAccount } from "@/api/monitor/account"; import { listAccount, getAccount, delAccount, addAccount, updateAccount, registerAccount, checkUsername, renewAccount, getDeviceList, updateDeviceExpire } from "@/api/monitor/account";
export default { export default {
name: "Account", name: "Account",
@@ -262,6 +335,14 @@ export default {
registerLoading: false, registerLoading: false,
usernameChecking: false, usernameChecking: false,
usernameAvailable: null, usernameAvailable: null,
// 设备列表
deviceListVisible: false,
deviceListLoading: false,
deviceList: [],
currentAccountName: '',
// 设备过期时间编辑
deviceExpireDialogVisible: false,
currentDevice: {},
// 状态数据字典 // 状态数据字典
statusOptions: [ statusOptions: [
{ dictLabel: "正常", dictValue: "0" }, { dictLabel: "正常", dictValue: "0" },
@@ -325,11 +406,6 @@ export default {
this.loading = false; this.loading = false;
}); });
}, },
// 检查是否过期
isExpired(row) {
if (!row.expireTime) return false;
return new Date(row.expireTime) < new Date();
},
// 表单重置 // 表单重置
reset() { reset() {
this.form = { this.form = {
@@ -338,6 +414,7 @@ export default {
username: null, username: null,
password: null, password: null,
status: "0", status: "0",
accountType: "trial",
expireTime: null, expireTime: null,
renewDays: null, renewDays: null,
deviceLimit: 3, deviceLimit: 3,
@@ -518,6 +595,43 @@ export default {
this.registerOpen = false; this.registerOpen = false;
this.resetRegisterForm(); this.resetRegisterForm();
}, },
/** 查看设备列表 */
async viewDevices(row) {
this.currentAccountName = row.accountName || row.username;
this.deviceListVisible = true;
this.deviceListLoading = true;
try {
const response = await getDeviceList(row.username);
this.deviceList = response.data || [];
} catch (error) {
this.$modal.msgError('获取设备列表失败');
this.deviceList = [];
} finally {
this.deviceListLoading = false;
}
},
/** 编辑设备过期时间 */
editDeviceExpire(row) {
this.currentDevice = { ...row };
this.deviceExpireDialogVisible = true;
},
/** 提交设备过期时间修改 */
async submitDeviceExpire() {
try {
await updateDeviceExpire({
deviceId: this.currentDevice.deviceId,
username: this.currentDevice.username,
trialExpireTime: this.currentDevice.trialExpireTime
});
this.$modal.msgSuccess('修改成功');
this.deviceExpireDialogVisible = false;
// 刷新设备列表
const response = await getDeviceList(this.currentDevice.username);
this.deviceList = response.data || [];
} catch (error) {
this.$modal.msgError('修改失败: ' + (error.message || '未知错误'));
}
},
/** 计算续费后的新到期时间 */ /** 计算续费后的新到期时间 */
calculateNewExpireTime() { calculateNewExpireTime() {
if (!this.form.renewDays) return ''; if (!this.form.renewDays) return '';
@@ -542,13 +656,13 @@ export default {
}); });
}, },
/** 获取剩余天数 */ /** 获取剩余天数 */
getRemainingDays(row) { getRemainingDays(expireTime) {
if (!row.expireTime) { if (!expireTime) {
return { text: '已过期', type: 'danger' }; return { text: '未设置', type: 'info' };
} }
const now = new Date(); const now = new Date();
const expireDate = new Date(row.expireTime); const expireDate = new Date(expireTime);
const diffTime = expireDate - now; const diffTime = expireDate - now;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));