feat(device): 实现设备与账号绑定管理机制
- 引入 ClientAccountDevice 表管理设备与账号绑定关系 - 重构设备注册逻辑,支持多账号绑定同一设备 - 新增设备配额检查,基于账号维度限制设备数量 -优化设备移除逻辑,仅解除绑定而非物理删除- 改进设备列表查询,通过账号ID关联获取设备信息 - 更新心跳任务,支持向设备绑定的所有账号发送心跳 - 调整设备API参数,增加username字段用于权限校验 -修复HTTP请求编码问题,统一使用UTF-8字符集 - 增强错误处理,携带错误码信息便于前端识别 - 移除设备表中的username字段,解耦设备与用户名关联
This commit is contained in:
@@ -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;
|
||||
});
|
||||
|
||||
// 保存关闭行为配置
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user