This commit is contained in:
2025-10-10 10:06:56 +08:00
parent 4fbe51d625
commit 6f22c9bffd
37 changed files with 2176 additions and 1183 deletions

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2021 Deluze
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -16,7 +16,7 @@
},
"publish": {
"provider": "generic",
"url": "http://192.168.1.89:8080/static/updates/"
"url": "http://192.168.1.89:8085/static/updates/"
},
"nsis": {
"oneClick": false,

File diff suppressed because it is too large Load Diff

View File

@@ -311,9 +311,9 @@ app.whenReady().then(() => {
splashWindow.loadFile(splashPath);
}
setTimeout(() => {
openAppIfNotOpened();
}, 2000);
// setTimeout(() => {
// openAppIfNotOpened();
// }, 2000);
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {

View File

@@ -5,6 +5,7 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import {authApi} from './api/auth'
import {deviceApi, type DeviceItem, type DeviceQuota} from './api/device'
import {getOrCreateDeviceId} from './utils/deviceId'
const LoginDialog = defineAsyncComponent(() => import('./components/auth/LoginDialog.vue'))
const RegisterDialog = defineAsyncComponent(() => import('./components/auth/RegisterDialog.vue'))
const NavigationBar = defineAsyncComponent(() => import('./components/layout/NavigationBar.vue'))
@@ -46,6 +47,19 @@ const devices = ref<DeviceItem[]>([])
const deviceQuota = ref<DeviceQuota>({limit: 0, used: 0})
const userPermissions = ref<string>('')
// VIP状态
const vipExpireTime = ref<Date | null>(null)
const vipStatus = computed(() => {
if (!vipExpireTime.value) return { isVip: false, daysLeft: 0, status: 'expired' }
const now = new Date()
const expire = new Date(vipExpireTime.value)
const daysLeft = Math.ceil((expire.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (daysLeft <= 0) return { isVip: false, daysLeft: 0, status: 'expired' }
if (daysLeft <= 7) return { isVip: true, daysLeft, status: 'warning' }
if (daysLeft <= 30) return { isVip: true, daysLeft, status: 'normal' }
return { isVip: true, daysLeft, status: 'active' }
})
// 更新对话框状态
const showUpdateDialog = ref(false)
@@ -131,7 +145,7 @@ function handleMenuSelect(key: string) {
addToHistory(key)
}
async function handleLoginSuccess(data: { token: string; permissions?: string }) {
async function handleLoginSuccess(data: { token: string; permissions?: string; expireTime?: string }) {
isAuthenticated.value = true
showAuthDialog.value = false
showRegDialog.value = false
@@ -141,7 +155,15 @@ async function handleLoginSuccess(data: { token: string; permissions?: string })
const username = getUsernameFromToken(data.token)
currentUsername.value = username
userPermissions.value = data?.permissions || ''
await deviceApi.register({username})
vipExpireTime.value = data?.expireTime ? new Date(data.expireTime) : null
// 获取设备ID并注册设备
const deviceId = await getOrCreateDeviceId()
await deviceApi.register({
username,
deviceId,
os: navigator.platform
})
SSEManager.connect()
} catch (e: any) {
isAuthenticated.value = false
@@ -169,6 +191,7 @@ async function logout() {
isAuthenticated.value = false
currentUsername.value = ''
userPermissions.value = ''
vipExpireTime.value = null
showAuthDialog.value = true
showDeviceDialog.value = false
SSEManager.disconnect()
@@ -207,9 +230,15 @@ async function checkAuth() {
const token = typeof tokenRes === 'string' ? tokenRes : tokenRes?.data
if (token) {
await authApi.verifyToken(token)
const verifyRes: any = await authApi.verifyToken(token)
isAuthenticated.value = true
currentUsername.value = getUsernameFromToken(token) || ''
userPermissions.value = verifyRes?.data?.permissions || verifyRes?.permissions || ''
if (verifyRes?.data?.expireTime || verifyRes?.expireTime) {
vipExpireTime.value = new Date(verifyRes?.data?.expireTime || verifyRes?.expireTime)
}
SSEManager.connect()
return
}
@@ -266,7 +295,7 @@ const SSEManager = {
return
}
let sseUrl = 'http://192.168.1.89:8080/monitor/account/events'
let sseUrl = 'http://192.168.1.89:8085/monitor/account/events'
try {
const resp = await fetch('/api/config/server')
if (resp.ok) sseUrl = (await resp.json()).sseUrl || sseUrl
@@ -304,6 +333,14 @@ const SSEManager = {
case 'PERMISSIONS_UPDATED':
checkAuth()
break
case 'ACCOUNT_EXPIRED':
vipExpireTime.value = null
ElMessage.warning('您的VIP已过期部分功能将受限')
break
case 'VIP_RENEWED':
checkAuth()
ElMessage.success('VIP已续费成功')
break
}
} catch (err) {
console.error('SSE消息处理失败:', err, '原始数据:', e.data)
@@ -423,6 +460,25 @@ onUnmounted(() => {
</li>
</ul>
<!-- VIP状态卡片 -->
<div v-if="isAuthenticated" class="vip-status-card" :class="'vip-' + vipStatus.status">
<div class="vip-info">
<div class="vip-status-text">
<template v-if="vipStatus.isVip">
<span v-if="vipStatus.status === 'warning'">即将到期</span>
<span v-else>订阅中</span>
</template>
<template v-else>
<span>已过期</span>
</template>
</div>
<div class="vip-expire-date" v-if="vipExpireTime">
有效期至{{ vipExpireTime ? new Date(vipExpireTime).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }) : '-' }}
</div>
</div>
</div>
</div>
<div class="main-content">
@@ -445,7 +501,7 @@ onUnmounted(() => {
</div>
</div>
<keep-alive v-if="activeDashboard">
<component :is="activeDashboard" :key="activeMenu"/>
<component :is="activeDashboard" :key="activeMenu" :is-vip="vipStatus.isVip"/>
</keep-alive>
<div v-if="showPlaceholder" class="placeholder">
<div class="placeholder-card">
@@ -580,6 +636,8 @@ onUnmounted(() => {
border-right: 1px solid #e8eaec;
padding: 16px 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.platform-icons {
@@ -789,4 +847,104 @@ onUnmounted(() => {
cursor: pointer;
user-select: none;
}
/* VIP状态卡片样式 */
.vip-status-card {
margin-top: auto;
width: 100%;
min-height: 64px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
box-sizing: border-box;
background: linear-gradient(135deg, #FFFAF0 0%, #FFE4B5 50%, #FFD700 100%);
box-shadow: 0 2px 8px rgba(255, 215, 0, 0.15);
transition: all 0.3s ease;
position: relative;
}
/* 正常状态和警告状态 - 统一温暖金色渐变 */
.vip-status-card.vip-active,
.vip-status-card.vip-normal,
.vip-status-card.vip-warning {
background: linear-gradient(135deg, #FFFAF0 0%, #FFE4B5 50%, #FFD700 100%);
box-shadow: 0 2px 8px rgba(255, 215, 0, 0.15);
}
/* 过期状态 - 灰色,垂直布局 */
.vip-status-card.vip-expired {
background: linear-gradient(135deg, #FAFAFA 0%, #E8E8E8 100%);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
flex-direction: column;
justify-content: space-between;
align-items: stretch;
padding: 10px;
gap: 8px;
}
.vip-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
text-align: left;
}
.vip-status-text {
font-size: 13px;
font-weight: 600;
color: #8B6914;
text-align: left;
letter-spacing: 0.3px;
}
.vip-expire-date {
font-size: 10px;
color: #A67C00;
line-height: 1.3;
text-align: left;
opacity: 0.9;
}
/* 右侧徽章按钮 */
.vip-badge {
padding: 5px 10px;
border-radius: 5px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
background: linear-gradient(135deg, #4A90E2 0%, #357ABD 100%);
color: white;
box-shadow: 0 2px 6px rgba(74, 144, 226, 0.3);
}
/* 过期状态续费按钮 - 置底 */
.vip-renew-btn {
padding: 7px 0;
text-align: center;
background: linear-gradient(135deg, #4A90E2 0%, #357ABD 100%);
color: white;
border-radius: 5px;
font-size: 11px;
font-weight: 600;
width: 100%;
flex-shrink: 0;
box-shadow: 0 2px 6px rgba(74, 144, 226, 0.3);
}
.vip-status-card.vip-expired .vip-info {
align-items: flex-start;
}
.vip-status-card.vip-expired .vip-status-text {
color: #909399;
}
.vip-status-card.vip-expired .vip-expire-date {
color: #B0B0B0;
}
</style>

View File

@@ -2,25 +2,31 @@ import { http } from './http'
export const authApi = {
login(params: { username: string; password: string }) {
return http.post('/api/login', params)
// 直接调用 RuoYi 后端的登录接口
return http.post('/monitor/account/login', params)
},
register(params: { username: string; password: string }) {
return http.post('/api/register', params)
// 直接调用 RuoYi 后端的注册接口
return http.post('/monitor/account/register', params)
},
checkUsername(username: string) {
return http.get('/api/check-username', { username })
// 直接调用 RuoYi 后端的用户名检查接口
return http.get('/monitor/account/check-username', { username })
},
verifyToken(token: string) {
return http.post('/api/verify', { token })
// 直接调用 RuoYi 后端的验证接口
return http.post('/monitor/account/verify', { token })
},
logout(token: string) {
// 保留客户端的 logout用于清理本地状态
return http.postVoid('/api/logout', { token })
},
// 以下缓存相关接口仍使用客户端服务(用于本地 SQLite 存储)
deleteTokenCache() {
return http.postVoid('/api/cache/delete?key=token')
},

View File

@@ -2,26 +2,32 @@ import { http } from './http'
export const deviceApi = {
getQuota(username: string) {
return http.get('/api/device/quota', { username })
// 直接调用 RuoYi 后端的设备配额接口
return http.get('/monitor/device/quota', { username })
},
list(username: string) {
return http.get('/api/device/list', { username })
// 直接调用 RuoYi 后端的设备列表接口
return http.get('/monitor/device/list', { username })
},
register(payload: { username: string }) {
return http.post('/api/device/register', payload)
// 直接调用 RuoYi 后端的设备注册接口
return http.post('/monitor/device/register', payload)
},
remove(payload: { deviceId: string }) {
return http.post('/api/device/remove', payload)
// 直接调用 RuoYi 后端的设备移除接口
return http.post('/monitor/device/remove', payload)
},
heartbeat(payload: { username: string; deviceId: string; version?: string }) {
return http.post('/api/device/heartbeat', payload)
// 直接调用 RuoYi 后端的心跳接口
return http.post('/monitor/device/heartbeat', payload)
},
offline(payload: { deviceId: string }) {
return http.post('/api/device/offline', payload)
// 直接调用 RuoYi 后端的离线接口
return http.post('/monitor/device/offline', payload)
}
}

View File

@@ -2,10 +2,12 @@
export type HttpMethod = 'GET' | 'POST';
const BASE_CLIENT = 'http://localhost:8081'; // erp_client_sb
const BASE_RUOYI = 'http://192.168.1.89:8080';
const BASE_RUOYI = 'http://192.168.1.89:8085';
function resolveBase(path: string): string {
// 走 ruoyi-admin 的路径:鉴权版本、平台工具路由
// 走 ruoyi-admin 的路径:鉴权、设备管理、版本、平台工具路由
if (path.startsWith('/monitor/account')) return BASE_RUOYI; // 账号认证相关
if (path.startsWith('/monitor/device')) return BASE_RUOYI; // 设备管理
if (path.startsWith('/system/')) return BASE_RUOYI; // 版本控制器 VersionController
if (path.startsWith('/tool/banma')) return BASE_RUOYI; // 既有规则保留
// 其他默认走客户端服务
@@ -41,7 +43,12 @@ async function request<T>(path: string, options: RequestInit): Promise<T> {
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
const json: any = await res.json();
// 检查业务状态码
if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
throw new Error(json.msg || json.message || '请求失败');
}
return json as T;
}
return (await res.text()) as unknown as T;
}

View File

@@ -1,9 +1,14 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { amazonApi } from '../../api/amazon'
import { handlePlatformFileExport } from '../../utils/settings'
// 接收VIP状态
const props = defineProps<{
isVip: boolean
}>()
// 响应式状态
const loading = ref(false) // 主加载状态
const tableLoading = ref(false) // 表格加载状态
@@ -85,6 +90,22 @@ async function onDrop(e: DragEvent) {
// 批量获取产品信息 - 核心数据处理逻辑
async function batchGetProductInfo(asinList: string[]) {
// VIP检查
if (!props.isVip) {
try {
await ElMessageBox.confirm(
'VIP已过期数据采集功能受限。请联系管理员续费后继续使用。',
'VIP功能限制',
{
confirmButtonText: '我知道了',
showCancelButton: false,
type: 'warning'
}
)
} catch {}
return
}
try {
currentAsin.value = '正在处理...'
progressPercentage.value = 0
@@ -165,8 +186,6 @@ async function startQueuedFetch() {
// 导出Excel数据
const exportLoading = ref(false)
const exportProgress = ref(0)
const showExportProgress = ref(false)
async function exportToExcel() {
if (!localProductData.value.length) {
@@ -175,12 +194,6 @@ async function exportToExcel() {
}
exportLoading.value = true
showExportProgress.value = true
exportProgress.value = 0
const progressInterval = setInterval(() => {
if (exportProgress.value < 90) exportProgress.value += Math.random() * 20
}, 100)
// 生成Excel HTML格式
let html = `<table>
@@ -198,17 +211,12 @@ async function exportToExcel() {
const blob = new Blob([html], { type: 'application/vnd.ms-excel' })
const fileName = `Amazon产品数据_${new Date().toISOString().slice(0, 10)}.xls`
await handlePlatformFileExport('amazon', blob, fileName)
const success = await handlePlatformFileExport('amazon', blob, fileName)
clearInterval(progressInterval)
exportProgress.value = 100
if (success) {
showMessage('Excel文件导出成功', 'success')
setTimeout(() => {
showExportProgress.value = false
}
exportLoading.value = false
exportProgress.value = 0
}, 2000)
}
// 获取卖家/配送方信息 - 数据处理辅助函数
@@ -360,13 +368,6 @@ onMounted(async () => {
<div class="step-header"><div class="title">导出数据</div></div>
<div class="action-buttons column">
<el-button size="small" class="w100 btn-blue" :disabled="!localProductData.length || loading || exportLoading" :loading="exportLoading" @click="exportToExcel">{{ exportLoading ? '导出中...' : '导出Excel' }}</el-button>
<!-- 导出进度条 -->
<div v-if="showExportProgress" class="export-progress">
<div class="export-progress-bar">
<div class="export-progress-fill" :style="{ width: exportProgress + '%' }"></div>
</div>
<div class="export-progress-text">{{ Math.round(exportProgress) }}%</div>
</div>
</div>
</div>
</div>
@@ -529,10 +530,6 @@ onMounted(async () => {
.progress-fill { height: 100%; background: #1677FF; border-radius: 999px; transition: width 0.3s ease; }
.progress-text { font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; text-align: right; }
.current-status { font-size: 12px; color: #606266; padding-left: 2px; }
.export-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 0 4px; }
.export-progress-bar { flex: 1; height: 4px; background: #e3eeff; border-radius: 2px; overflow: hidden; }
.export-progress-fill { height: 100%; background: #1677FF; border-radius: 2px; transition: width 0.3s ease; }
.export-progress-text { font-size: 11px; color: #1677FF; font-weight: 500; min-width: 32px; text-align: right; }
.table-container { display: flex; flex-direction: column; flex: 1; min-height: 400px; overflow: hidden; }
.table-section { flex: 1; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; display: flex; flex-direction: column; }
.table-wrapper { flex: 1; overflow: auto; }

View File

@@ -4,6 +4,7 @@ import { ElMessage } from 'element-plus'
import { User } from '@element-plus/icons-vue'
import { authApi } from '../../api/auth'
import { deviceApi } from '../../api/device'
import { getOrCreateDeviceId } from '../../utils/deviceId'
interface Props {
modelValue: boolean
@@ -11,7 +12,7 @@ interface Props {
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'loginSuccess', data: { token: string; user: any }): void
(e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string }): void
(e: 'showRegister'): void
}
@@ -31,16 +32,26 @@ async function handleAuth() {
authLoading.value = true
try {
await deviceApi.register({ username: authForm.value.username })
const loginRes: any = await authApi.login(authForm.value)
const data = loginRes?.data || loginRes
// 获取或生成设备ID
const deviceId = await getOrCreateDeviceId()
// 注册设备
await deviceApi.register({
username: authForm.value.username,
deviceId: deviceId,
os: navigator.platform
})
// 登录
const loginRes: any = await authApi.login({
...authForm.value,
clientId: deviceId
})
emit('loginSuccess', {
token: data.token,
user: {
username: data.username,
permissions: data.permissions
}
token: loginRes.data.accessToken || loginRes.data.token,
permissions: loginRes.data.permissions,
expireTime: loginRes.data.expireTime
})
ElMessage.success('登录成功')
resetForm()

View File

@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { User } from '@element-plus/icons-vue'
import { authApi } from '../../api/auth'
import { getOrCreateDeviceId } from '../../utils/deviceId'
interface Props {
modelValue: boolean
@@ -10,7 +11,7 @@ interface Props {
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'loginSuccess', data: { token: string; user: any }): void
(e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string }): void
(e: 'backToLogin'): void
}
@@ -42,8 +43,8 @@ async function checkUsernameAvailability() {
try {
const res: any = await authApi.checkUsername(registerForm.value.username)
const data = res?.data || res
usernameCheckResult.value = data?.available || false
// 后端返回 {code: 200, data: true/false}data 直接是布尔值
usernameCheckResult.value = res.data
} catch {
usernameCheckResult.value = null
}
@@ -54,23 +55,34 @@ async function handleRegister() {
registerLoading.value = true
try {
await authApi.register({
// 获取设备ID
const deviceId = await getOrCreateDeviceId()
// 注册账号传递设备ID用于判断是否赠送VIP
const registerRes: any = await authApi.register({
username: registerForm.value.username,
password: registerForm.value.password
password: registerForm.value.password,
deviceId: deviceId
})
const loginRes: any = await authApi.login({
username: registerForm.value.username,
password: registerForm.value.password
})
const loginData = loginRes?.data || loginRes
// 显示注册成功和VIP信息
if (registerRes.data.expireTime) {
const expireDate = new Date(registerRes.data.expireTime)
const now = new Date()
const daysLeft = Math.ceil((expireDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
emit('loginSuccess', {
token: loginData.token,
user: {
username: loginData.username,
permissions: loginData.permissions
if (daysLeft > 0) {
ElMessage.success(`注册成功!您获得了 ${daysLeft} 天VIP体验`)
} else {
ElMessage.warning('注册成功!该设备已使用过新人福利,请联系管理员续费')
}
}
// 使用注册返回的token直接登录
emit('loginSuccess', {
token: registerRes.data.accessToken || registerRes.data.token,
permissions: registerRes.data.permissions,
expireTime: registerRes.data.expireTime
})
resetForm()
} catch (err) {

View File

@@ -117,14 +117,16 @@ const info = ref({
const SKIP_VERSION_KEY = 'skipped_version'
const REMIND_LATER_KEY = 'remind_later_time'
async function autoCheck() {
async function autoCheck(silent = false) {
try {
version.value = await (window as any).electronAPI.getJarVersion()
const checkRes: any = await updateApi.checkUpdate(version.value)
const result = checkRes?.data || checkRes
if (!result.needUpdate) {
if (!silent) {
ElMessage.info('当前已是最新版本')
}
return
}
@@ -149,11 +151,15 @@ async function autoCheck() {
}
show.value = true
stage.value = 'check'
if (!silent) {
ElMessage.success('发现新版本')
}
} catch (error) {
console.error('检查更新失败:', error)
if (!silent) {
ElMessage.error('检查更新失败')
}
}
}
function skipVersion() {
@@ -239,6 +245,7 @@ async function installUpdate() {
onMounted(async () => {
version.value = await (window as any).electronAPI.getJarVersion()
await autoCheck(true)
})
onUnmounted(() => {

View File

@@ -1,10 +1,15 @@
<script setup lang="ts">
import {ref, computed, onMounted} from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import {rakutenApi} from '../../api/rakuten'
import { batchConvertImages } from '../../utils/imageProxy'
import { handlePlatformFileExport } from '../../utils/settings'
// 接收VIP状态
const props = defineProps<{
isVip: boolean
}>()
// UI 与加载状态
const loading = ref(false)
const tableLoading = ref(false)
@@ -114,17 +119,22 @@ function needsSearch(product: any) {
}
async function loadLatest() {
const resp = await rakutenApi.getLatestProducts()
allProducts.value = (resp.products || []).map(p => ({...p, skuPrices: parseSkuPrices(p)}))
const resp: any = await rakutenApi.getLatestProducts()
const products = resp.data.products || []
allProducts.value = products.map((p: any) => ({...p, skuPrices: parseSkuPrices(p)}))
}
async function searchProductInternal(product: any) {
if (!product || !product.imgUrl) return
if (!needsSearch(product)) return
const res = await rakutenApi.search1688(product.imgUrl, currentBatchId.value)
const data = res
const skuJson = (data as any)?.skuPriceJson ?? (data as any)?.skuPrice
if (!props.isVip) {
ElMessage.warning('VIP已过期1688识图功能受限')
return
}
const res: any = await rakutenApi.search1688(product.imgUrl, currentBatchId.value)
const data = res.data
const skuJson = data.skuPriceJson || data.skuPrice
Object.assign(product, {
mapRecognitionLink: data.mapRecognitionLink,
freight: data.freight,
@@ -186,8 +196,24 @@ async function onDrop(e: DragEvent) {
}
// 点击获取数据
// 点击"获取数据
async function handleStartSearch() {
// VIP检查
if (!props.isVip) {
try {
await ElMessageBox.confirm(
'VIP已过期数据采集功能受限。请联系管理员续费后继续使用。',
'VIP功能限制',
{
confirmButtonText: '我知道了',
showCancelButton: false,
type: 'warning'
}
)
} catch {}
return
}
if (pendingFile.value) {
try {
loading.value = true
@@ -199,8 +225,8 @@ async function handleStartSearch() {
progressPercentage.value = 0
totalProducts.value = 0
processedProducts.value = 0
const resp = await rakutenApi.getProducts({file: pendingFile.value, batchId: currentBatchId.value})
const products = (resp.products || []).map(p => ({...p, skuPrices: parseSkuPrices(p)}))
const resp: any = await rakutenApi.getProducts({file: pendingFile.value, batchId: currentBatchId.value})
const products = (resp.data.products || []).map((p: any) => ({...p, skuPrices: parseSkuPrices(p)}))
if (products.length === 0) {
showMessage('未采集到数据,请检查代理或店铺是否存在', 'warning')
@@ -373,9 +399,11 @@ async function exportToExcel() {
})
const fileName = `乐天商品数据_${new Date().toISOString().slice(0, 10)}.xlsx`
await handlePlatformFileExport('rakuten', blob, fileName)
const success = await handlePlatformFileExport('rakuten', blob, fileName)
if (success) {
showMessage('Excel文件导出成功', 'success')
}
} catch (error) {
showMessage('导出失败', 'error')
} finally {

View File

@@ -6,6 +6,11 @@ import AccountManager from '../common/AccountManager.vue'
import { batchConvertImages } from '../../utils/imageProxy'
import { handlePlatformFileExport } from '../../utils/settings'
// 接收VIP状态
const props = defineProps<{
isVip: boolean
}>()
type Shop = { id: string; shopName: string }
const accounts = ref<BanmaAccount[]>([])
@@ -86,6 +91,22 @@ function handleCurrentChange(page: number) {
async function fetchData() {
if (isFetching.value) return
// VIP检查
if (!props.isVip) {
try {
await ElMessageBox.confirm(
'VIP已过期数据采集功能受限。请联系管理员续费后继续使用。',
'VIP功能限制',
{
confirmButtonText: '我知道了',
showCancelButton: false,
type: 'warning'
}
)
} catch {}
return
}
loading.value = true
isFetching.value = true
showProgress.value = true
@@ -237,9 +258,11 @@ async function exportToExcel() {
})
const fileName = `斑马订单数据_${new Date().toISOString().slice(0, 10)}.xlsx`
await handlePlatformFileExport('zebra', blob, fileName)
const success = await handlePlatformFileExport('zebra', blob, fileName)
if (success) {
showMessage('Excel文件导出成功', 'success')
}
} catch (error) {
showMessage('导出失败', 'error')
} finally {

View File

@@ -0,0 +1,70 @@
/**
* 设备ID管理工具
* 从客户端服务获取硬件UUID通过 wmic 命令)
*/
const BASE_CLIENT = 'http://localhost:8081'
/**
* 从客户端服务获取硬件设备ID
* 客户端会使用 wmic 命令获取硬件UUID仅Windows
*/
async function fetchDeviceIdFromClient(): Promise<string> {
const response = await fetch(`${BASE_CLIENT}/api/device-id`, {
method: 'GET',
credentials: 'omit',
cache: 'no-store'
})
if (!response.ok) {
throw new Error('获取设备ID失败')
}
const result = await response.json()
const deviceId = result?.data
if (!deviceId) {
throw new Error('设备ID为空')
}
return deviceId
}
/**
* 获取或创建设备ID
* 1. 优先从本地缓存读取
* 2. 如果没有缓存从客户端服务获取使用硬件UUID
* 3. 保存到本地缓存
*/
export async function getOrCreateDeviceId(): Promise<string> {
try {
// 尝试从本地缓存获取
const response = await fetch(`${BASE_CLIENT}/api/cache/get?key=deviceId`)
if (response.ok) {
const result = await response.json()
const cachedDeviceId = result?.data
if (cachedDeviceId) {
return cachedDeviceId
}
}
} catch (error) {
console.warn('从缓存读取设备ID失败:', error)
}
// 从客户端服务获取新的设备ID硬件UUID
const newDeviceId = await fetchDeviceIdFromClient()
// 保存到本地缓存
try {
await fetch(`${BASE_CLIENT}/api/cache/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'deviceId', value: newDeviceId })
})
} catch (error) {
console.warn('保存设备ID到缓存失败:', error)
}
return newDeviceId
}

View File

@@ -90,7 +90,7 @@ export async function handlePlatformFileExport(
platform: Platform,
blob: Blob,
defaultFileName: string
): Promise<void> {
): Promise<boolean> {
const config = getPlatformExportConfig(platform)
if (!config.exportPath) {
@@ -105,10 +105,13 @@ export async function handlePlatformFileExport(
if (!result.canceled && result.filePath) {
await writeFileToPath(blob, result.filePath)
return true
}
return false
} else {
const filePath = `${config.exportPath}/${defaultFileName}`
await writeFileToPath(blob, filePath)
return true
}
}

View File

@@ -10,7 +10,7 @@
</parent>
<groupId>com.tashow.erp</groupId>
<artifactId>erp_client_sb</artifactId>
<version>2.4.8</version>
<version>2.4.7</version>
<name>erp_client_sb</name>
<description>erp客户端</description>
<properties>

View File

@@ -12,77 +12,30 @@ import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Optional;
/**
* 客户端本地服务控制器
*/
@RestController
@RequestMapping("/api")
public class AuthController {
@Autowired
private IAuthService authService;
@Autowired
private AuthTokenRepository authTokenRepository;
@Autowired
private CacheDataRepository cacheDataRepository;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody Map<String, Object> loginData) {
String username = (String) loginData.get("username");
String password = (String) loginData.get("password");
Map<String, Object> result = authService.login(username, password);
Object success = result.get("success");
Object tokenObj = result.get("token");
if (Boolean.TRUE.equals(success) && tokenObj instanceof String token && token != null && !token.isEmpty()) {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.SET_COOKIE, buildHttpOnlyCookie("FX_TOKEN", token, 2 * 24 * 60 * 60));
return ResponseEntity.ok().headers(headers).body(result);
}
return ResponseEntity.ok(result);
}
@PostMapping("/verify")
public ResponseEntity<?> verifyToken(@RequestBody Map<String, Object> data) {
String token = (String) data.get("token");
if (token == null) {
return ResponseEntity.ok(Map.of("code", 400, "message", "token不能为空"));
}
Map<String, Object> result = authService.verifyToken(token);
return ResponseEntity.ok(result);
}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody Map<String, Object> registerData) {
String username = (String) registerData.get("username");
String password = (String) registerData.get("password");
if (username == null || password == null) {
return ResponseEntity.ok(Map.of("code", 400, "message", "用户名和密码不能为空"));
}
Map<String, Object> result = authService.register(username, password);
Object success2 = result.get("success");
Object tokenObj2 = result.get("token");
if (Boolean.TRUE.equals(success2) && tokenObj2 instanceof String token && token != null && !token.isEmpty()) {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.SET_COOKIE, buildHttpOnlyCookie("FX_TOKEN", token, 2 * 24 * 60 * 60));
return ResponseEntity.ok().headers(headers).body(result);
}
return ResponseEntity.ok(result);
}
/**
* 退出登录(清理本地状态)
*/
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestBody Map<String, Object> data) {
authService.logout();
// 清理本地缓存
try {
cacheDataRepository.deleteByCacheKey("token");
cacheDataRepository.deleteByCacheKey("deviceId");
} catch (Exception ignored) {}
return ResponseEntity.ok(Map.of("code", 0, "message", "退出成功"));
}
@GetMapping("/check-username")
public ResponseEntity<?> checkUsername(@RequestParam String username) {
if (username == null || username.trim().isEmpty()) {
return ResponseEntity.ok(Map.of("code", 400, "message", "用户名不能为空"));
}
boolean available = authService.checkUsername(username);
return ResponseEntity.ok(Map.of(
"code", 200,
"message", "检查成功",
"data", available
));
}
/**
* 保存认证密钥
*/
@@ -179,15 +132,12 @@ public class AuthController {
return JsonData.buildSuccess("会话已恢复");
}
private String buildHttpOnlyCookie(String name, String value, int maxAgeSeconds) {
StringBuilder sb = new StringBuilder();
sb.append(name).append("=").append(value).append(";");
sb.append(" Path=/;");
sb.append(" HttpOnly;");
sb.append(" SameSite=Strict;");
if (maxAgeSeconds > 0) {
sb.append(" Max-Age=").append(maxAgeSeconds).append(";");
}
return sb.toString();
/**
* 获取设备ID硬件UUID
*/
@GetMapping("/device-id")
public JsonData getDeviceId() {
String deviceId = com.tashow.erp.utils.DeviceUtils.generateDeviceId();
return JsonData.buildSuccess(deviceId);
}
}

View File

@@ -1,60 +0,0 @@
package com.tashow.erp.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import com.tashow.erp.utils.ApiForwarder;
import com.tashow.erp.utils.DeviceUtils;
import java.util.Map;
import java.util.HashMap;
/**
* 设备管理代理控制器
* 简化职责:透传请求到后端服务
*/
@RestController
public class DeviceProxyController {
/**
* 注册设备
*/
@Autowired
private ApiForwarder apiForwarder;
@PostMapping("/api/device/register")
public ResponseEntity<?> deviceRegister(@RequestBody Map<String, Object> body, @RequestHeader(value = "Authorization", required = false) String auth) {
Map<String, Object> deviceData = new HashMap<>(body);
deviceData.put("deviceId", DeviceUtils.generateDeviceId());
return apiForwarder.post("/monitor/device/register", deviceData, auth);
}
@PostMapping("/api/device/remove")
public ResponseEntity<?> deviceRemove(@RequestBody Map<String, Object> body, @RequestHeader(value = "Authorization", required = false) String auth) {
return apiForwarder.post("/monitor/device/remove", body, auth);
}
@PostMapping("/api/device/offline")
public ResponseEntity<?> deviceOffline(@RequestBody Map<String, Object> body, @RequestHeader(value = "Authorization", required = false) String auth) {
return apiForwarder.post("/monitor/device/offline", body, auth);
}
/**
* 设备心跳
*/
@PostMapping("/api/device/heartbeat")
public ResponseEntity<?> deviceHeartbeat(@RequestBody Map<String, Object> body, @RequestHeader(value = "Authorization", required = false) String auth) {
return apiForwarder.post("/monitor/device/heartbeat", body, auth);
}
@GetMapping("/api/device/quota")
public ResponseEntity<?> deviceQuota(@RequestParam("username") String username, @RequestHeader(value = "Authorization", required = false) String auth) {
return apiForwarder.get("/monitor/device/quota?username=" + username, auth);
}
@GetMapping("/api/device/list")
public ResponseEntity<?> deviceList(@RequestParam("username") String username, @RequestHeader(value = "Authorization", required = false) String auth) {
return apiForwarder.get("/monitor/device/list?username=" + username, auth);
}
}

View File

@@ -5,6 +5,7 @@ import com.tashow.erp.service.IAuthService;
import com.tashow.erp.utils.ApiForwarder;
import com.tashow.erp.utils.DeviceUtils;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.HashMap;
@@ -20,12 +21,11 @@ public class AuthServiceImpl implements IAuthService {
@Value("${project.version:2.1.0}")
private String appVersion;
private final ObjectMapper objectMapper = new ObjectMapper();
@org.springframework.beans.factory.annotation.Autowired
@Autowired
private ApiForwarder apiForwarder;
@org.springframework.beans.factory.annotation.Autowired
@Autowired
private com.tashow.erp.repository.CacheDataRepository cacheDataRepository;
@Getter
@@ -164,7 +164,6 @@ public class AuthServiceImpl implements IAuthService {
Map<String, Object> verifyData = new HashMap<>();
verifyData.put("token", token);
JsonNode response = sendPostRequest("/monitor/account/verify", verifyData);
if (response.has("code") && response.get("code").asInt() == 200) {
JsonNode dataNode = response.has("data") ? response.get("data") : response;
result.put("success", true);

View File

@@ -28,7 +28,7 @@ import java.util.stream.Collectors;
public class BanmaOrderServiceImpl implements IBanmaOrderService {
private static final Logger logger = LoggerUtil.getLogger(BanmaOrderServiceImpl.class);
private static final String SERVICE_NAME = "banma";
private static final String RUOYI_ADMIN_BASE = "http://127.0.0.1:8080";
private static final String RUOYI_ADMIN_BASE = "http://192.168.1.89:8085";
private static final String API_URL = "https://banma365.cn/api/order/list?%srecipientName=&page=%d&size=%d&markFlag=0&state=4&_t=%d";
private static final String API_URL_WITH_TIME = "https://banma365.cn/api/order/list?%srecipientName=&page=%d&size=%d&markFlag=0&state=4&orderedAtStart=%s&orderedAtEnd=%s&_t=%d";
private static final String TRACKING_URL = "https://banma365.cn/zebraExpressHub/web/tracking/getByExpressNumber/%s";

View File

@@ -28,6 +28,6 @@ public class DeviceUtils {
} catch (Exception e) {
// 静默处理异常
}
return UUID.randomUUID().toString();
return null;
}
}

View File

@@ -17,7 +17,7 @@ import com.tashow.erp.service.IAuthService;
@Component
public class ErrorReporter {
@Value("${server.monitor.url:http://localhost:8080}")
@Value("${api.server.base-url}")
private String serverUrl;
@Autowired

View File

@@ -43,7 +43,7 @@ api:
server:
# 主服务器API配置
# base-url: "http://8.138.23.49:8080"
base-url: "http://192.168.1.89:8080"
base-url: "http://192.168.1.89:8085"
paths:
monitor: "/monitor/client/api"
login: "/monitor/account/login"

View File

@@ -54,6 +54,8 @@ public class ClientAccountController extends BaseController {
private JwtRsaKeyService jwtRsaKeyService;
@Autowired
private SseHubService sseHubService;
@Autowired
private ClientDeviceMapper clientDeviceMapper;
/**
* 查询账号列表
@@ -137,9 +139,6 @@ public class ClientAccountController extends BaseController {
if (!"0".equals(account.getStatus())) {
return AjaxResult.error("账号已被停用");
}
if (account.getExpireTime() != null && account.getExpireTime().before(new Date())) {
return AjaxResult.error("账号已过期");
}
String clientId = loginData.get("clientId");
String accessToken = Jwts.builder()
.setHeaderParam("kid", jwtRsaKeyService.getKeyId())
@@ -151,7 +150,6 @@ public class ClientAccountController extends BaseController {
.claim("clientId", clientId)
.signWith(SignatureAlgorithm.RS256, jwtRsaKeyService.getPrivateKey())
.compact();
Map<String, Object> result = new HashMap<>();
result.put("accessToken", accessToken);
result.put("permissions", account.getPermissions());
@@ -181,6 +179,15 @@ public class ClientAccountController extends BaseController {
result.put("username", username);
result.put("permissions", account.getPermissions());
result.put("accountName", account.getAccountName());
result.put("expireTime", account.getExpireTime());
// 计算VIP状态
if (account.getExpireTime() != null) {
boolean isExpired = account.getExpireTime().before(new Date());
result.put("isVip", !isExpired);
} else {
result.put("isVip", false);
}
return AjaxResult.success("验证成功", result);
}
@@ -203,36 +210,53 @@ public class ClientAccountController extends BaseController {
/**
* 客户端账号注册
* 新设备注册送3天VIP同一设备ID重复注册不赠送
*/
@PostMapping("/register")
public AjaxResult register(@RequestBody ClientAccount clientAccount) {
if (StringUtils.isEmpty(clientAccount.getUsername()) || StringUtils.isEmpty(clientAccount.getPassword())) {
return AjaxResult.error("用户名和密码不能为空");
}
if (clientAccount.getPassword().length() < 6) {
return AjaxResult.error("密码长度不能少于6位");
}
if (clientAccountService.selectClientAccountByUsername(clientAccount.getUsername()) != null) {
return AjaxResult.error("用户名已存在");
}
public AjaxResult register(@RequestBody Map<String, String> registerData) {
String username = registerData.get("username");
String password = registerData.get("password");
String deviceId = registerData.get("deviceId");
ClientAccount clientAccount = new ClientAccount();
clientAccount.setUsername(username);
clientAccount.setAccountName(username);
clientAccount.setCreateBy("system");
clientAccount.setStatus("0");
clientAccount.setPermissions("{\"amazon\":true,\"rakuten\":true,\"zebra\":true}");
clientAccount.setPassword(passwordEncoder.encode(clientAccount.getPassword()));
if (clientAccount.getExpireTime() == null) {
Date expireDate = new Date(System.currentTimeMillis() + 90L * 24 * 60 * 60 * 1000);
clientAccount.setExpireTime(expireDate);
clientAccount.setPassword(passwordEncoder.encode(password));
// 检查设备ID是否已注册过赠送VIP逻辑
boolean isNewDevice = true;
if (!StringUtils.isEmpty(deviceId)) {
ClientDevice existingDevice = clientDeviceMapper.selectByDeviceId(deviceId);
isNewDevice = (existingDevice == null);
}
int vipDays;
if (isNewDevice) {
vipDays = 3;
} else {
vipDays = 0; // 立即过期,需要续费
}
if (vipDays > 0) {
Date expireDate = new Date(System.currentTimeMillis() + vipDays * 24L * 60 * 60 * 1000);
clientAccount.setExpireTime(expireDate);
} else {
clientAccount.setExpireTime(new Date());
}
int result = clientAccountService.insertClientAccount(clientAccount);
if (result <= 0) {
return AjaxResult.error("注册失败");
}
String accessToken = Jwts.builder()
.setHeaderParam("kid", jwtRsaKeyService.getKeyId())
.setSubject(clientAccount.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + JWT_EXPIRATION))
.claim("accountId", clientAccount.getId())
.claim("clientId", deviceId)
.signWith(SignatureAlgorithm.RS256, jwtRsaKeyService.getPrivateKey())
.compact();
@@ -256,5 +280,43 @@ public class ClientAccountController extends BaseController {
return AjaxResult.success(account == null);
}
/**
* 续费账号
*/
@PreAuthorize("@ss.hasPermi('monitor:account:edit')")
@Log(title = "账号续费", businessType = BusinessType.UPDATE)
@PostMapping("/renew")
public AjaxResult renew(@RequestBody Map<String, Object> data) {
Long accountId = Long.valueOf(data.get("accountId").toString());
Integer days = Integer.valueOf(data.get("days").toString());
ClientAccount account = clientAccountService.selectClientAccountById(accountId);
if (account == null) {
return AjaxResult.error("账号不存在");
}
java.util.Calendar cal = java.util.Calendar.getInstance();
if (account.getExpireTime() != null && account.getExpireTime().after(new Date())) {
cal.setTime(account.getExpireTime());
} else {
cal.setTime(new Date());
}
cal.add(java.util.Calendar.DAY_OF_MONTH, days);
Date newExpireTime = cal.getTime();
account.setExpireTime(newExpireTime);
account.setUpdateBy(getUsername());
clientAccountService.updateClientAccount(account);
// 通过SSE推送续费通知给该账号的所有在线设备
try {
sseHubService.sendEventToAllDevices(account.getUsername(), "VIP_RENEWED",
"{\"expireTime\":\"" + newExpireTime + "\"}");
} catch (Exception e) {
// SSE推送失败不影响续费操作
}
return AjaxResult.success("续费成功,新的过期时间:" + newExpireTime);
}
}

View File

@@ -4,6 +4,8 @@ import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.ip.IpUtils;
import com.ruoyi.system.domain.ClientDevice;
import com.ruoyi.system.mapper.ClientDeviceMapper;
import com.ruoyi.system.mapper.ClientAccountMapper;
import com.ruoyi.system.domain.ClientAccount;
import com.ruoyi.web.sse.SseHubService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@@ -19,9 +21,28 @@ public class ClientDeviceController {
@Autowired
private ClientDeviceMapper clientDeviceMapper;
@Autowired
private ClientAccountMapper clientAccountMapper;
@Autowired
private SseHubService sseHubService;
private static final int DEFAULT_LIMIT = 3;
/**
* 获取账号的设备数量限制
*
* @param username 用户名
* @return 设备数量限制,如果账号不存在或未配置则返回默认值
*/
private int getDeviceLimit(String username) {
if (username == null || username.isEmpty()) {
return DEFAULT_LIMIT;
}
ClientAccount account = clientAccountMapper.selectClientAccountByUsername(username);
if (account == null || account.getDeviceLimit() == null) {
return DEFAULT_LIMIT;
}
return account.getDeviceLimit();
}
/**
* 查询设备配额与已使用数量
*
@@ -35,8 +56,9 @@ public class ClientDeviceController {
for (ClientDevice d : all) {
if (!"removed".equals(d.getStatus())) used++;
}
int limit = getDeviceLimit(username);
Map<String, Object> map = new HashMap<>();
map.put("limit", DEFAULT_LIMIT);
map.put("limit", limit);
map.put("used", used);
return AjaxResult.success(map);
}
@@ -55,26 +77,26 @@ public class ClientDeviceController {
return AjaxResult.success(active);
}
/**
* 设备注册(幂等)
*
* 根据 deviceId 判断:
* - 不存在:插入新记录(检查设备数量限制)
* - 已存在:更新设备信息
* 设备注册
*/
@PostMapping("/register")
public AjaxResult register(@RequestBody ClientDevice device, HttpServletRequest request) {
ClientDevice exists = clientDeviceMapper.selectByDeviceId(device.getDeviceId());
String ip = IpUtils.getIpAddr(request);
String deviceName = request.getHeader("X-Client-User") + "@" + ip + " (" + request.getHeader("X-Client-OS") + ")";
// 从请求体读取用户名和操作系统,构建设备名称
String username = device.getUsername();
String os = device.getOs();
String deviceName = username + "@" + ip + " (" + os + ")";
if (exists == null) {
// 检查设备数量限制
int deviceLimit = getDeviceLimit(device.getUsername());
List<ClientDevice> userDevices = clientDeviceMapper.selectByUsername(device.getUsername());
int activeDeviceCount = 0;
for (ClientDevice d : userDevices) {
if (!"removed".equals(d.getStatus())) activeDeviceCount++;
}
if (activeDeviceCount >= DEFAULT_LIMIT) {
return AjaxResult.error("设备数量已达上限(" + DEFAULT_LIMIT + "个),请先移除其他设备");
if (activeDeviceCount >= deviceLimit) {
return AjaxResult.error("设备数量已达上限(" + deviceLimit + "个),请先移除其他设备");
}
device.setIp(ip);
device.setStatus("online");
@@ -139,7 +161,6 @@ public class ClientDeviceController {
public AjaxResult offline(@RequestBody Map<String, String> body) {
String deviceId = body.get("deviceId");
if (deviceId == null) return AjaxResult.error("deviceId不能为空");
ClientDevice device = clientDeviceMapper.selectByDeviceId(deviceId);
if (device != null) {
device.setStatus("offline");
@@ -157,16 +178,20 @@ public class ClientDeviceController {
public AjaxResult heartbeat(@RequestBody ClientDevice device, HttpServletRequest request) {
ClientDevice exists = clientDeviceMapper.selectByDeviceId(device.getDeviceId());
String ip = IpUtils.getIpAddr(request);
String deviceName = request.getHeader("X-Client-User") + "@" + ip + " (" + request.getHeader("X-Client-OS") + ")";
// 从请求体读取用户名和操作系统,构建设备名称
String username = device.getUsername() ;
String os = device.getOs();
String deviceName = username + "@" + ip + " (" + os + ")";
if (exists == null) {
// 检查设备数量限制
int deviceLimit = getDeviceLimit(device.getUsername());
List<ClientDevice> userDevices = clientDeviceMapper.selectByUsername(device.getUsername());
int activeDeviceCount = 0;
for (ClientDevice d : userDevices) {
if (!"removed".equals(d.getStatus())) activeDeviceCount++;
}
if (activeDeviceCount >= DEFAULT_LIMIT) {
return AjaxResult.error("设备数量已达上限(" + DEFAULT_LIMIT + "个),请先移除其他设备");
if (activeDeviceCount >= deviceLimit) {
return AjaxResult.error("设备数量已达上限(" + deviceLimit + "个),请先移除其他设备");
}
device.setIp(ip);
device.setStatus("online");

View File

@@ -92,6 +92,28 @@ public class SseHubService {
}
}
/**
* 向指定账号的所有设备推送消息
*/
public void sendEventToAllDevices(String username, String type, String message) {
if (username == null || username.isEmpty()) return;
// 遍历所有会话找到匹配的username
String prefix = username + ":";
sessionEmitters.forEach((key, emitter) -> {
if (key.startsWith(prefix)) {
try {
String data = message != null ? message : "{}";
String eventData = "{\"type\":\"" + type + "\",\"message\":" + escapeJson(data) + "}";
emitter.send(SseEmitter.event().data(eventData));
} catch (IOException e) {
sessionEmitters.remove(key);
try { emitter.complete(); } catch (Exception ignored) {}
}
}
});
}
/**
* 更新设备状态
*/

View File

@@ -0,0 +1,21 @@
package com.ruoyi.web.task;
import com.ruoyi.system.service.IBanmaAccountService;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@Component
public class BanmaTokenRefreshTask {
@Resource
private IBanmaAccountService banmaAccountService;
// 每两天凌晨3点
@Scheduled(cron = "0 0 3 */2 * ?")
public void refreshAllTokens() {
banmaAccountService.refreshAllTokens();
}
}

View File

@@ -0,0 +1,44 @@
package com.ruoyi.web.task;
import com.ruoyi.system.domain.ClientDevice;
import com.ruoyi.system.mapper.ClientDeviceMapper;
import com.ruoyi.web.sse.SseHubService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 设备心跳定时任务
*
* @author ruoyi
*/
@Component
public class DeviceHeartbeatTask {
@Autowired
private ClientDeviceMapper clientDeviceMapper;
@Autowired
private SseHubService sseHubService;
/**
* 每30秒发送一次ping保持SSE连接活跃
*/
@Scheduled(fixedRate = 30000)
public void sendHeartbeatPing() {
List<ClientDevice> onlineDevices = clientDeviceMapper.selectOnlineDevices();
for (ClientDevice device : onlineDevices) {
sseHubService.sendPing(device.getUsername(), device.getDeviceId());
}
}
/**
* 每2分钟清理一次过期设备
*/
@Scheduled(fixedRate = 120000)
public void cleanExpiredDevices() {
clientDeviceMapper.updateExpiredDevicesOffline();
}
}

View File

@@ -27,7 +27,7 @@ qiniu:
# 开发环境配置
server:
# 服务器的HTTP端口默认为8080
port: 8080
port: 8085
servlet:
# 应用的访问路径
context-path: /

View File

@@ -47,6 +47,10 @@ public class ClientAccount extends BaseEntity
/** 功能权限配置(JSON格式) */
private String permissions;
/** 设备数量限制 */
@Excel(name = "设备数量限制")
private Integer deviceLimit;
public void setId(Long id)
{
this.id = id;
@@ -133,4 +137,14 @@ public class ClientAccount extends BaseEntity
{
return permissions;
}
public void setDeviceLimit(Integer deviceLimit)
{
this.deviceLimit = deviceLimit;
}
public Integer getDeviceLimit()
{
return deviceLimit;
}
}

View File

@@ -14,6 +14,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="allowedIpRange" column="allowed_ip_range" />
<result property="remark" column="remark" />
<result property="permissions" column="permissions" />
<result property="deviceLimit" column="device_limit" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
@@ -22,7 +23,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="selectClientAccountVo">
select id, account_name, username, password, status, expire_time,
allowed_ip_range, remark, permissions, create_by, create_time, update_by, update_time
allowed_ip_range, remark, permissions, device_limit, create_by, create_time, update_by, update_time
from client_account
</sql>
@@ -57,6 +58,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="allowedIpRange != null">allowed_ip_range,</if>
<if test="remark != null">remark,</if>
<if test="permissions != null">permissions,</if>
<if test="deviceLimit != null">device_limit,</if>
<if test="createBy != null">create_by,</if>
create_time
</trim>
@@ -69,6 +71,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="allowedIpRange != null">#{allowedIpRange},</if>
<if test="remark != null">#{remark},</if>
<if test="permissions != null">#{permissions},</if>
<if test="deviceLimit != null">#{deviceLimit},</if>
<if test="createBy != null">#{createBy},</if>
sysdate()
</trim>
@@ -85,6 +88,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="allowedIpRange != null">allowed_ip_range = #{allowedIpRange},</if>
<if test="remark != null">remark = #{remark},</if>
<if test="permissions != null">permissions = #{permissions},</if>
<if test="deviceLimit != null">device_limit = #{deviceLimit},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
update_time = sysdate()
</trim>

View File

@@ -166,17 +166,38 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select * from client_info where client_id = #{clientId}
</select>
<!-- 查询客户端信息列表 -->
<select id="selectClientInfoList" parameterType="ClientInfo" resultMap="ClientInfoResult">
select * from client_info
select
d.device_id as client_id,
d.username,
d.os as os_name,
d.ip as ip_address,
d.last_active_at as last_active_time,
d.create_time as auth_time,
CASE WHEN d.status = 'online' THEN '1' ELSE '0' END as online,
a.account_name as hostname,
'' as app_version,
'' as os_version,
'' as java_version
from client_device d
left join client_account a on d.username COLLATE utf8mb4_unicode_ci = a.username
<where>
<if test="clientId != null and clientId != ''">AND client_id like concat('%', #{clientId}, '%')</if>
<if test="username != null and username != ''">AND username like concat('%', #{username}, '%')</if>
<if test="osName != null and osName != ''">AND os_name like concat('%', #{osName}, '%')</if>
<if test="status != null and status != ''">AND status = #{status}</if>
<if test="online != null and online != ''">AND online = #{online}</if>
<if test="clientId != null and clientId != ''">AND d.device_id like concat('%', #{clientId}, '%')</if>
<if test="username != null and username != ''">AND d.username like concat('%', #{username}, '%')</if>
<if test="osName != null and osName != ''">AND d.os like concat('%', #{osName}, '%')</if>
<if test="online != null and online != ''">
AND d.status =
<choose>
<when test="online == '1'">
'online'
</when>
<otherwise>
'offline'
</otherwise>
</choose>
</if>
</where>
order by last_active_time desc
order by d.last_active_at desc
</select>
<!-- 查询客户端错误报告列表 -->
@@ -223,14 +244,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by collect_time desc
</select>
<!-- 查询在线客户端数量 -->
<!-- 查询在线客户端数量 - 基于 client_device 表 -->
<select id="selectOnlineClientCount" resultType="int">
select count(*) from client_info where online = '1'
select count(*) from client_device where status = 'online'
</select>
<!-- 查询客户端总数 -->
<!-- 查询客户端总数 - 基于 client_device 表 -->
<select id="selectTotalClientCount" resultType="int">
SELECT COUNT(*) FROM client_info
SELECT COUNT(*) FROM client_device WHERE status != 'removed'
</select>
<!-- 查询今日错误数量 -->
@@ -342,11 +363,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
from client_data_report
</sql>
<!-- 查询最近7天的客户端活跃趋势 -->
<!-- 查询最近7天的客户端活跃趋势 - 基于 client_device 表 -->
<select id="selectClientActiveTrend" resultType="map">
SELECT
days.date_str as date,
IFNULL(counts.client_count, 0) as count
IFNULL(counts.device_count, 0) as count
FROM
(
SELECT DATE_FORMAT(CURDATE() - INTERVAL 6 DAY, '%Y-%m-%d') as date_str
@@ -360,15 +381,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
LEFT JOIN
(
SELECT
DATE_FORMAT(auth_time, '%Y-%m-%d') as auth_date,
COUNT(DISTINCT client_id) as client_count
DATE_FORMAT(create_time, '%Y-%m-%d') as create_date,
COUNT(*) as device_count
FROM
client_info
client_device
WHERE
auth_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
create_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
AND status != 'removed'
GROUP BY
DATE_FORMAT(auth_time, '%Y-%m-%d')
) counts ON days.date_str = counts.auth_date
DATE_FORMAT(create_time, '%Y-%m-%d')
) counts ON days.date_str = counts.create_date
ORDER BY
days.date_str ASC
</select>

View File

@@ -5,4 +5,4 @@ VUE_APP_TITLE = ERP管理系统
ENV = 'production'
# ERP管理系统/生产环境
VUE_APP_BASE_API = 'http://8.138.23.49:8080'
VUE_APP_BASE_API = 'http://8.138.23.49:8085'

View File

@@ -60,3 +60,12 @@ export function checkUsername(username) {
params: { username }
})
}
// 续费客户端账号
export function renewAccount(data) {
return request({
url: '/monitor/account/renew',
method: 'post',
data: data
})
}

View File

@@ -12,13 +12,13 @@
<template v-slot:header>
<div class="card-header">
<i class="el-icon-user"></i>
<span>当前在线客户端</span>
<span>当前在线设备</span>
</div>
</template>
<div class="card-body">
<count-to :start-val="0" :end-val="statisticsData.onlineCount" :duration="2500" class="card-value" />
<div class="card-footer">
<span>注册客户端: {{ statisticsData.totalCount }}</span>
<span>设备数: {{ statisticsData.totalCount }}</span>
</div>
</div>
</el-card>
@@ -60,13 +60,13 @@
<template v-slot:header>
<div class="card-header">
<i class="el-icon-s-data"></i>
<span>离线客户端</span>
<span>离线设备</span>
</div>
</template>
<div class="card-body">
<count-to :start-val="0" :end-val="statisticsData.totalCount - statisticsData.onlineCount" :duration="2500" class="card-value" />
<div class="card-footer">
<span>离线时长: {{ statisticsData.avgOfflineTime || '未知' }}</span>
<span>离线设备数</span>
</div>
</div>
</el-card>
@@ -80,7 +80,7 @@
<template v-slot:header>
<div class="card-header">
<i class="el-icon-data-analysis"></i>
<span>近7天新增客户端趋势</span>
<span>近7天新增设备趋势</span>
</div>
</template>
<div class="chart-container">
@@ -109,25 +109,25 @@
<template v-slot:header>
<div class="card-header">
<i class="el-icon-s-grid"></i>
<span>活跃客户端列表</span>
<span>活跃设备列表</span>
<el-button style="float: right; padding: 3px 0;" type="text" @click="refreshClients">
<i class="el-icon-refresh"></i> 刷新
</el-button>
</div>
</template>
<el-table :data="clientList" style="width: 100%" v-loading="loading">
<el-table-column prop="clientId" label="客户端ID" width="240"></el-table-column>
<el-table-column prop="username" label="用户名" width="120"></el-table-column>
<el-table-column prop="clientId" label="设备ID" width="240" show-overflow-tooltip></el-table-column>
<el-table-column prop="username" label="账号" width="120"></el-table-column>
<el-table-column prop="hostname" label="设备名称" width="150" show-overflow-tooltip></el-table-column>
<el-table-column prop="osName" label="操作系统" width="120"></el-table-column>
<el-table-column prop="appVersion" label="应用版本" width="100"></el-table-column>
<el-table-column prop="ipAddress" label="IP地址" width="150"></el-table-column>
<el-table-column prop="lastActiveTime" label="最后活跃时间" width="180"></el-table-column>
<el-table-column prop="online" label="状态">
<el-table-column prop="online" label="状态" width="100">
<template v-slot="scope">
<el-tag :type="scope.row.online === '1' ? 'success' : 'info'">{{ scope.row.online === '1' ? '在线' : '离线' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<el-table-column label="操作" width="120">
<template v-slot="scope">
<el-button size="mini" type="text" @click="viewClientData(scope.row)">详情</el-button>
</template>
@@ -150,19 +150,17 @@
</el-row>
<!-- 客户端详情弹窗 -->
<el-dialog :title="'客户端详情: ' + currentClient.clientId" v-model="detailDialogVisible" width="50%">
<el-dialog :title="'设备详情: ' + currentClient.hostname" v-model="detailDialogVisible" width="50%">
<el-descriptions :column="2" border>
<el-descriptions-item label="用户名">{{ currentClient.username }}</el-descriptions-item>
<el-descriptions-item label="设备ID">{{ currentClient.clientId }}</el-descriptions-item>
<el-descriptions-item label="账号名">{{ currentClient.username }}</el-descriptions-item>
<el-descriptions-item label="设备名称">{{ currentClient.hostname }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="currentClient.online === '1' ? 'success' : 'info'">{{ currentClient.online === '1' ? '在线' : '离线' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="操作系统">{{ currentClient.osName }}</el-descriptions-item>
<el-descriptions-item label="系统版本">{{ currentClient.osVersion }}</el-descriptions-item>
<el-descriptions-item label="Java版本">{{ currentClient.javaVersion }}</el-descriptions-item>
<el-descriptions-item label="应用版本">{{ currentClient.appVersion }}</el-descriptions-item>
<el-descriptions-item label="IP地址">{{ currentClient.ipAddress }}</el-descriptions-item>
<el-descriptions-item label="主机名">{{ currentClient.hostname }}</el-descriptions-item>
<el-descriptions-item label="认证时间">{{ currentClient.authTime }}</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ currentClient.authTime }}</el-descriptions-item>
<el-descriptions-item label="最后活跃">{{ currentClient.lastActiveTime }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
@@ -273,12 +271,12 @@ export default {
errorRate: 0,
avgOfflineTime: '未知'
},
// 近7天新增客户端趋势数据
// 近7天新增设备趋势数据
onlineClientData: {
labels: [],
datasets: [
{
label: '新增客户端数',
label: '新增设备数',
data: [],
backgroundColor: 'rgba(103, 194, 58, 0.2)',
borderColor: 'rgb(103, 194, 58)',

View File

@@ -89,13 +89,22 @@
</el-table-column>
<el-table-column label="过期时间" align="center" prop="expireTime" width="180">
<template slot-scope="scope">
<span>{{ scope.row.expireTime }}</span>
<el-tag v-if="isExpired(scope.row)" type="danger" size="mini">已过期</el-tag>
<el-tag
:type="getRemainingDays(scope.row).type"
size="small"
style="margin-top: 5px;"
>
{{ getRemainingDays(scope.row).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="设备限制" align="center" prop="deviceLimit" width="100">
<template slot-scope="scope">
<span>{{ scope.row.deviceLimit || 3 }}</span>
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />
<el-table-column label="创建时间" align="center" prop="createTime" width="180" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="150">
<template slot-scope="scope">
<el-button
size="mini"
@@ -153,16 +162,37 @@
style="width: 100%;"
></el-date-picker>
</el-form-item>
<el-form-item label="快速续费" v-if="form.id">
<el-select v-model="form.renewDays" placeholder="选择续费套餐(可选)" style="width: 100%;" clearable>
<el-option label="月付30天" :value="30"></el-option>
<el-option label="季付90天" :value="90"></el-option>
<el-option label="半年付180天" :value="180"></el-option>
<el-option label="年付365天" :value="365"></el-option>
</el-select>
<div v-if="form.renewDays" style="margin-top: 8px; color: #409EFF; font-size: 12px;">
<i class="el-icon-info"></i> 续费后到期时间{{ calculateNewExpireTime() }}
</div>
</el-form-item>
<el-form-item label="设备数量限制" prop="deviceLimit">
<el-input-number
v-model="form.deviceLimit"
:min="1"
:max="20"
placeholder="请输入设备数量限制"
style="width: 100%;"
></el-input-number>
<span style="color: #909399; font-size: 12px;">允许同时登录的设备数量默认3台</span>
</el-form-item>
<el-form-item label="功能权限" prop="permissions">
<div class="permission-config">
<el-checkbox-group v-model="selectedPermissions" @change="onPermissionChange">
<el-checkbox label="rakuten">日本乐天平台</el-checkbox>
<el-checkbox label="amazon">亚马逊平台</el-checkbox>
<el-checkbox label="zebra">斑马平台</el-checkbox>
<el-checkbox label="shopee">虾皮购物平台</el-checkbox>
<el-checkbox label="toolbox">工具箱功能</el-checkbox>
<el-checkbox label="dataCollection">数据采集功能</el-checkbox>
<el-checkbox label="priceCompare">1688比价功能</el-checkbox>
<el-checkbox label="rakuten"><i class="el-icon-goods"></i> 日本乐天平台</el-checkbox>
<el-checkbox label="amazon"><i class="el-icon-shopping-cart-2"></i> 亚马逊平台</el-checkbox>
<el-checkbox label="zebra"><i class="el-icon-postcard"></i> 斑马平台</el-checkbox>
<el-checkbox label="shopee"><i class="el-icon-shopping-bag-2"></i> 虾皮购物平台</el-checkbox>
<el-checkbox label="toolbox"><i class="el-icon-box"></i> 工具箱功能</el-checkbox>
<el-checkbox label="dataCollection"><i class="el-icon-document-copy"></i> 数据采集功能</el-checkbox>
<el-checkbox label="priceCompare"><i class="el-icon-price-tag"></i> 1688比价功能</el-checkbox>
</el-checkbox-group>
</div>
</el-form-item>
@@ -201,7 +231,7 @@
</template>
<script>
import { listAccount, getAccount, delAccount, addAccount, updateAccount, registerAccount, checkUsername } from "@/api/monitor/account";
import { listAccount, getAccount, delAccount, addAccount, updateAccount, registerAccount, checkUsername, renewAccount } from "@/api/monitor/account";
export default {
name: "Account",
@@ -309,6 +339,8 @@ export default {
password: null,
status: "0",
expireTime: null,
renewDays: null,
deviceLimit: 3,
remark: null,
permissions: null
};
@@ -353,10 +385,24 @@ export default {
this.$refs["form"].validate(valid => {
if (valid) {
if (this.form.id != null) {
updateAccount(this.form).then(response => {
this.$modal.msgSuccess("修改成功");
// 如果选择了续费,先执行续费
const promises = [];
if (this.form.renewDays) {
promises.push(renewAccount({
accountId: this.form.id,
days: this.form.renewDays
}));
}
// 执行更新
promises.push(updateAccount(this.form));
Promise.all(promises).then(() => {
const msg = this.form.renewDays ? "修改并续费成功" : "修改成功";
this.$modal.msgSuccess(msg);
this.open = false;
this.getList();
}).catch(error => {
this.$modal.msgError('操作失败: ' + (error.message || '未知错误'));
});
} else {
addAccount(this.form).then(response => {
@@ -471,6 +517,48 @@ export default {
cancelRegister() {
this.registerOpen = false;
this.resetRegisterForm();
},
/** 计算续费后的新到期时间 */
calculateNewExpireTime() {
if (!this.form.renewDays) return '';
let baseDate;
if (this.form.expireTime && new Date(this.form.expireTime) > new Date()) {
// 未过期,从到期时间延长
baseDate = new Date(this.form.expireTime);
} else {
// 已过期或无到期时间,从当前时间开始
baseDate = new Date();
}
baseDate.setDate(baseDate.getDate() + this.form.renewDays);
return baseDate.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
},
/** 获取剩余天数 */
getRemainingDays(row) {
if (!row.expireTime) {
return { text: '已过期', type: 'danger' };
}
const now = new Date();
const expireDate = new Date(row.expireTime);
const diffTime = expireDate - now;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return { text: '已过期', type: 'danger' };
} else if (diffDays <= 3) {
return { text: `剩余 ${diffDays}`, type: 'warning' };
} else {
return { text: `剩余 ${diffDays}`, type: 'success' };
}
}
}
};
@@ -478,25 +566,34 @@ export default {
<style scoped>
.permission-config {
padding: 10px;
border: 1px solid #e6e6e6;
padding: 15px;
border: 1px solid #DCDFE6;
border-radius: 4px;
background-color: #fafafa;
}
.permission-config .el-checkbox {
display: block;
margin: 8px 0;
margin-left: 0;
background: linear-gradient(135deg, #f5f7fa 0%, #fafbfc 100%);
}
.permission-config .el-checkbox-group {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px 20px;
}
.permission-config .el-checkbox + .el-checkbox {
margin-left: 0;
.permission-config .el-checkbox {
margin: 0;
padding: 8px 12px;
border-radius: 4px;
transition: all 0.3s;
background-color: white;
}
.permission-config .el-checkbox:hover {
background-color: #ecf5ff;
transform: translateX(2px);
}
.permission-config .el-checkbox i {
margin-right: 6px;
color: #409EFF;
}
.checking {