feat(device): 实现设备与账号绑定管理机制

- 引入 ClientAccountDevice 表管理设备与账号绑定关系
- 重构设备注册逻辑,支持多账号绑定同一设备
- 新增设备配额检查,基于账号维度限制设备数量
-优化设备移除逻辑,仅解除绑定而非物理删除- 改进设备列表查询,通过账号ID关联获取设备信息
- 更新心跳任务,支持向设备绑定的所有账号发送心跳
- 调整设备API参数,增加username字段用于权限校验
-修复HTTP请求编码问题,统一使用UTF-8字符集
- 增强错误处理,携带错误码信息便于前端识别
- 移除设备表中的username字段,解耦设备与用户名关联
This commit is contained in:
2025-10-22 09:51:55 +08:00
parent 901d67d2dc
commit 17b6a7b9f9
29 changed files with 589 additions and 277 deletions

View File

@@ -213,7 +213,7 @@ function startSpringBoot() {
}
}
// startSpringBoot();
startSpringBoot();
function stopSpringBoot() {
if (!springProcess) return;
@@ -253,12 +253,23 @@ function createWindow() {
Menu.setApplicationMenu(null);
mainWindow.setMenuBarVisibility(false);
// 阻止默认的文件拖拽导航行为,让渲染进程的 JavaScript 处理拖拽上传
mainWindow.webContents.on('will-navigate', (event, url) => {
// 允许开发模式下的热重载导航
if (isDev && url.startsWith('http://localhost')) return;
// 阻止所有其他导航(包括拖拽文件)
event.preventDefault();
});
// 同样阻止新窗口的文件拖拽
mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
// 拦截关闭事件
mainWindow.on('close', (event) => {
if (isQuitting) return;
const config = loadConfig();
const closeAction = config.closeAction || 'tray';
const closeAction = config.closeAction || 'quit';
if (closeAction === 'quit') {
isQuitting = true;
@@ -280,6 +291,21 @@ function createWindow() {
}
// 单实例锁定
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
});
}
app.whenReady().then(() => {
// 应用开机自启动配置
const config = loadConfig();
@@ -324,9 +350,9 @@ app.whenReady().then(() => {
}
}
setTimeout(() => {
openAppIfNotOpened();
}, 2000);
// setTimeout(() => {
// openAppIfNotOpened();
// }, 2000);
app.on('activate', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -727,7 +753,7 @@ ipcMain.handle('read-log-file', async (event, logDate: string) => {
// 获取关闭行为配置
ipcMain.handle('get-close-action', () => {
const config = loadConfig();
return config.closeAction || 'tray';
return config.closeAction;
});
// 保存关闭行为配置

View File

@@ -415,6 +415,14 @@ async function openDeviceManager() {
await fetchDeviceData()
}
async function handleDeviceConflict(username: string) {
showAuthDialog.value = false
currentUsername.value = username
showDeviceDialog.value = true
ElMessage.warning('该设备已在其他位置登录,请先移除冲突的设备')
await fetchDeviceData()
}
function openSettings() {
showSettingsDialog.value = true
}
@@ -470,6 +478,18 @@ onMounted(async () => {
// 检查是否有待安装的更新
await checkPendingUpdate()
// 全局阻止文件拖拽到窗口(避免意外打开文件)
// 只在指定的 dropzone 区域处理拖拽上传
document.addEventListener('dragover', (e) => {
e.preventDefault()
e.stopPropagation()
}, false)
document.addEventListener('drop', (e) => {
e.preventDefault()
e.stopPropagation()
}, false)
})
async function checkPendingUpdate() {
@@ -580,7 +600,8 @@ onUnmounted(() => {
<LoginDialog
v-model="showAuthDialog"
@login-success="handleLoginSuccess"
@show-register="showRegisterDialog"/>
@show-register="showRegisterDialog"
@device-conflict="handleDeviceConflict"/>
<RegisterDialog
v-model="showRegDialog"

View File

@@ -40,7 +40,7 @@ export const deviceApi = {
return http.post('/monitor/device/register', { ...payload, ip })
},
remove(payload: { deviceId: string }) {
remove(payload: { deviceId: string; username: string }) {
return http.post('/monitor/device/remove', payload)
},

View File

@@ -39,7 +39,7 @@ async function request<T>(path: string, options: RequestInit & { signal?: AbortS
cache: 'no-store',
...options,
headers: {
'Content-Type': 'application/json',
'Content-Type': 'application/json;charset=UTF-8',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...options.headers
}
@@ -54,7 +54,9 @@ async function request<T>(path: string, options: RequestInit & { signal?: AbortS
if (contentType.includes('application/json')) {
const json: any = await res.json();
if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
throw new Error(json.msg || '请求失败');
const error: any = new Error(json.msg || '请求失败');
error.code = json.code;
throw error;
}
return json as T;
}
@@ -98,7 +100,9 @@ export const http = {
if (contentType.includes('application/json')) {
const json: any = await res.json();
if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
throw new Error(json.msg || '请求失败');
const error: any = new Error(json.msg || '请求失败');
error.code = json.code;
throw error;
}
return json as T;
}

View File

@@ -5,6 +5,10 @@ export const zebraApi = {
return http.get('/tool/banma/accounts', name ? { name } : undefined)
},
getAccountLimit(name?: string) {
return http.get('/tool/banma/account-limit', name ? { name } : undefined)
},
saveAccount(body: any, name?: string) {
const url = name ? `/tool/banma/accounts?name=${encodeURIComponent(name)}` : '/tool/banma/accounts'
return http.post(url, body)

View File

@@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { amazonApi } from '../../api/amazon'
import { systemApi } from '../../api/system'
import { handlePlatformFileExport } from '../../utils/settings'
import { useFileDrop } from '../../composables/useFileDrop'
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
@@ -28,7 +29,6 @@ let abortController: AbortController | null = null // 请求取消控制器
const currentPage = ref(1)
const pageSize = ref(15)
const amazonUpload = ref<HTMLInputElement | null>(null)
const dragActive = ref(false)
// 试用期过期弹框
const showTrialExpiredDialog = ref(false)
@@ -88,17 +88,12 @@ async function handleExcelUpload(e: Event) {
input.value = ''
}
function onDragOver(e: DragEvent) { e.preventDefault(); dragActive.value = true }
function onDragLeave() { dragActive.value = false }
async function onDrop(e: DragEvent) {
e.preventDefault()
dragActive.value = false
const file = e.dataTransfer?.files?.[0]
if (!file) return
const ok = /\.xlsx?$/i.test(file.name)
if (!ok) return showMessage('仅支持 .xls/.xlsx 文件', 'warning')
await processExcelFile(file)
}
// 拖拽上传
const { dragActive, onDragEnter, onDragOver, onDragLeave, onDrop } = useFileDrop({
accept: /\.xlsx?$/i,
onFile: processExcelFile,
onError: (msg) => showMessage(msg, 'warning')
})
// 批量获取产品信息 - 核心数据处理逻辑
async function batchGetProductInfo(asinList: string[]) {
@@ -347,7 +342,7 @@ onMounted(async () => {
<span class="sep">|</span>
<a class="link" @click.prevent="downloadAmazonTemplate">点击下载模板</a>
</div>
<div class="dropzone" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openAmazonUpload">
<div class="dropzone" :class="{ active: dragActive }" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openAmazonUpload">
<div class="dz-el-icon">📤</div>
<div class="dz-text">点击或将文件拖拽到这里上传</div>
<div class="dz-sub">支持 .xls .xlsx</div>

View File

@@ -13,6 +13,7 @@ interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string; accountType?: string; deviceTrialExpired?: boolean }): void
(e: 'showRegister'): void
(e: 'deviceConflict', username: string): void
}
const props = defineProps<Props>()
@@ -49,8 +50,14 @@ async function handleAuth() {
})
ElMessage.success('登录成功')
resetForm()
} catch (err) {
ElMessage.error((err as Error).message)
} catch (err: any) {
// 设备冲突/数量达上限:触发设备管理
if (err.code === 501 ) {
emit('deviceConflict', authForm.value.username)
resetForm()
} else {
ElMessage.error(err.message || '登录失败')
}
} finally {
authLoading.value = false
}

View File

@@ -18,11 +18,17 @@ const PLATFORM_LABEL: Record<PlatformKey, string> = {
}
const accounts = ref<BanmaAccount[]>([])
const accountLimit = ref({ limit: 1, count: 0 })
async function load() {
const username = getUsernameFromToken()
const res = await zebraApi.getAccounts(username)
const [res, limitRes] = await Promise.all([
zebraApi.getAccounts(username),
zebraApi.getAccountLimit(username)
])
const list = (res as any)?.data ?? res
accounts.value = Array.isArray(list) ? list : []
const limitData = (limitRes as any)?.data ?? limitRes
accountLimit.value = { limit: limitData?.limit ?? 1, count: limitData?.count ?? 0 }
}
// 暴露方法供父组件调用
@@ -74,10 +80,10 @@ export default defineComponent({ name: 'AccountManager' })
<div class="top">
<img src="/icon/image.png" class="hero" alt="logo" />
<div class="head-main">
<div class="main-title">在线账号管理3/3</div>
<div class="main-title">在线账号管理{{ accountLimit.count }}/{{ accountLimit.limit }}</div>
<div class="main-sub">
您当前订阅可同时托管3家 Shopee 店铺<br>
如需扩增同时托管店铺数 <span class="upgrade">升级订阅</span>
您当前订阅可同时托管{{ accountLimit.limit }}个斑马账号<br>
<span v-if="accountLimit.limit < 3">如需扩增账号数量,请 <span class="upgrade">升级订阅</span></span>
</div>
</div>
</div>

View File

@@ -281,7 +281,7 @@ async function loadLogDates() {
async function loadCloseAction() {
try {
const action = await (window as any).electronAPI.getCloseAction()
closeAction.value = action || 'tray'
if (action) closeAction.value = action
} catch (error) {
console.warn('获取关闭行为配置失败:', error)
}

View File

@@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import {rakutenApi} from '../../api/rakuten'
import { batchConvertImages } from '../../utils/imageProxy'
import { handlePlatformFileExport } from '../../utils/settings'
import { useFileDrop } from '../../composables/useFileDrop'
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
@@ -26,7 +27,6 @@ let abortController: AbortController | null = null
const singleShopName = ref('')
const currentBatchId = ref('')
const uploadInputRef = ref<HTMLInputElement | null>(null)
const dragActive = ref(false)
// 数据与分页
const allProducts = ref<any[]>([])
@@ -223,15 +223,12 @@ async function handleExcelUpload(e: Event) {
input.value = ''
}
function onDragOver(e: DragEvent) { e.preventDefault(); dragActive.value = true }
function onDragLeave() { dragActive.value = false }
async function onDrop(e: DragEvent) {
e.preventDefault()
dragActive.value = false
const file = e.dataTransfer?.files?.[0]
if (!file) return
await processFile(file)
}
// 拖拽上传
const { dragActive, onDragEnter, onDragOver, onDragLeave, onDrop } = useFileDrop({
accept: /\.xlsx?$/i,
onFile: processFile,
onError: (msg) => ElMessage({ message: msg, type: 'warning' })
})
// 点击"获取数据
@@ -358,7 +355,6 @@ function delay(ms: number) {
}
function nextTickSafe() {
// 不额外引入 nextTick使用微任务刷新即可保持体积精简
return Promise.resolve()
}
@@ -424,12 +420,10 @@ async function exportToExcel() {
base64: base64Data,
extension: 'jpeg',
})
worksheet.addImage(imageId, {
tl: { col: 1, row: row.number - 1 },
ext: { width: 60, height: 60 }
})
row.height = 50
}
}
@@ -440,9 +434,7 @@ async function exportToExcel() {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const fileName = `乐天商品数据_${new Date().toISOString().slice(0, 10)}.xlsx`
const success = await handlePlatformFileExport('rakuten', blob, fileName)
if (success) {
showMessage('Excel文件导出成功', 'success')
}
@@ -458,7 +450,6 @@ onMounted(loadLatest)
</script>
<template>
<div class="rakuten-root">
<div class="main-container">
<div class="body-layout">
<!-- 左侧步骤栏 -->
@@ -480,7 +471,7 @@ onMounted(loadLatest)
<a class="link" @click.prevent="downloadRakutenTemplate">点击下载模板</a>
</div>
<div class="dropzone" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openRakutenUpload" :class="{ disabled: loading }">
<div class="dropzone" :class="{ disabled: loading, active: dragActive }" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openRakutenUpload">
<div class="dz-el-icon">📤</div>
<div class="dz-text">点击或将文件拖拽到这里上传</div>
<div class="dz-sub">支持 .xls .xlsx</div>
@@ -532,12 +523,8 @@ onMounted(loadLatest)
</div>
<div class="desc">点击下方按钮导出所有商品数据到 Excel 文件</div>
<el-button size="small" class="w100 btn-blue" :disabled="!allProducts.length || loading || exportLoading" :loading="exportLoading" @click="exportToExcel">{{ exportLoading ? '导出中...' : '导出数据' }}</el-button>
<!-- 导出进度条 -->
</div>
</div>
</div>
</aside>

View File

@@ -289,7 +289,19 @@ const rememberPwd = ref(true)
const managerVisible = ref(false)
const accountManagerRef = ref()
function openAddAccount() {
async function openAddAccount() {
try {
const username = getUsernameFromToken()
const limitRes = await zebraApi.getAccountLimit(username)
const limitData = (limitRes as any)?.data ?? limitRes
const { limit = 1, count = 0 } = limitData
if (count >= limit) {
ElMessage({ message: `账号数量已达上限(${limit}个)${limit < 3 ? '请升级订阅或' : ''}请先删除其他账号`, type: 'warning' })
return
}
} catch (e) {
console.error('检查账号限制失败:', e)
}
isEditMode.value = false
accountForm.value = { name: '', username: '', isDefault: 0, status: 1 }
formUsername.value = ''

View File

@@ -0,0 +1,64 @@
import { ref } from 'vue'
export interface UseFileDropOptions {
accept?: RegExp
onFile: (file: File) => void | Promise<void>
onError?: (message: string) => void
}
export function useFileDrop(options: UseFileDropOptions) {
const { accept, onFile, onError } = options
const dragActive = ref(false)
let dragCounter = 0
const onDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter++
dragActive.value = true
}
const onDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy'
}
}
const onDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter--
if (dragCounter === 0) {
dragActive.value = false
}
}
const onDrop = async (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter = 0
dragActive.value = false
const file = e.dataTransfer?.files?.[0]
if (!file) return
if (accept && !accept.test(file.name)) {
const fileTypes = accept.source.includes('xlsx?') ? '.xls 或 .xlsx' : accept.source
onError?.(`仅支持 ${fileTypes} 文件`)
return
}
await onFile(file)
}
return {
dragActive,
onDragEnter,
onDragOver,
onDragLeave,
onDrop
}
}