1
This commit is contained in:
1
.idea/compiler.xml
generated
1
.idea/compiler.xml
generated
@@ -2,6 +2,7 @@
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<annotationProcessing>
|
||||
<profile default="true" name="Default" enabled="true" />
|
||||
<profile name="Maven default annotation processors profile" enabled="true">
|
||||
<sourceOutputDir name="target/generated-sources/annotations" />
|
||||
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
||||
|
||||
110
.idea/workspace.xml
generated
110
.idea/workspace.xml
generated
@@ -5,12 +5,31 @@
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="71d19dd6-7472-4ebf-b309-b7afee3f99de" name="更改" comment="1">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/jarRepositories.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/jarRepositories.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/.idea/compiler.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/compiler.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/data/erp-cache.db" beforeDir="false" afterPath="$PROJECT_DIR$/data/erp-cache.db" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/data/erp-cache.db-wal" beforeDir="false" afterPath="$PROJECT_DIR$/data/erp-cache.db-wal" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/electron-vue-template/public/icon/img.png" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/electron-vue-template/public/icons/icon.ico" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/electron-vue-template/public/icons/icon.png" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/electron-vue-template/src/main/main.ts" beforeDir="false" afterPath="$PROJECT_DIR$/electron-vue-template/src/main/main.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/electron-vue-template/src/renderer/App.vue" beforeDir="false" afterPath="$PROJECT_DIR$/electron-vue-template/src/renderer/App.vue" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/electron-vue-template/src/renderer/api/auth.ts" beforeDir="false" afterPath="$PROJECT_DIR$/electron-vue-template/src/renderer/api/auth.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/electron-vue-template/src/renderer/api/http.ts" beforeDir="false" afterPath="$PROJECT_DIR$/electron-vue-template/src/renderer/api/http.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/electron-vue-template/src/renderer/api/zebra.ts" beforeDir="false" afterPath="$PROJECT_DIR$/electron-vue-template/src/renderer/api/zebra.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/electron-vue-template/src/renderer/components/amazon/AmazonDashboard.vue" beforeDir="false" afterPath="$PROJECT_DIR$/electron-vue-template/src/renderer/components/amazon/AmazonDashboard.vue" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/electron-vue-template/src/renderer/components/auth/LoginDialog.vue" beforeDir="false" afterPath="$PROJECT_DIR$/electron-vue-template/src/renderer/components/auth/LoginDialog.vue" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/electron-vue-template/src/renderer/components/layout/NavigationBar.vue" beforeDir="false" afterPath="$PROJECT_DIR$/electron-vue-template/src/renderer/components/layout/NavigationBar.vue" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/electron-vue-template/src/renderer/components/rakuten/RakutenDashboard.vue" beforeDir="false" afterPath="$PROJECT_DIR$/electron-vue-template/src/renderer/components/rakuten/RakutenDashboard.vue" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/electron-vue-template/src/renderer/components/zebra/ZebraDashboard.vue" beforeDir="false" afterPath="$PROJECT_DIR$/electron-vue-template/src/renderer/components/zebra/ZebraDashboard.vue" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/erp_client_sb/src/main/java/com/tashow/erp/controller/AuthController.java" beforeDir="false" afterPath="$PROJECT_DIR$/erp_client_sb/src/main/java/com/tashow/erp/controller/AuthController.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/erp_client_sb/src/main/java/com/tashow/erp/controller/DeviceProxyController.java" beforeDir="false" afterPath="$PROJECT_DIR$/erp_client_sb/src/main/java/com/tashow/erp/controller/DeviceProxyController.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/erp_client_sb/src/main/java/com/tashow/erp/service/impl/Alibaba1688ServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/erp_client_sb/src/main/java/com/tashow/erp/service/impl/Alibaba1688ServiceImpl.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/erp_client_sb/src/main/java/com/tashow/erp/service/impl/AuthServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/erp_client_sb/src/main/java/com/tashow/erp/service/impl/AuthServiceImpl.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/ClientAccountController.java" beforeDir="false" afterPath="$PROJECT_DIR$/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/ClientAccountController.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/ClientDeviceController.java" beforeDir="false" afterPath="$PROJECT_DIR$/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/ClientDeviceController.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/BanmaOrderController.java" beforeDir="false" afterPath="$PROJECT_DIR$/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/BanmaOrderController.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/ruoyi-admin/src/main/java/com/ruoyi/web/sse/SseHubService.java" beforeDir="false" afterPath="$PROJECT_DIR$/ruoyi-admin/src/main/java/com/ruoyi/web/sse/SseHubService.java" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -39,6 +58,10 @@
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RequestMappingsPanelOrder0": "0",
|
||||
"RequestMappingsPanelOrder1": "1",
|
||||
"RequestMappingsPanelWidth0": "75",
|
||||
"RequestMappingsPanelWidth1": "75",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
@@ -51,6 +74,7 @@
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "MavenSettings",
|
||||
"ts.external.directory.path": "C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\electron-vue-template\\node_modules\\typescript\\lib",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}]]></component>
|
||||
@@ -64,7 +88,7 @@
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
<component name="RunManager" selected="Spring Boot.RuoYiApplication">
|
||||
<component name="RunManager" selected="Spring Boot.ErpClientSbApplication">
|
||||
<configuration default="true" type="JetRunConfigurationType">
|
||||
<module name="RuoYi-Vue" />
|
||||
<method v="2">
|
||||
@@ -125,7 +149,87 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1758512348322</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="3" />
|
||||
<task id="LOCAL-00003" summary="1">
|
||||
<option name="closed" value="true" />
|
||||
<created>1758521046022</created>
|
||||
<option name="number" value="00003" />
|
||||
<option name="presentableId" value="LOCAL-00003" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1758521046022</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00004" summary="1">
|
||||
<option name="closed" value="true" />
|
||||
<created>1758522202417</created>
|
||||
<option name="number" value="00004" />
|
||||
<option name="presentableId" value="LOCAL-00004" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1758522202417</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00005" summary="1">
|
||||
<option name="closed" value="true" />
|
||||
<created>1758522758523</created>
|
||||
<option name="number" value="00005" />
|
||||
<option name="presentableId" value="LOCAL-00005" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1758522758523</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00006" summary="1">
|
||||
<option name="closed" value="true" />
|
||||
<created>1758523822682</created>
|
||||
<option name="number" value="00006" />
|
||||
<option name="presentableId" value="LOCAL-00006" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1758523822682</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00007" summary="1">
|
||||
<option name="closed" value="true" />
|
||||
<created>1758524938236</created>
|
||||
<option name="number" value="00007" />
|
||||
<option name="presentableId" value="LOCAL-00007" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1758524938236</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00008" summary="1">
|
||||
<option name="closed" value="true" />
|
||||
<created>1758525299299</created>
|
||||
<option name="number" value="00008" />
|
||||
<option name="presentableId" value="LOCAL-00008" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1758525299299</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00009" summary="1">
|
||||
<option name="closed" value="true" />
|
||||
<created>1758525500986</created>
|
||||
<option name="number" value="00009" />
|
||||
<option name="presentableId" value="LOCAL-00009" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1758525500986</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00010" summary="1">
|
||||
<option name="closed" value="true" />
|
||||
<created>1758526085800</created>
|
||||
<option name="number" value="00010" />
|
||||
<option name="presentableId" value="LOCAL-00010" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1758526085800</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00011" summary="1">
|
||||
<option name="closed" value="true" />
|
||||
<created>1758528696003</created>
|
||||
<option name="number" value="00011" />
|
||||
<option name="presentableId" value="LOCAL-00011" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1758528696003</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00012" summary="1">
|
||||
<option name="closed" value="true" />
|
||||
<created>1758529627894</created>
|
||||
<option name="number" value="00012" />
|
||||
<option name="presentableId" value="LOCAL-00012" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1758529627894</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="13" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 169 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB |
@@ -90,11 +90,10 @@ function stopSpringBoot() {
|
||||
}
|
||||
}
|
||||
|
||||
// 创建主窗口(预创建但隐藏)
|
||||
function createWindow () {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed, defineAsyncComponent } from 'vue'
|
||||
import { ElConfigProvider, ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {onMounted, ref, computed, defineAsyncComponent, type Component} from 'vue'
|
||||
import {ElConfigProvider, ElMessage, ElMessageBox} from 'element-plus'
|
||||
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 ZebraDashboard from './components/zebra/ZebraDashboard.vue'
|
||||
import {authApi} from './api/auth'
|
||||
import {deviceApi, type DeviceItem, type DeviceQuota} from './api/device'
|
||||
// 面板按需加载,互不影响且可缓存
|
||||
|
||||
const LoginDialog = defineAsyncComponent(() => import('./components/auth/LoginDialog.vue'))
|
||||
const RegisterDialog = defineAsyncComponent(() => import('./components/auth/RegisterDialog.vue'))
|
||||
const NavigationBar = defineAsyncComponent(() => import('./components/layout/NavigationBar.vue'))
|
||||
const RakutenDashboard = defineAsyncComponent(() => import('./components/rakuten/RakutenDashboard.vue'))
|
||||
const AmazonDashboard = defineAsyncComponent(() => import('./components/amazon/AmazonDashboard.vue'))
|
||||
const ZebraDashboard = defineAsyncComponent(() => import('./components/zebra/ZebraDashboard.vue'))
|
||||
|
||||
const dashboardsMap: Record<string, Component> = {
|
||||
rakuten: RakutenDashboard,
|
||||
amazon: AmazonDashboard,
|
||||
zebra: ZebraDashboard,
|
||||
}
|
||||
|
||||
const activeDashboard = computed<Component | null>(() => {
|
||||
if (!isAuthenticated.value) return null
|
||||
return dashboardsMap[activeMenu.value] || null
|
||||
})
|
||||
|
||||
const isDefaultPanel = computed(() => ['rakuten', 'amazon', 'zebra'].includes(activeMenu.value))
|
||||
const showHomeSplash = computed(() => !isAuthenticated.value && isDefaultPanel.value)
|
||||
const showPlaceholder = computed(() => !showHomeSplash.value && !activeDashboard.value)
|
||||
|
||||
// 导航历史栈
|
||||
const navigationHistory = ref<string[]>(['rakuten'])
|
||||
@@ -27,15 +44,15 @@ const currentUsername = ref('')
|
||||
const showDeviceDialog = ref(false)
|
||||
const deviceLoading = ref(false)
|
||||
const devices = ref<DeviceItem[]>([])
|
||||
const deviceQuota = ref<DeviceQuota>({ limit: 0, used: 0 })
|
||||
const deviceQuota = ref<DeviceQuota>({limit: 0, used: 0})
|
||||
const userPermissions = ref<string>('')
|
||||
|
||||
// 菜单配置 - 复刻ERP客户端格式
|
||||
const menuConfig = [
|
||||
{ key: 'rakuten', name: 'Rakuten', index: 'rakuten', icon: 'R' },
|
||||
{ key: 'amazon', name: 'Amazon', index: 'amazon', icon: 'A' },
|
||||
{ key: 'zebra', name: 'Zebra', index: 'zebra', icon: 'Z' },
|
||||
{ key: 'shopee', name: 'Shopee', index: 'shopee', icon: 'S' },
|
||||
{key: 'rakuten', name: 'Rakuten', index: 'rakuten', icon: 'R'},
|
||||
{key: 'amazon', name: 'Amazon', index: 'amazon', icon: 'A'},
|
||||
{key: 'zebra', name: 'Zebra', index: 'zebra', icon: 'Z'},
|
||||
{key: 'shopee', name: 'Shopee', index: 'shopee', icon: 'S'},
|
||||
]
|
||||
|
||||
// 权限检查 - 复刻ERP客户端逻辑
|
||||
@@ -65,7 +82,9 @@ function showContent() {
|
||||
const loading = document.getElementById('loading')
|
||||
if (loading) {
|
||||
loading.style.opacity = '0'
|
||||
setTimeout(() => { loading.style.display = 'none' }, 100)
|
||||
setTimeout(() => {
|
||||
loading.style.display = 'none'
|
||||
}, 100)
|
||||
}
|
||||
const app = document.getElementById('app-root')
|
||||
if (app) app.style.opacity = '1'
|
||||
@@ -114,10 +133,13 @@ async function handleLoginSuccess(data: { token: string; permissions?: string })
|
||||
showAuthDialog.value = false
|
||||
|
||||
try {
|
||||
// 保存token到本地数据库
|
||||
await authApi.saveToken(data.token)
|
||||
|
||||
const username = getUsernameFromToken(data.token)
|
||||
currentUsername.value = username
|
||||
userPermissions.value = data?.permissions || ''
|
||||
await deviceApi.register({ username })
|
||||
await deviceApi.register({username})
|
||||
|
||||
// 建立SSE连接
|
||||
SSEManager.connect()
|
||||
@@ -126,15 +148,10 @@ async function handleLoginSuccess(data: { token: string; permissions?: string })
|
||||
console.warn('设备注册失败:', e)
|
||||
}
|
||||
}
|
||||
async function logout() {
|
||||
try {
|
||||
await fetch('/api/cache/delete?key=token', { method: 'POST' })
|
||||
} catch (e) {
|
||||
console.log('删除后端token缓存失败:', e)
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await authApi.deleteTokenCache()
|
||||
// 清理前端状态
|
||||
try { localStorage.removeItem('token') } catch {}
|
||||
isAuthenticated.value = false
|
||||
currentUsername.value = ''
|
||||
userPermissions.value = ''
|
||||
@@ -151,15 +168,15 @@ async function handleUserClick() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm('确认退出登录?', '提示', { type: 'warning', confirmButtonText: '退出', cancelButtonText: '取消' })
|
||||
await ElMessageBox.confirm('确认退出登录?', '提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '退出',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
await logout()
|
||||
ElMessage.success('已退出登录')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
function handleLoginCancel() {
|
||||
showAuthDialog.value = false
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function showRegisterDialog() {
|
||||
@@ -177,69 +194,68 @@ function backToLogin() {
|
||||
showAuthDialog.value = true
|
||||
}
|
||||
|
||||
// 检查认证状态 - 复刻ERP客户端逻辑
|
||||
async function checkAuth() {
|
||||
const token = localStorage.getItem('token')
|
||||
const authRequiredMenus = ['rakuten', 'amazon', 'zebra', 'shopee']
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
try {
|
||||
await authApi.sessionBootstrap().catch(() => undefined)
|
||||
const token = await authApi.getToken()
|
||||
if (token) {
|
||||
const response = await authApi.verifyToken(token)
|
||||
if (response.success) {
|
||||
if (response?.success) {
|
||||
isAuthenticated.value = true
|
||||
if (!currentUsername.value) {
|
||||
const u = getUsernameFromToken(token)
|
||||
if (u) currentUsername.value = u
|
||||
}
|
||||
userPermissions.value = response.permissions || ''
|
||||
|
||||
// 认证成功后建立SSE连接
|
||||
currentUsername.value = getUsernameFromToken(token) || ''
|
||||
SSEManager.connect()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem('token')
|
||||
await authApi.deleteTokenCache()
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
// 检查是否需要显示登录弹框
|
||||
if (!isAuthenticated.value && authRequiredMenus.includes(activeMenu.value)) {
|
||||
if (authRequiredMenus.includes(activeMenu.value)) {
|
||||
showAuthDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function getClientIdFromToken(token?: string) {
|
||||
async function getClientIdFromToken(token?: string) {
|
||||
try {
|
||||
const t = token || localStorage.getItem('token') || ''
|
||||
let t = token
|
||||
if (!t) {
|
||||
t = await authApi.getToken()
|
||||
}
|
||||
if (!t) return ''
|
||||
|
||||
const payload = JSON.parse(atob(t.split('.')[1] || ''))
|
||||
const clientId = payload.clientId || ''
|
||||
console.log('从token解析clientId:', { token: t?.substring(0, 20) + '...', clientId })
|
||||
return clientId
|
||||
} catch (e) {
|
||||
console.warn('解析token中的clientId失败:', e)
|
||||
return payload.clientId || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function getUsernameFromToken(token?: string) {
|
||||
const t = token || localStorage.getItem('token') || ''
|
||||
const payload = JSON.parse(atob(t.split('.')[1] || ''))
|
||||
function getUsernameFromToken(token: string) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1] || ''))
|
||||
return payload.username || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// SSE管理器 - 简化封装
|
||||
// SSE管理器
|
||||
const SSEManager = {
|
||||
connection: null as EventSource | null,
|
||||
|
||||
async connect() {
|
||||
if (this.connection) return
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const clientId = getClientIdFromToken(token)
|
||||
if (!token || !clientId) return
|
||||
|
||||
try {
|
||||
// 简化配置获取,失败时使用默认配置
|
||||
const token = await authApi.getToken()
|
||||
if (!token) return
|
||||
|
||||
const clientId = await getClientIdFromToken(token)
|
||||
if (!clientId) return
|
||||
|
||||
let sseUrl = 'http://192.168.1.89:8080/monitor/account/events'
|
||||
try {
|
||||
const resp = await fetch('/api/config/server')
|
||||
@@ -247,125 +263,59 @@ const SSEManager = {
|
||||
const config = await resp.json()
|
||||
sseUrl = config.sseUrl || sseUrl
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
}
|
||||
|
||||
const src = new EventSource(`${sseUrl}?clientId=${clientId}&token=${token}`)
|
||||
this.connection = src
|
||||
|
||||
const username = getUsernameFromToken(token)
|
||||
console.log('=== SSE连接初始化 ===')
|
||||
console.log('连接URL:', sseUrl)
|
||||
console.log('用户名:', username)
|
||||
console.log('客户端ID:', clientId)
|
||||
console.log('预期sessionKey:', `${username}:${clientId}`)
|
||||
console.log('完整连接URL:', `${sseUrl}?clientId=${clientId}&token=${token.substring(0, 20)}...`)
|
||||
|
||||
src.onopen = () => {
|
||||
console.log('=== SSE连接成功 ===')
|
||||
console.log('✅ SSE连接已成功打开')
|
||||
console.log('连接状态:', src.readyState, '(0=CONNECTING, 1=OPEN, 2=CLOSED)')
|
||||
console.log('连接URL:', src.url)
|
||||
console.log('连接时间:', new Date().toLocaleTimeString())
|
||||
}
|
||||
|
||||
src.onmessage = (e) => {
|
||||
console.log('=== SSE消息接收 ===')
|
||||
console.log('📨 SSE收到原始消息:', e)
|
||||
console.log('事件类型:', e.type)
|
||||
console.log('消息数据:', e.data)
|
||||
console.log('接收时间:', new Date().toLocaleTimeString())
|
||||
this.handleMessage(e)
|
||||
}
|
||||
|
||||
src.onerror = (e) => {
|
||||
console.log('=== SSE连接错误 ===')
|
||||
console.error('❌ SSE连接错误:', e)
|
||||
console.log('连接状态:', src.readyState)
|
||||
console.log('错误时间:', new Date().toLocaleTimeString())
|
||||
this.handleError()
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('SSE连接失败:', e.message)
|
||||
src.onopen = () => console.log('SSE连接已建立')
|
||||
src.onmessage = (e) => this.handleMessage(e)
|
||||
src.onerror = () => this.handleError()
|
||||
} catch (e: any) {
|
||||
console.warn('SSE连接失败:', e?.message || e)
|
||||
}
|
||||
},
|
||||
|
||||
handleMessage(e: MessageEvent) {
|
||||
try {
|
||||
console.log('=== SSE消息处理 ===')
|
||||
console.log('原始消息数据:', e.data)
|
||||
console.log('SSE消息:', e.data)
|
||||
|
||||
const payload = JSON.parse(e.data)
|
||||
console.log('解析后的消息:', payload)
|
||||
console.log('事件类型:', payload.type)
|
||||
console.log('消息内容:', payload.message)
|
||||
|
||||
switch (payload.type) {
|
||||
case 'ready':
|
||||
console.log('SSE连接已就绪')
|
||||
break
|
||||
case 'DEVICE_REMOVED':
|
||||
console.log('🚨 收到设备移除事件,正在执行logout')
|
||||
logout()
|
||||
ElMessage.warning('您的设备已被移除,请重新登录')
|
||||
break
|
||||
case 'FORCE_LOGOUT':
|
||||
console.log('🚨 收到强制退出事件,正在执行logout')
|
||||
logout()
|
||||
ElMessage.warning('会话已失效,请重新登录')
|
||||
break
|
||||
case 'PERMISSIONS_UPDATED':
|
||||
console.log('🔄 收到权限更新事件,重新检查权限')
|
||||
checkAuth()
|
||||
break
|
||||
default:
|
||||
console.log('❓ 收到未知SSE事件:', payload.type, payload)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ SSE消息处理失败:', err)
|
||||
console.error('原始数据:', e.data)
|
||||
console.error('SSE消息处理失败:', err, '原始数据:', e.data)
|
||||
}
|
||||
},
|
||||
|
||||
handleError() {
|
||||
console.log('=== SSE错误处理 ===')
|
||||
console.log('准备断开并重连SSE')
|
||||
this.disconnect()
|
||||
setTimeout(() => {
|
||||
console.log('🔄 开始重连SSE')
|
||||
this.connect()
|
||||
}, 3000)
|
||||
setTimeout(() => this.connect(), 3000)
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
if (this.connection) {
|
||||
console.log('=== SSE断开连接 ===')
|
||||
console.log('断开连接URL:', this.connection.url)
|
||||
console.log('断开前状态:', this.connection.readyState)
|
||||
try {
|
||||
this.connection.close()
|
||||
console.log('✅ SSE连接已主动关闭')
|
||||
} catch (e) {
|
||||
console.log('❌ SSE关闭时出错:', e.message)
|
||||
} catch {
|
||||
}
|
||||
this.connection = null
|
||||
} else {
|
||||
console.log('⚠️ 尝试断开SSE,但连接不存在')
|
||||
}
|
||||
},
|
||||
|
||||
// 检查连接状态
|
||||
checkStatus() {
|
||||
if (!this.connection) {
|
||||
console.log('❌ SSE未连接')
|
||||
return false
|
||||
}
|
||||
console.log('SSE连接状态:', this.connection.readyState, this.connection.url)
|
||||
return this.connection.readyState === 1 // 1 = OPEN
|
||||
},
|
||||
|
||||
// 强制重连
|
||||
reconnect() {
|
||||
console.log('🔄 强制重连SSE')
|
||||
this.disconnect()
|
||||
setTimeout(() => this.connect(), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
async function openDeviceManager() {
|
||||
@@ -378,20 +328,19 @@ async function openDeviceManager() {
|
||||
}
|
||||
|
||||
async function fetchDeviceData() {
|
||||
const username = (currentUsername.value || getUsernameFromToken()).trim()
|
||||
if (!username) {
|
||||
if (!currentUsername.value) {
|
||||
ElMessage.warning('未获取到用户名,请重新登录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
deviceLoading.value = true
|
||||
const [quota, list] = await Promise.all([
|
||||
deviceApi.getQuota(username),
|
||||
deviceApi.list(username),
|
||||
deviceApi.getQuota(currentUsername.value),
|
||||
deviceApi.list(currentUsername.value),
|
||||
])
|
||||
deviceQuota.value = quota || { limit: 0, used: 0 }
|
||||
const clientId = getClientIdFromToken()
|
||||
devices.value = (list || []).map(d => ({ ...d, isCurrent: d.deviceId === clientId })) as any
|
||||
deviceQuota.value = quota || {limit: 0, used: 0}
|
||||
const clientId = await getClientIdFromToken()
|
||||
devices.value = (list || []).map(d => ({...d, isCurrent: d.deviceId === clientId})) as any
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '获取设备列表失败')
|
||||
} finally {
|
||||
@@ -401,27 +350,25 @@ async function fetchDeviceData() {
|
||||
|
||||
async function confirmRemoveDevice(row: DeviceItem & { isCurrent?: boolean }) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要移除该设备吗?', '你确定要移除设备吗?', { confirmButtonText: '确定移除', cancelButtonText: '取消', type: 'warning' })
|
||||
|
||||
console.log('正在移除设备:', row.deviceId)
|
||||
await deviceApi.remove({ deviceId: row.deviceId })
|
||||
console.log('✅ 移除设备API调用成功')
|
||||
await ElMessageBox.confirm('确定要移除该设备吗?', '你确定要移除设备吗?', {
|
||||
confirmButtonText: '确定移除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await deviceApi.remove({deviceId: row.deviceId})
|
||||
devices.value = devices.value.filter(d => d.deviceId !== row.deviceId)
|
||||
deviceQuota.value.used = Math.max(0, (deviceQuota.value.used || 0) - 1)
|
||||
|
||||
// 如果是本机设备被移除,执行logout
|
||||
const clientId = getClientIdFromToken()
|
||||
console.log('检查设备ID:', { removed: row.deviceId, current: clientId })
|
||||
const clientId = await getClientIdFromToken()
|
||||
if (row.deviceId === clientId) {
|
||||
console.log('移除的是本机设备,执行logout')
|
||||
await logout()
|
||||
}
|
||||
|
||||
ElMessage.success('已移除设备')
|
||||
} catch (e) {
|
||||
console.error('移除设备失败:', e)
|
||||
ElMessage.error('移除设备失败: ' + (e?.message || '未知错误'))
|
||||
} catch (e: any) {
|
||||
ElMessage.error('移除设备失败: ' + ((e as any)?.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,122 +376,126 @@ onMounted(async () => {
|
||||
showContent()
|
||||
await checkAuth()
|
||||
|
||||
// 添加全局调试函数
|
||||
window.debugSSE = {
|
||||
status: () => SSEManager.checkStatus(),
|
||||
reconnect: () => SSEManager.reconnect(),
|
||||
disconnect: () => SSEManager.disconnect(),
|
||||
getCurrentClientId: () => getClientIdFromToken(),
|
||||
testLogout: () => logout()
|
||||
}
|
||||
console.log('🔧 调试工具已注册到 window.debugSSE')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-config-provider :locale="zhCnLocale">
|
||||
<div id="app-root" class="root">
|
||||
<div class="loading-container" id="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
|
||||
<div class="erp-container">
|
||||
<div class="sidebar">
|
||||
<div class="user-avatar">
|
||||
<img src="/icon/icon.png" alt="logo" />
|
||||
</div>
|
||||
<div class="menu-group-title">电商平台</div>
|
||||
<ul class="menu">
|
||||
<li
|
||||
v-for="item in visibleMenus"
|
||||
:key="item.key"
|
||||
class="menu-item"
|
||||
:class="{ active: activeMenu === item.key }"
|
||||
@click="handleMenuSelect(item.key)"
|
||||
>
|
||||
<span class="menu-text"><span class="menu-icon" :data-k="item.key">{{ item.icon }}</span>{{ item.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div id="app-root" class="root">
|
||||
<div class="loading-container" id="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<NavigationBar
|
||||
:can-go-back="canGoBack"
|
||||
:can-go-forward="canGoForward"
|
||||
:active-menu="activeMenu"
|
||||
@go-back="goBack"
|
||||
@go-forward="goForward"
|
||||
@reload="reloadPage"
|
||||
@user-click="handleUserClick"
|
||||
@open-device="openDeviceManager" />
|
||||
<div class="content-body">
|
||||
<div
|
||||
class="dashboard-home"
|
||||
v-if="!isAuthenticated && (activeMenu === 'rakuten' || activeMenu === 'amazon' || activeMenu === 'zebra')">
|
||||
<div class="icon-container">
|
||||
<img src="/image/111.png" alt="ERP Logo" class="main-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<ZebraDashboard v-if="activeMenu === 'zebra'" />
|
||||
<RakutenDashboard v-else-if="activeMenu === 'rakuten'" />
|
||||
<AmazonDashboard v-else-if="activeMenu === 'amazon'" />
|
||||
<div v-else class="placeholder">
|
||||
<div class="placeholder-card">
|
||||
<div class="placeholder-title">{{ activeMenu.toUpperCase() }} 面板</div>
|
||||
<div class="placeholder-desc">功能开发中...</div>
|
||||
</div>
|
||||
<div class="erp-container">
|
||||
<div class="sidebar">
|
||||
<div class="user-avatar">
|
||||
<img src="/icon/icon.png" alt="logo"/>
|
||||
</div>
|
||||
<div class="menu-group-title">电商平台</div>
|
||||
<ul class="menu">
|
||||
<li
|
||||
v-for="item in visibleMenus"
|
||||
:key="item.key"
|
||||
class="menu-item"
|
||||
:class="{ active: activeMenu === item.key }"
|
||||
@click="handleMenuSelect(item.key)"
|
||||
>
|
||||
<span class="menu-text"><span class="menu-icon" :data-k="item.key">{{ item.icon }}</span>{{
|
||||
item.name
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 认证组件 -->
|
||||
<LoginDialog
|
||||
v-model="showAuthDialog"
|
||||
@login-success="handleLoginSuccess"
|
||||
@show-register="showRegisterDialog" />
|
||||
<div class="main-content">
|
||||
<NavigationBar
|
||||
:can-go-back="canGoBack"
|
||||
:can-go-forward="canGoForward"
|
||||
:active-menu="activeMenu"
|
||||
@go-back="goBack"
|
||||
@go-forward="goForward"
|
||||
@reload="reloadPage"
|
||||
@user-click="handleUserClick"
|
||||
@open-device="openDeviceManager"/>
|
||||
<div class="content-body">
|
||||
<div
|
||||
class="dashboard-home"
|
||||
v-if="showHomeSplash">
|
||||
<div class="icon-container">
|
||||
<img src="/image/111.png" alt="ERP Logo" class="main-icon"/>
|
||||
</div>
|
||||
</div>
|
||||
<keep-alive>
|
||||
<component v-if="activeDashboard" :is="activeDashboard"/>
|
||||
</keep-alive>
|
||||
<div v-if="showPlaceholder" class="placeholder">
|
||||
<div class="placeholder-card">
|
||||
<div class="placeholder-title">{{ activeMenu.toUpperCase() }} 面板</div>
|
||||
<div class="placeholder-desc">功能开发中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RegisterDialog
|
||||
v-model="showRegDialog"
|
||||
@register-success="handleRegisterSuccess"
|
||||
@back-to-login="backToLogin" />
|
||||
<!-- 认证组件 -->
|
||||
<LoginDialog
|
||||
v-model="showAuthDialog"
|
||||
@login-success="handleLoginSuccess"
|
||||
@show-register="showRegisterDialog"/>
|
||||
|
||||
<!-- 设备管理弹框 -->
|
||||
<el-dialog
|
||||
:title="`设备管理 (${deviceQuota.used || 0}/${deviceQuota.limit || 0})`"
|
||||
v-model="showDeviceDialog"
|
||||
width="560px"
|
||||
:close-on-click-modal="false">
|
||||
<div style="margin-bottom: 10px; color:#909399;">当前账号可以授权绑定 {{ deviceQuota.limit }} 台设备</div>
|
||||
<el-table :data="devices" size="small" :loading="deviceLoading" style="width:100%" stripe>
|
||||
<el-table-column label="设备名" min-width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.name || scope.row.deviceId }}</span>
|
||||
<el-tag v-if="scope.row.isCurrent" size="small" type="success" style="margin-left:6px;">本机</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status==='online' ? 'success' : 'info'" size="small">{{ scope.row.status==='online' ? '已登录' : '已登出' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近" min-width="130">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.lastActiveAt || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="scope">
|
||||
<el-button type="text" size="small" style="color:#F56C6C" @click="confirmRemoveDevice(scope.row)">移除设备</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="showDeviceDialog=false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<RegisterDialog
|
||||
v-model="showRegDialog"
|
||||
@register-success="handleRegisterSuccess"
|
||||
@back-to-login="backToLogin"/>
|
||||
|
||||
<!-- 设备管理弹框 -->
|
||||
<el-dialog
|
||||
v-model="showDeviceDialog"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
align-center
|
||||
class="device-dialog">
|
||||
<template #header>
|
||||
<div class="device-dialog-header">
|
||||
<img src="/icon/img.png" alt="devices" class="device-illustration"/>
|
||||
<div class="device-title">设备管理 <span class="device-count">({{ deviceQuota.used || 0 }}/{{ deviceQuota.limit || 0 }})</span></div>
|
||||
<div class="device-subtitle">当前账号可以授权绑定 {{ deviceQuota.limit }} 台设备</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="devices" size="small" :loading="deviceLoading" style="width:100%" stripe>
|
||||
<el-table-column label="设备名" min-width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.name || scope.row.deviceId }}</span>
|
||||
<el-tag v-if="scope.row.isCurrent" size="small" type="success" style="margin-left:6px;">本机</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status==='online' ? 'success' : 'info'" size="small">
|
||||
{{ scope.row.status === 'online' ? '已登录' : '已登出' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近" min-width="130">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.lastActiveAt || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="scope">
|
||||
<el-button type="text" size="small" style="color:#F56C6C" @click="confirmRemoveDevice(scope.row)">
|
||||
移除设备
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="showDeviceDialog=false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
@@ -573,6 +524,7 @@ onMounted(async () => {
|
||||
z-index: 9999;
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
@@ -581,9 +533,14 @@ onMounted(async () => {
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.erp-container {
|
||||
@@ -592,16 +549,28 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
width: 180px;
|
||||
min-width: 180px;
|
||||
flex-shrink: 0;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e8eaec;
|
||||
padding: 16px 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.platform-icons { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||
.picon { width: 28px; height: 28px; object-fit: contain; }
|
||||
|
||||
.platform-icons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.picon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -610,6 +579,7 @@ onMounted(async () => {
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
@@ -617,17 +587,20 @@ onMounted(async () => {
|
||||
object-fit: contain;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.menu-group-title {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 8px 6px 10px;
|
||||
text-align: left; /* “电商平台”四个字靠左 */
|
||||
}
|
||||
|
||||
.menu {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -637,22 +610,53 @@ onMounted(async () => {
|
||||
color: #333333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
background: #ecf5ff !important;
|
||||
color: #409EFF !important;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
.menu-text { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.menu-icon { display: inline-flex; width: 18px; height: 18px; border-radius: 4px; align-items: center; justify-content: center; font-size: 12px; color: #fff; }
|
||||
.menu-icon[data-k="rakuten"] { background: #BF0000; }
|
||||
.menu-icon[data-k="amazon"] { background: #FF9900; color: #1A1A1A; }
|
||||
.menu-icon[data-k="zebra"] { background: #34495e; }
|
||||
.menu-icon[data-k="shopee"] { background: #EE4D2D; }
|
||||
|
||||
.menu-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.menu-icon[data-k="rakuten"] {
|
||||
background: #BF0000;
|
||||
}
|
||||
|
||||
.menu-icon[data-k="amazon"] {
|
||||
background: #FF9900;
|
||||
color: #1A1A1A;
|
||||
}
|
||||
|
||||
.menu-icon[data-k="zebra"] {
|
||||
background: #34495e;
|
||||
}
|
||||
|
||||
.menu-icon[data-k="shopee"] {
|
||||
background: #EE4D2D;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
@@ -671,6 +675,7 @@ onMounted(async () => {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-home {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -680,7 +685,12 @@ onMounted(async () => {
|
||||
background: #ffffff;
|
||||
z-index: 100;
|
||||
}
|
||||
.icon-container { display: flex; justify-content: center; }
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main-icon {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
@@ -696,6 +706,7 @@ onMounted(async () => {
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.placeholder-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e8eaec;
|
||||
@@ -704,6 +715,40 @@ onMounted(async () => {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
color: #2c3e50;
|
||||
}
|
||||
.placeholder-title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
|
||||
.placeholder-desc { font-size: 13px; color: #606266; }
|
||||
|
||||
.placeholder-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.placeholder-desc {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
.device-dialog-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 0 4px 0;
|
||||
margin-left: 40px;
|
||||
}
|
||||
.device-dialog :deep(.el-dialog__header) {
|
||||
text-align: center;
|
||||
}
|
||||
.device-dialog :deep(.el-dialog__body) { padding-top: 0; }
|
||||
.device-illustration {
|
||||
width: 180px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.device-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.device-count { color: #909399; font-weight: 500; }
|
||||
.device-subtitle { font-size: 12px; color: #909399; }
|
||||
</style>
|
||||
|
||||
@@ -44,10 +44,6 @@ interface RegisterResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface CheckUsernameResponse {
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
// 用户登录
|
||||
login(params: LoginRequest) {
|
||||
@@ -68,7 +64,6 @@ export const authApi = {
|
||||
return http
|
||||
.get('/api/check-username', { username })
|
||||
.then(res => {
|
||||
// checkUsername 使用标准格式 {code: 200, data: boolean}
|
||||
if (res && res.code === 200) {
|
||||
return { available: res.data };
|
||||
}
|
||||
@@ -87,4 +82,32 @@ export const authApi = {
|
||||
logout(token: string) {
|
||||
return http.postVoid('/api/logout', { token });
|
||||
},
|
||||
|
||||
// 删除token缓存
|
||||
deleteTokenCache() {
|
||||
return http.postVoid('/api/cache/delete?key=token');
|
||||
},
|
||||
// 保存token到本地数据库
|
||||
saveToken(token: string) {
|
||||
return http.postVoid('/api/cache/save', { key: 'token', value: token });
|
||||
},
|
||||
|
||||
// 从本地数据库获取token
|
||||
getToken(): Promise<string | undefined> {
|
||||
return http.get<any>('/api/cache/get?key=token').then((res: any) => {
|
||||
if (typeof res === 'string') return res;
|
||||
if (res && typeof res === 'object') {
|
||||
if (typeof res.code === 'number') {
|
||||
return res.code === 0 ? (res.data as string | undefined) : undefined;
|
||||
}
|
||||
if (typeof (res as any).data === 'string') return (res as any).data as string;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
|
||||
// 会话引导:检查并恢复会话(返回体各异,这里保持 any)
|
||||
sessionBootstrap() {
|
||||
return http.get<any>('/api/session/bootstrap');
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,13 @@
|
||||
// 极简 HTTP 工具:仅封装 GET/POST,默认指向本地 8081
|
||||
// 极简 HTTP 工具:封装 GET/POST,按路径选择后端服务
|
||||
export type HttpMethod = 'GET' | 'POST';
|
||||
|
||||
const BASE_URL = 'http://localhost:8081';
|
||||
const BASE_CLIENT = 'http://localhost:8081'; // erp_client_sb
|
||||
const BASE_RUOYI = 'http://localhost:8080'; // ruoyi-admin
|
||||
|
||||
function resolveBase(path: string): string {
|
||||
if (path.startsWith('/tool/banma')) return BASE_RUOYI;
|
||||
return BASE_CLIENT;
|
||||
}
|
||||
|
||||
// 将对象转为查询字符串
|
||||
function buildQuery(params?: Record<string, unknown>): string {
|
||||
@@ -17,7 +23,7 @@ function buildQuery(params?: Record<string, unknown>): string {
|
||||
|
||||
// 统一请求入口:自动加上 BASE_URL、JSON 头与错误处理
|
||||
async function request<T>(path: string, options: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
const res = await fetch(`${resolveBase(path)}${path}`, {
|
||||
credentials: 'omit',
|
||||
cache: 'no-store',
|
||||
...options,
|
||||
@@ -44,9 +50,12 @@ export const http = {
|
||||
post<T>(path: string, body?: unknown) {
|
||||
return request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined });
|
||||
},
|
||||
delete<T>(path: string) {
|
||||
return request<T>(path, { method: 'DELETE' });
|
||||
},
|
||||
// 用于无需读取响应体的 POST(如删除/心跳等),从根源避免读取中断
|
||||
postVoid(path: string, body?: unknown) {
|
||||
return fetch(`${BASE_URL}${path}`, {
|
||||
return fetch(`${resolveBase(path)}${path}`, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
credentials: 'omit',
|
||||
@@ -59,7 +68,7 @@ export const http = {
|
||||
},
|
||||
// 文件上传:透传 FormData,不设置 Content-Type 让浏览器自动处理
|
||||
upload<T>(path: string, form: FormData) {
|
||||
const res = fetch(`${BASE_URL}${path}`, {
|
||||
const res = fetch(`${resolveBase(path)}${path}`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
credentials: 'omit',
|
||||
|
||||
@@ -27,23 +27,48 @@ export interface ZebraOrdersResp {
|
||||
|
||||
import { http } from './http';
|
||||
|
||||
export interface BanmaAccount {
|
||||
id?: number;
|
||||
name?: string;
|
||||
username?: string;
|
||||
token?: string;
|
||||
tokenExpireAt?: string | number;
|
||||
isDefault?: number;
|
||||
status?: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
// 斑马 API:与原 zebra-api.js 对齐的接口封装
|
||||
export const zebraApi = {
|
||||
getOrders(params: Record<string, unknown>) {
|
||||
return http.get<ZebraOrdersResp>('/api/banma/orders', params);
|
||||
// 账号管理(ruoyi-admin)
|
||||
getAccounts() {
|
||||
return http.get<{ code?: number; msg?: string; data: BanmaAccount[] }>('/tool/banma/accounts');
|
||||
},
|
||||
saveAccount(body: BanmaAccount) {
|
||||
return http.post<{ id: number }>('/tool/banma/accounts', body);
|
||||
},
|
||||
removeAccount(id: number) {
|
||||
// 用 postVoid 也可,但这里前端未用到,保留以备将来
|
||||
return http.delete<void>(`/tool/banma/accounts/${id}`);
|
||||
},
|
||||
|
||||
// 业务采集(仍走客户端微服务 8081)
|
||||
getShops() {
|
||||
return http.get<{ data?: { list?: Array<{ id: string; shopName: string }> } }>(
|
||||
'/api/banma/shops'
|
||||
);
|
||||
},
|
||||
getOrders(params: { startDate?: string; endDate?: string; page?: number; pageSize?: number; shopIds?: string }) {
|
||||
return http.get<ZebraOrdersResp>('/api/banma/orders', params as unknown as Record<string, unknown>);
|
||||
},
|
||||
|
||||
// 其他功能(客户端微服务)
|
||||
getOrdersByBatch(batchId: string) {
|
||||
return http.get<ZebraOrdersResp>(`/api/banma/orders/batch/${batchId}`);
|
||||
},
|
||||
getLatestOrders() {
|
||||
return http.get<ZebraOrdersResp>('/api/banma/orders/latest');
|
||||
},
|
||||
getShops() {
|
||||
return http.get<{ data?: { list?: Array<{ id: string; shopName: string }> } }>('/api/banma/shops');
|
||||
},
|
||||
refreshToken() {
|
||||
return http.post('/api/banma/refresh-token');
|
||||
},
|
||||
exportAndSaveOrders(exportData: unknown) {
|
||||
return http.post<{ filePath: string }>('/api/banma/export-and-save', exportData);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { amazonApi } from '../../api/amazon'
|
||||
|
||||
// 响应式状态
|
||||
@@ -7,7 +8,6 @@ const loading = ref(false) // 主加载状态
|
||||
const tableLoading = ref(false) // 表格加载状态
|
||||
const progressPercentage = ref(0) // 进度百分比
|
||||
const localProductData = ref<any[]>([]) // 本地产品数据
|
||||
const singleAsin = ref('') // 单个ASIN输入
|
||||
const currentAsin = ref('') // 当前处理的ASIN
|
||||
const genmaiLoading = ref(false) // Genmai Spirit加载状态
|
||||
|
||||
@@ -25,9 +25,27 @@ const paginatedData = computed(() => {
|
||||
return localProductData.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 左侧步骤栏进度
|
||||
const activeStep = computed(() => {
|
||||
// 0 导入/输入 -> 1 采集 -> 2 查看校验 -> 3 导出
|
||||
if (loading.value && progressPercentage.value < 100) return 1
|
||||
if (!localProductData.value.length) return 0
|
||||
if (localProductData.value.length && progressPercentage.value < 100) return 1
|
||||
return 2
|
||||
})
|
||||
|
||||
// 左侧:网站地区 & 待采集队列
|
||||
const region = ref('JP')
|
||||
const regionOptions = [
|
||||
{ label: '日本 (Japan)', value: 'JP', flag: '🇯🇵' },
|
||||
{ label: '美国 (USA)', value: 'US', flag: '🇺🇸' },
|
||||
{ label: '中国 (China)', value: 'CN', flag: '🇨🇳' },
|
||||
]
|
||||
const pendingAsins = ref<string[]>([])
|
||||
|
||||
// 通用消息提示
|
||||
function showMessage(message: string, type: 'success' | 'warning' | 'error' | 'info' = 'info') {
|
||||
alert(`[${type.toUpperCase()}] ${message}`)
|
||||
ElMessage({ message, type })
|
||||
}
|
||||
|
||||
// Excel文件上传处理 - 主要业务逻辑入口
|
||||
@@ -46,8 +64,9 @@ async function processExcelFile(file: File) {
|
||||
return
|
||||
}
|
||||
|
||||
showMessage(`成功解析 ${asinList.length} 个ASIN`, 'success')
|
||||
await batchGetProductInfo(asinList)
|
||||
// 存入待采集队列,等待用户点击“获取数据”再开始
|
||||
pendingAsins.value = asinList
|
||||
showMessage(`成功解析 ${asinList.length} 个ASIN,点击“获取数据”开始采集`, 'success')
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '处理文件失败', 'error')
|
||||
} finally {
|
||||
@@ -144,26 +163,18 @@ async function batchGetProductInfo(asinList: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// 单个ASIN查询
|
||||
async function searchSingleAsin() {
|
||||
const asin = singleAsin.value.trim()
|
||||
if (!asin) return
|
||||
|
||||
localProductData.value = []
|
||||
// 点击开始采集
|
||||
async function startQueuedFetch() {
|
||||
if (!pendingAsins.value.length) {
|
||||
showMessage('请先导入ASIN列表', 'warning')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
|
||||
tableLoading.value = true
|
||||
try {
|
||||
const resp = await amazonApi.getProductsBatch([asin], `SINGLE_${Date.now()}`)
|
||||
if (resp?.data?.products?.length > 0) {
|
||||
localProductData.value = resp.data.products
|
||||
showMessage('查询成功', 'success')
|
||||
singleAsin.value = ''
|
||||
} else {
|
||||
showMessage('未找到商品信息', 'warning')
|
||||
}
|
||||
} catch (e: any) {
|
||||
showMessage(e?.message || '查询失败', 'error')
|
||||
await batchGetProductInfo(pendingAsins.value)
|
||||
} finally {
|
||||
tableLoading.value = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
@@ -255,99 +266,138 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="amazon-root">
|
||||
<div class="main-container">
|
||||
|
||||
<!-- 文件导入和操作区域 -->
|
||||
<div class="import-section" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop" :class="{ 'drag-active': dragActive }">
|
||||
<div class="import-controls">
|
||||
<!-- 文件上传按钮 -->
|
||||
<el-button type="primary" :disabled="loading" @click="openAmazonUpload">
|
||||
📂 {{ loading ? '处理中...' : '导入ASIN列表' }}
|
||||
</el-button>
|
||||
<input ref="amazonUpload" style="display:none" type="file" accept=".csv,.txt,.xls,.xlsx" @change="handleExcelUpload" :disabled="loading" />
|
||||
|
||||
<!-- 单个ASIN输入 -->
|
||||
<div class="single-input">
|
||||
<input class="text" v-model="singleAsin" placeholder="输入单个ASIN" :disabled="loading" @keyup.enter="searchSingleAsin" />
|
||||
<el-button type="info" :disabled="!singleAsin || loading" @click="searchSingleAsin">查询</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="action-buttons">
|
||||
<el-button type="danger" :disabled="!loading" @click="stopFetch">停止获取</el-button>
|
||||
<el-button type="success" :disabled="!localProductData.length || loading" @click="exportToExcel">导出Excel</el-button>
|
||||
<el-button type="warning" :loading="genmaiLoading" @click="openGenmaiSpirit">{{ genmaiLoading ? '启动中...' : '跟卖精灵' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 进度条显示 -->
|
||||
<div class="progress-section" v-if="loading">
|
||||
<div class="progress-box">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
|
||||
<div class="body-layout">
|
||||
<!-- 左侧步骤栏 -->
|
||||
<aside class="steps-sidebar">
|
||||
<div class="steps-title">查询步骤:</div>
|
||||
<div class="steps-flow">
|
||||
<!-- 1 -->
|
||||
<div class="flow-item">
|
||||
<div class="step-index">1</div>
|
||||
<div class="step-card">
|
||||
<div class="step-header"><div class="title">导入ASIN</div></div>
|
||||
<div class="desc">仅支持包含 ASIN 列的 CSV/Excel 文档</div>
|
||||
<div class="links">
|
||||
<a class="link" @click.prevent>点击查看示例</a>
|
||||
<span class="sep">|</span>
|
||||
<a class="link" @click.prevent>点击下载模板</a>
|
||||
</div>
|
||||
<div class="dropzone" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openAmazonUpload">
|
||||
<div class="dz-el-icon">📤</div>
|
||||
<div class="dz-text">点击或将文件拖拽到这里上传</div>
|
||||
<div class="dz-sub">支持 .csv .txt .xls .xlsx</div>
|
||||
</div>
|
||||
<input ref="amazonUpload" style="display:none" type="file" accept=".csv,.txt,.xls,.xlsx" @change="handleExcelUpload" :disabled="loading" />
|
||||
</div>
|
||||
<div class="progress-text">{{ progressPercentage }}%</div>
|
||||
</div>
|
||||
<div class="current-status" v-if="currentAsin">{{ currentAsin }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 2 网站地区 -->
|
||||
<div class="flow-item">
|
||||
<div class="step-index">2</div>
|
||||
<div class="step-card">
|
||||
<div class="step-header"><div class="title">网站地区</div></div>
|
||||
<div class="desc">请选择目标网站地区,如:日本区</div>
|
||||
<el-select v-model="region" placeholder="选择地区" size="small" style="width: 100%">
|
||||
<el-option v-for="opt in regionOptions" :key="opt.value" :label="opt.label" :value="opt.value">
|
||||
<span style="margin-right:6px">{{ opt.flag }}</span>{{ opt.label }}
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 3 获取数据 -->
|
||||
<div class="flow-item">
|
||||
<div class="step-index">3</div>
|
||||
<div class="step-card">
|
||||
<div class="step-header"><div class="title">获取数据</div></div>
|
||||
<div class="desc">导入表格后,点击下方按钮开始获取ASIN数据</div>
|
||||
<el-button size="small" class="w100 btn-blue" :disabled="!pendingAsins.length || loading" @click="startQueuedFetch">{{ loading ? '处理中...' : '获取数据' }}</el-button>
|
||||
<div class="mini-hint" v-if="pendingAsins.length">已导入 {{ pendingAsins.length }} 个 ASIN</div>
|
||||
<!-- 左侧不再显示进度条 -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- 4 -->
|
||||
<div class="flow-item">
|
||||
<div class="step-index">4</div>
|
||||
<div class="step-card">
|
||||
<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" @click="exportToExcel">导出Excel</el-button>
|
||||
|
||||
<!-- 数据显示区域 -->
|
||||
<div class="table-container">
|
||||
<!-- 数据表格(无数据时也显示表头) -->
|
||||
<div class="table-section">
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ASIN</th>
|
||||
<th>卖家/配送方</th>
|
||||
<th>当前售价</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in paginatedData" :key="row.asin">
|
||||
<td>{{ row.asin }}</td>
|
||||
<td>
|
||||
<div class="seller-info">
|
||||
<span class="seller">{{ row.seller || '无货' }}</span>
|
||||
<span v-if="row.shipper && row.shipper !== row.seller" class="shipper">/ {{ row.shipper }}</span>
|
||||
<el-button size="small" class="w100 btn-blue" plain :disabled="!loading" @click="stopFetch">停止获取</el-button>
|
||||
<el-button size="small" class="w100 btn-blue" :loading="genmaiLoading" @click="openGenmaiSpirit">{{ genmaiLoading ? '启动中...' : '跟卖精灵' }}</el-button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧主区域 -->
|
||||
<section class="content-panel">
|
||||
|
||||
<!-- 数据显示区域 -->
|
||||
<div class="table-container">
|
||||
<div class="table-section">
|
||||
<!-- 表格上方进度条(与乐天一致) -->
|
||||
<div class="progress-section" v-if="loading">
|
||||
<div class="progress-box">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="price">{{ row.price || '无货' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="paginatedData.length === 0" class="empty-abs">
|
||||
<div class="empty-container">
|
||||
<div class="empty-icon">📄</div>
|
||||
<div class="empty-text">暂无数据,请导入ASIN列表</div>
|
||||
<div class="progress-text">{{ progressPercentage }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ASIN</th>
|
||||
<th>卖家/配送方</th>
|
||||
<th>当前售价</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in paginatedData" :key="row.asin">
|
||||
<td>{{ row.asin }}</td>
|
||||
<td>
|
||||
<div class="seller-info">
|
||||
<span class="seller">{{ row.seller || '无货' }}</span>
|
||||
<span v-if="row.shipper && row.shipper !== row.seller" class="shipper">/ {{ row.shipper }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="price">{{ row.price || '无货' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="paginatedData.length === 0" class="empty-abs">
|
||||
<div v-if="tableLoading || loading" class="empty-container">
|
||||
<div class="spinner">⟳</div>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
<div v-else class="empty-container">
|
||||
<div class="empty-icon">📄</div>
|
||||
<div class="empty-text">暂无数据,请导入ASIN列表</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pagination-fixed" >
|
||||
<el-pagination
|
||||
background
|
||||
:current-page="currentPage"
|
||||
:page-sizes="[15,30,50,100]"
|
||||
:page-size="pageSize"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="localProductData.length"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格加载遮罩 -->
|
||||
<div v-if="tableLoading && paginatedData.length === 0" class="table-loading">
|
||||
<div class="spinner">⟳</div>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页器 -->
|
||||
<div class="pagination-fixed" >
|
||||
<el-pagination
|
||||
background
|
||||
:current-page="currentPage"
|
||||
:page-sizes="[15,30,50,100]"
|
||||
:page-size="pageSize"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="localProductData.length"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,6 +406,45 @@ onMounted(async () => {
|
||||
<style scoped>
|
||||
.amazon-root { position: absolute; inset: 0; background: #f5f5f5; padding: 12px; box-sizing: border-box; }
|
||||
.main-container { background: #fff; border-radius: 4px; padding: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); height: 100%; display: flex; flex-direction: column; }
|
||||
.body-layout { display: flex; gap: 12px; height: 100%; }
|
||||
.steps-sidebar { width: 220px; background: #fff; border: 1px solid #ebeef5; border-radius: 6px; padding: 10px; height: 100%; flex-shrink: 0; }
|
||||
.steps-title { font-size: 14px; font-weight: 600; color: #303133; margin-bottom: 8px; }
|
||||
.steps-flow { position: relative; }
|
||||
.steps-flow:before { content: ''; position: absolute; left: 12px; top: 0; bottom: 0; width: 2px; background: #e5e7eb; }
|
||||
.flow-item { position: relative; display: grid; grid-template-columns: 24px 1fr; gap: 10px; padding: 8px 0; }
|
||||
.flow-item + .flow-item { border-top: 1px dashed #ebeef5; }
|
||||
.flow-item .step-index { position: static; width: 24px; height: 24px; line-height: 24px; text-align: center; border-radius: 50%; background: #1677FF; color: #fff; font-size: 12px; font-weight: 600; margin-top: 2px; }
|
||||
.flow-item:after { display: none; }
|
||||
.step-card { border: none; border-radius: 0; padding: 0; background: transparent; }
|
||||
.step-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.title { font-size: 13px; font-weight: 600; color: #303133; text-align: left; }
|
||||
.desc { font-size: 12px; color: #909399; margin-bottom: 8px; text-align: left; }
|
||||
.links { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
|
||||
.link { color: #409EFF; cursor: pointer; font-size: 12px; }
|
||||
.sep { color: #dcdfe6; }
|
||||
.content-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
.left-controls { margin-top: 10px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.dropzone { border: 1px dashed #c0c4cc; border-radius: 6px; padding: 16px; text-align: center; cursor: pointer; background: #fafafa; }
|
||||
.dropzone:hover { background: #f6fbff; border-color: #409EFF; }
|
||||
.dz-icon { font-size: 20px; margin-bottom: 6px; }
|
||||
.dz-text { color: #303133; font-size: 13px; }
|
||||
.dz-sub { color: #909399; font-size: 12px; }
|
||||
.single-input.left { display: flex; gap: 8px; }
|
||||
.action-buttons.column { display: flex; flex-direction: column; gap: 8px; }
|
||||
.form-row { margin-bottom: 10px; }
|
||||
.label { display: block; font-size: 12px; color: #606266; margin-bottom: 6px; }
|
||||
|
||||
/* 统一左侧控件宽度与主色 */
|
||||
.steps-sidebar :deep(.el-date-editor),
|
||||
.steps-sidebar :deep(.el-range-editor.el-input__wrapper),
|
||||
.steps-sidebar :deep(.el-input),
|
||||
.steps-sidebar :deep(.el-input__wrapper),
|
||||
.steps-sidebar :deep(.el-select) { width: 100%; box-sizing: border-box; }
|
||||
.btn-blue { background: #1677FF; border-color: #1677FF; color: #fff; }
|
||||
.btn-blue:disabled { background: #a6c8ff; border-color: #a6c8ff; color: #fff; }
|
||||
.w100 { width: 100%; }
|
||||
.steps-sidebar :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.import-section { margin-bottom: 10px; flex-shrink: 0; }
|
||||
.import-controls { display: flex; align-items: flex-end; gap: 20px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.single-input { display: flex; align-items: center; gap: 8px; }
|
||||
@@ -363,16 +452,21 @@ onMounted(async () => {
|
||||
.text:focus { border-color: #409EFF; }
|
||||
.text:disabled { background: #f5f7fa; color: #c0c4cc; }
|
||||
.action-buttons { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.progress-section { margin: 15px 0 10px 0; }
|
||||
.progress-box { padding: 8px 0; }
|
||||
.progress-container { display: flex; align-items: center; position: relative; padding-right: 50px; margin-bottom: 8px; }
|
||||
.progress-bar { flex: 1; height: 3px; background: #ebeef5; border-radius: 2px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, #409EFF, #66b1ff); border-radius: 2px; transition: width 0.3s ease; }
|
||||
.progress-text { position: absolute; right: 0; font-size: 13px; color: #409EFF; font-weight: 500; }
|
||||
.progress-section { margin: 12px 12px 6px 12px; }
|
||||
.progress-box { padding: 4px 0; }
|
||||
.progress-container { display: flex; align-items: center; gap: 8px; }
|
||||
.progress-bar { flex: 1; height: 6px; background: #e3eeff; border-radius: 999px; overflow: hidden; }
|
||||
.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; }
|
||||
.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; }
|
||||
.table-wrapper { scrollbar-width: thin; scrollbar-color: #c0c4cc transparent; }
|
||||
.table-wrapper::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.table-wrapper::-webkit-scrollbar-track { background: transparent; }
|
||||
.table-wrapper::-webkit-scrollbar-thumb { background: #c0c4cc; border-radius: 3px; }
|
||||
.table-wrapper:hover::-webkit-scrollbar-thumb { background: #a8abb2; }
|
||||
.table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
|
||||
.table th { background: #f5f7fa; color: #909399; font-weight: 600; padding: 12px 8px; border-bottom: 2px solid #ebeef5; text-align: left; }
|
||||
.table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
|
||||
|
||||
@@ -31,7 +31,6 @@ async function handleAuth() {
|
||||
authLoading.value = true
|
||||
try {
|
||||
const data = await authApi.login(authForm.value)
|
||||
localStorage.setItem('token', data.token)
|
||||
emit('loginSuccess', {
|
||||
token: data.token,
|
||||
user: {
|
||||
|
||||
@@ -57,7 +57,7 @@ defineEmits<Emits>()
|
||||
|
||||
<style scoped>
|
||||
.top-navbar {
|
||||
height: 48px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -94,12 +94,12 @@ defineEmits<Emits>()
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -131,13 +131,13 @@ defineEmits<Emits>()
|
||||
}
|
||||
|
||||
.nav-btn-round {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -30,6 +30,41 @@ const progressStarted = ref(false)
|
||||
const progressPercentage = ref(0)
|
||||
const totalProducts = ref(0)
|
||||
const processedProducts = ref(0)
|
||||
// 进度头部文案(展示在进度条上方)
|
||||
const successCount = computed(() =>
|
||||
allProducts.value.filter(
|
||||
(p: any) => p && p.mapRecognitionLink && String(p.mapRecognitionLink).trim() !== ''
|
||||
).length
|
||||
)
|
||||
const progressHeader = computed(() => {
|
||||
if (!progressStarted.value) return ''
|
||||
if (progressPercentage.value >= 100) {
|
||||
return `数据获取完成(成功获取 ${successCount.value} 个) 左侧操作栏点击“导出数据”按钮,可导出为Excel文件`
|
||||
}
|
||||
return '数据获取中'
|
||||
})
|
||||
|
||||
// 左侧步骤栏进度
|
||||
const activeStep = computed(() => {
|
||||
// 0 导入/输入 -> 1 获取1688 -> 2 查看校验 -> 3 导出
|
||||
if (loading.value && progressPercentage.value < 100) return 1
|
||||
if (!allProducts.value.length) return 0
|
||||
if (allProducts.value.length && progressPercentage.value < 100) return 1
|
||||
// 数据已经准备好
|
||||
return 2
|
||||
})
|
||||
|
||||
// 左侧:上传文件名与地区
|
||||
const selectedFileName = ref('')
|
||||
const pendingFile = ref<File | null>(null)
|
||||
const region = ref('JP')
|
||||
const regionOptions = [
|
||||
{ label: '日本 (Japan)', value: 'JP', flag: '🇯🇵' },
|
||||
{ label: '美国 (USA)', value: 'US', flag: '🇺🇸' },
|
||||
{ label: '中国 (China)', value: 'CN', flag: '🇨🇳' },
|
||||
]
|
||||
// 获取数据筛选:查询日期
|
||||
const dateRange = ref<string[] | null>(null)
|
||||
|
||||
function handleSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
@@ -89,7 +124,7 @@ function beforeUpload(file: File) {
|
||||
|
||||
async function processFile(file: File) {
|
||||
if (!beforeUpload(file)) return
|
||||
progressStarted.value = true
|
||||
progressStarted.value = false
|
||||
progressPercentage.value = 0
|
||||
totalProducts.value = 0
|
||||
processedProducts.value = 0
|
||||
@@ -97,20 +132,10 @@ async function processFile(file: File) {
|
||||
tableLoading.value = true
|
||||
currentBatchId.value = `RAKUTEN_${Date.now()}`
|
||||
try {
|
||||
const resp = await rakutenApi.getProducts({file, batchId: currentBatchId.value})
|
||||
const products = (resp.products || []).map(p => ({...p, skuPrices: parseSkuPrices(p)}))
|
||||
allProducts.value = products
|
||||
statusMessage.value = `已获取 ${allProducts.value.length} 个乐天商品`
|
||||
|
||||
const needSearch = allProducts.value.filter(p => p && p.imgUrl && !p.mapRecognitionLink)
|
||||
if (needSearch.length > 0) {
|
||||
statusType.value = 'info'
|
||||
statusMessage.value = `已获取 ${allProducts.value.length} 个乐天商品,正在自动获取1688数据...`
|
||||
await startBatch1688Search(needSearch)
|
||||
} else {
|
||||
statusType.value = 'success'
|
||||
statusMessage.value = `已获取 ${allProducts.value.length} 个乐天商品,所有数据已完整!`
|
||||
}
|
||||
pendingFile.value = file
|
||||
selectedFileName.value = file.name
|
||||
statusType.value = 'info'
|
||||
statusMessage.value = `文件已准备:${file.name},点击“获取数据”开始解析并识图`
|
||||
} catch (e: any) {
|
||||
statusMessage.value = e?.message || '上传失败'
|
||||
statusType.value = 'error'
|
||||
@@ -153,17 +178,9 @@ async function searchSingleShop() {
|
||||
try {
|
||||
const resp = await rakutenApi.getProducts({shopName: shop, batchId: currentBatchId.value})
|
||||
allProducts.value = (resp.products || []).filter((p: any) => p.originalShopName === shop).map(p => ({ ...p, skuPrices: parseSkuPrices(p) }))
|
||||
statusMessage.value = `店铺 ${shop} 共 ${allProducts.value.length} 条`
|
||||
statusType.value = 'info'
|
||||
statusMessage.value = `店铺 ${shop} 共 ${allProducts.value.length} 条,点击“获取数据”开始识图`
|
||||
singleShopName.value = ''
|
||||
|
||||
const needSearch = allProducts.value.filter(p => p && p.imgUrl && !p.mapRecognitionLink)
|
||||
if (needSearch.length > 0) {
|
||||
await startBatch1688Search(needSearch)
|
||||
} else if (allProducts.value.length > 0) {
|
||||
statusType.value = 'success'
|
||||
statusMessage.value = `店铺 ${shop} 的数据已加载完成,所有1688链接都已存在!`
|
||||
progressPercentage.value = 100
|
||||
}
|
||||
} catch (e: any) {
|
||||
statusMessage.value = e?.message || '查询失败'
|
||||
statusType.value = 'error'
|
||||
@@ -173,6 +190,41 @@ async function searchSingleShop() {
|
||||
}
|
||||
}
|
||||
|
||||
// 点击“获取数据”触发识图
|
||||
async function handleStartSearch() {
|
||||
// 如果存在待解析文件,先请求后端解析再进入识图
|
||||
if (pendingFile.value) {
|
||||
try {
|
||||
loading.value = true
|
||||
tableLoading.value = true
|
||||
currentBatchId.value = `RAKUTEN_${Date.now()}`
|
||||
// 清空旧表格数据,开始新的获取流程
|
||||
allProducts.value = []
|
||||
progressStarted.value = true
|
||||
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)}))
|
||||
allProducts.value = products
|
||||
pendingFile.value = null
|
||||
} catch (e) {
|
||||
statusType.value = 'error'
|
||||
statusMessage.value = '解析失败,请重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
const items = allProducts.value.filter(p => p && p.imgUrl && !p.mapRecognitionLink)
|
||||
if (items.length === 0) {
|
||||
statusType.value = 'warning'
|
||||
statusMessage.value = '没有可识图的商品,请先导入或查询店铺'
|
||||
return
|
||||
}
|
||||
await startBatch1688Search(items)
|
||||
}
|
||||
|
||||
function stopTask() {
|
||||
loading.value = false
|
||||
tableLoading.value = false
|
||||
@@ -195,15 +247,15 @@ async function startBatch1688Search(products: any[]) {
|
||||
processedProducts.value = 0
|
||||
progressStarted.value = true
|
||||
progressPercentage.value = 0
|
||||
// 开始阶段不显示提示文案,仅显示进度条
|
||||
statusType.value = 'info'
|
||||
statusMessage.value = `正在获取1688数据,共 ${totalProducts.value} 个商品...`
|
||||
statusMessage.value = ''
|
||||
await serialSearch1688(items)
|
||||
|
||||
if (processedProducts.value >= totalProducts.value) {
|
||||
progressPercentage.value = 100
|
||||
statusType.value = 'success'
|
||||
const successCount = allProducts.value.filter(p => p && p.mapRecognitionLink && String(p.mapRecognitionLink).trim() !== '').length
|
||||
statusMessage.value = `成功获取 ${successCount} 个`
|
||||
statusMessage.value = ''
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
@@ -264,130 +316,183 @@ onMounted(loadLatest)
|
||||
<template>
|
||||
<div class="rakuten-root">
|
||||
<div class="main-container">
|
||||
<div class="body-layout">
|
||||
<!-- 左侧步骤栏 -->
|
||||
<aside class="steps-sidebar">
|
||||
<div class="steps-title">查询步骤:</div>
|
||||
|
||||
<!-- 文件导入和操作区域 -->
|
||||
<div class="import-section" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop" :class="{ 'drag-active': dragActive }">
|
||||
<div class="import-controls">
|
||||
<!-- 文件上传按钮 -->
|
||||
<el-button type="primary" :disabled="loading" @click="openRakutenUpload">
|
||||
📂 {{ loading ? '处理中...' : '导入店铺名列表' }}
|
||||
</el-button>
|
||||
<input ref="uploadInputRef" style="display:none" type="file" accept=".xlsx,.xls" @change="handleExcelUpload"
|
||||
:disabled="loading"/>
|
||||
|
||||
<!-- 单个店铺名输入 -->
|
||||
<div class="single-input">
|
||||
<el-input v-model="singleShopName" placeholder="输入单个店铺名" :disabled="loading"
|
||||
@keyup.enter="searchSingleShop" style="width: 140px"/>
|
||||
<el-button type="info" :disabled="!singleShopName || loading" @click="searchSingleShop">查询</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="action-buttons">
|
||||
<el-button type="danger" :disabled="!loading" @click="stopTask">停止获取</el-button>
|
||||
<el-button type="success" :disabled="!allProducts.length || loading" @click="exportToExcel">导出Excel
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条显示 -->
|
||||
<div class="progress-section" v-if="progressStarted">
|
||||
<div class="progress-box">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
|
||||
</div>
|
||||
<div class="progress-text">{{ progressPercentage }}%</div>
|
||||
<div class="steps-flow">
|
||||
<!-- Step 1 导入乐天店铺 -->
|
||||
<div class="flow-item">
|
||||
<div class="step-index">1</div>
|
||||
<div class="step-card">
|
||||
<div class="step-header">
|
||||
<div class="title">导入乐天店铺</div>
|
||||
</div>
|
||||
<div class="desc">请导入店铺信息,仅限 Excel 文件;表格第一列必须为乐天店铺名。</div>
|
||||
<div class="links">
|
||||
<a class="link" @click.prevent>点击查看示例</a>
|
||||
<span class="sep">|</span>
|
||||
<a class="link" @click.prevent>点击下载模板</a>
|
||||
</div>
|
||||
<div class="current-status" v-if="statusMessage">{{ statusMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据显示区域 -->
|
||||
<div class="table-container">
|
||||
<!-- 数据表格(无数据时也显示表头) -->
|
||||
<div class="table-section">
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>店铺名</th>
|
||||
<th>商品链接</th>
|
||||
<th>商品图片</th>
|
||||
<th>排名</th>
|
||||
<th>商品标题</th>
|
||||
<th>价格</th>
|
||||
<th>1688识图链接</th>
|
||||
<th>1688运费</th>
|
||||
<th>1688中位价</th>
|
||||
<th>1688最低价</th>
|
||||
<th>1688中间价</th>
|
||||
<th>1688最高价</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in paginatedData" :key="row.productUrl + (row.productTitle || '')">
|
||||
<td class="truncate shop-col" :title="row.originalShopName">{{ row.originalShopName }}</td>
|
||||
<td class="truncate url-col">
|
||||
<el-input v-if="row.productUrl" :value="row.productUrl" readonly @click="$event.target.select()" size="small"/>
|
||||
<span v-else>--</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="image-container" v-if="row.imgUrl">
|
||||
<img :src="row.imgUrl" class="thumb" alt="thumb"/>
|
||||
<div class="dropzone" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openRakutenUpload" :class="{ disabled: loading }">
|
||||
<div class="dz-el-icon">📤</div>
|
||||
<div class="dz-text">点击或将文件拖拽到这里上传</div>
|
||||
<div class="dz-sub">支持扩展名:.xls .xlsx .numbers</div>
|
||||
<div class="dz-sub">文件单列:1/1</div>
|
||||
</div>
|
||||
<input ref="uploadInputRef" style="display:none" type="file" accept=".xlsx,.xls" @change="handleExcelUpload" :disabled="loading"/>
|
||||
<div v-if="selectedFileName" class="file-chip">
|
||||
<span class="dot"></span>
|
||||
<span class="name">{{ selectedFileName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Step 2 网站地区 -->
|
||||
<div class="flow-item">
|
||||
<div class="step-index">2</div>
|
||||
<div class="step-card">
|
||||
<div class="step-header">
|
||||
<div class="title">网站地区</div>
|
||||
</div>
|
||||
<div class="desc">请选择目标网站地区,如:日本区。</div>
|
||||
<el-select v-model="region" placeholder="选择地区" size="small" style="width: 100%">
|
||||
<el-option v-for="opt in regionOptions" :key="opt.value" :label="opt.label" :value="opt.value">
|
||||
<span style="margin-right:6px">{{ opt.flag }}</span>{{ opt.label }}
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 获取数据 -->
|
||||
<div class="flow-item">
|
||||
<div class="step-index">3</div>
|
||||
<div class="step-card">
|
||||
<div class="step-header">
|
||||
<div class="title">获取数据</div>
|
||||
</div>
|
||||
<div class="desc">导入表格后,点击下方按钮开始获取店铺商品数据</div>
|
||||
<el-button size="small" class="w100 btn-blue" :loading="loading" @click="handleStartSearch" :disabled="loading || (!pendingFile && allProducts.length === 0)">获取数据</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4 导出数据 -->
|
||||
<div class="flow-item">
|
||||
<div class="step-index">4</div>
|
||||
<div class="step-card">
|
||||
<div class="step-header">
|
||||
<div class="title">导出数据</div>
|
||||
</div>
|
||||
<div class="mini-hint">点击下方按钮导出为 Excel</div>
|
||||
<el-button size="small" class="w100 btn-blue" :disabled="!allProducts.length || loading" @click="exportToExcel">导出数据</el-button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧主区域 -->
|
||||
<section class="content-panel">
|
||||
<!-- 数据显示区域 -->
|
||||
<div class="table-container">
|
||||
<div class="table-section">
|
||||
<!-- 表格上方进度条(移动到表格容器内部) -->
|
||||
<div v-if="progressStarted" class="progress-head">
|
||||
<div class="progress-title">
|
||||
<span v-if="progressPercentage>=100" class="ok-badge">●</span>
|
||||
<span class="title-text">{{ progressHeader }}</span>
|
||||
</div>
|
||||
<div class="progress-section">
|
||||
<div class="progress-box">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
|
||||
</div>
|
||||
<div class="progress-text">{{ progressPercentage }}%</div>
|
||||
</div>
|
||||
<span v-else>无图片</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="row.ranking">{{ row.ranking }}</span>
|
||||
<span v-else>--</span>
|
||||
</td>
|
||||
<td class="truncate" :title="row.productTitle">{{ row.productTitle || '--' }}</td>
|
||||
<td>{{ row.price ? row.price + '円' : '--' }}</td>
|
||||
<td class="truncate url-col">
|
||||
<el-input v-if="row.mapRecognitionLink" :value="row.mapRecognitionLink" readonly @click="$event.target.select()" size="small"/>
|
||||
<span v-else-if="row.searching1688">搜索中...</span>
|
||||
<span v-else>--</span>
|
||||
</td>
|
||||
<td>{{ row.freight ?? '--' }}</td>
|
||||
<td>{{ row.median ?? '--' }}</td>
|
||||
<td>{{ row.skuPrices?.[0] ?? '--' }}</td>
|
||||
<td>{{ row.skuPrices?.[Math.floor(row.skuPrices.length / 2)] ?? '--' }}</td>
|
||||
<td>{{ row.skuPrices?.[row.skuPrices.length - 1] ?? '--' }}</td>
|
||||
</tr>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>店铺名</th>
|
||||
<th>商品链接</th>
|
||||
<th>商品图片</th>
|
||||
<th>排名</th>
|
||||
<th>商品标题</th>
|
||||
<th>价格</th>
|
||||
<th>1688识图链接</th>
|
||||
<th>1688运费</th>
|
||||
<th>1688中位价</th>
|
||||
<th>1688最低价</th>
|
||||
<th>1688中间价</th>
|
||||
<th>1688最高价</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in paginatedData" :key="row.productUrl + (row.productTitle || '')">
|
||||
<td class="truncate shop-col" :title="row.originalShopName">{{ row.originalShopName }}</td>
|
||||
<td class="truncate url-col">
|
||||
<el-input v-if="row.productUrl" :value="row.productUrl" readonly @click="$event.target.select()" size="small"/>
|
||||
<span v-else>--</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="image-container" v-if="row.imgUrl">
|
||||
<img :src="row.imgUrl" class="thumb" alt="thumb"/>
|
||||
</div>
|
||||
<span v-else>无图片</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="row.ranking">{{ row.ranking }}</span>
|
||||
<span v-else>--</span>
|
||||
</td>
|
||||
<td class="truncate" :title="row.productTitle">{{ row.productTitle || '--' }}</td>
|
||||
<td>{{ row.price ? row.price + '円' : '--' }}</td>
|
||||
<td class="truncate url-col">
|
||||
<el-input v-if="row.mapRecognitionLink" :value="row.mapRecognitionLink" readonly @click="$event.target.select()" size="small"/>
|
||||
<span v-else-if="row.searching1688">搜索中...</span>
|
||||
<span v-else>--</span>
|
||||
</td>
|
||||
<td>{{ row.freight ?? '--' }}</td>
|
||||
<td>{{ row.median ?? '--' }}</td>
|
||||
<td>{{ row.skuPrices?.[0] ?? '--' }}</td>
|
||||
<td>{{ row.skuPrices?.[Math.floor(row.skuPrices.length / 2)] ?? '--' }}</td>
|
||||
<td>{{ row.skuPrices?.[row.skuPrices.length - 1] ?? '--' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="paginatedData.length === 0" class="empty-abs">
|
||||
<div v-if="tableLoading || loading" class="empty-container">
|
||||
<div class="spinner">⟳</div>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
<div v-else class="empty-container">
|
||||
<div class="empty-icon">📄</div>
|
||||
<div class="empty-text">暂无数据,请导入店铺名列表</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
<div v-if="paginatedData.length === 0" class="empty-abs">
|
||||
<div class="empty-container">
|
||||
<div class="empty-icon">📄</div>
|
||||
<div class="empty-text">暂无数据,请导入店铺名列表</div>
|
||||
<div class="pagination-fixed" >
|
||||
<el-pagination
|
||||
background
|
||||
:current-page="currentPage"
|
||||
:page-sizes="[15,30,50,100]"
|
||||
:page-size="pageSize"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="allProducts.length"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格加载遮罩 -->
|
||||
<div v-if="tableLoading && paginatedData.length === 0" class="table-loading">
|
||||
<div class="spinner">⟳</div>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页器 -->
|
||||
<div class="pagination-fixed" >
|
||||
<el-pagination
|
||||
background
|
||||
:current-page="currentPage"
|
||||
:page-sizes="[15,30,50,100]"
|
||||
:page-size="pageSize"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="allProducts.length"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -412,6 +517,58 @@ onMounted(loadLatest)
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.body-layout { display: flex; gap: 12px; height: 100%; }
|
||||
.steps-sidebar { width: 220px; background: #fff; border: 1px solid #ebeef5; border-radius: 6px; padding: 10px; height: 100%; flex-shrink: 0; }
|
||||
.steps-title { font-size: 14px; font-weight: 600; color: #303133; margin-bottom: 8px; }
|
||||
|
||||
/* 卡片式步骤,与示例一致 */
|
||||
.steps-flow { position: relative; }
|
||||
.steps-flow:before { content: ''; position: absolute; left: 12px; top: 0; bottom: 0; width: 2px; background: #e5e7eb; }
|
||||
.flow-item { position: relative; display: grid; grid-template-columns: 24px 1fr; gap: 10px; padding: 8px 0; }
|
||||
.flow-item + .flow-item { border-top: 1px dashed #ebeef5; }
|
||||
.flow-item .step-index { position: static; width: 24px; height: 24px; line-height: 24px; text-align: center; border-radius: 50%; background: #1677FF; color: #fff; font-size: 12px; font-weight: 600; margin-top: 2px; }
|
||||
.flow-item:after { display: none; }
|
||||
.step-card { border: none; border-radius: 0; padding: 0; background: transparent; }
|
||||
.step-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.title { font-size: 13px; font-weight: 600; color: #303133; text-align: left; }
|
||||
.desc { font-size: 12px; color: #909399; margin-bottom: 8px; text-align: left; }
|
||||
.mini-hint { font-size: 12px; color: #909399; margin-top: 8px; text-align: left; }
|
||||
.links { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
|
||||
.link { color: #409EFF; cursor: pointer; font-size: 12px; }
|
||||
.sep { color: #dcdfe6; }
|
||||
|
||||
.content-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
.left-controls { margin-top: 10px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.dropzone { border: 1px dashed #c0c4cc; border-radius: 6px; padding: 16px; text-align: center; cursor: pointer; background: #fafafa; }
|
||||
.dropzone:hover { background: #f6fbff; border-color: #409EFF; }
|
||||
.dropzone.disabled { opacity: .6; cursor: not-allowed; }
|
||||
.dz-el-icon { font-size: 20px; margin-bottom: 6px; color: #909399; }
|
||||
.dz-text { color: #303133; font-size: 13px; }
|
||||
.dz-sub { color: #909399; font-size: 12px; }
|
||||
.single-input.left { display: flex; gap: 8px; }
|
||||
.action-buttons.column { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.file-chip { display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: #f5f7fa; border-radius: 4px; font-size: 12px; color: #606266; margin-top: 6px; }
|
||||
.file-chip .dot { width: 6px; height: 6px; background: #409EFF; border-radius: 50%; display: inline-block; }
|
||||
.file-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.progress-section.left { margin-top: 10px; }
|
||||
.full { width: 100%; }
|
||||
.form-row { margin-bottom: 10px; }
|
||||
.label { display: block; font-size: 12px; color: #606266; margin-bottom: 6px; }
|
||||
|
||||
/* 统一左侧控件宽度与主色 */
|
||||
.steps-sidebar :deep(.el-date-editor),
|
||||
.steps-sidebar :deep(.el-range-editor.el-input__wrapper),
|
||||
.steps-sidebar :deep(.el-input),
|
||||
.steps-sidebar :deep(.el-input__wrapper),
|
||||
.steps-sidebar :deep(.el-select) { width: 100%; box-sizing: border-box; }
|
||||
.btn-blue { background: #1677FF; border-color: #1677FF; color: #fff; }
|
||||
.btn-blue:disabled { background: #a6c8ff; border-color: #a6c8ff; color: #fff; }
|
||||
.w100 { width: 100%; }
|
||||
.steps-sidebar :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
|
||||
.import-section {
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
@@ -438,32 +595,16 @@ onMounted(loadLatest)
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin: 15px 0 10px 0;
|
||||
}
|
||||
|
||||
.progress-box {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding-right: 50px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-bar { flex: 1; height: 3px; background: #ebeef5; border-radius: 2px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, #409EFF, #66b1ff); border-radius: 2px; transition: width 0.3s ease; }
|
||||
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
font-size: 13px;
|
||||
color: #409EFF;
|
||||
font-weight: 500;
|
||||
}
|
||||
.progress-section { margin: 12px 12px 6px 12px; }
|
||||
.progress-box { padding: 4px 0; }
|
||||
.progress-container { display: flex; align-items: center; gap: 8px; }
|
||||
.progress-bar { flex: 1; height: 6px; background: #e3eeff; border-radius: 999px; overflow: hidden; }
|
||||
.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; }
|
||||
.progress-head { padding: 8px 12px 0 12px; }
|
||||
.progress-title { display:flex; align-items:center; gap:8px; color:#606266; font-size: 13px; margin-bottom: 6px; }
|
||||
.progress-title .ok-badge { color: #52c41a; font-size: 12px; }
|
||||
.progress-title .title-text { color:#303133; font-weight:600; }
|
||||
|
||||
.current-status {
|
||||
font-size: 12px;
|
||||
@@ -506,12 +647,12 @@ onMounted(loadLatest)
|
||||
|
||||
.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; }
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.table-wrapper { scrollbar-width: thin; scrollbar-color: #c0c4cc transparent; }
|
||||
.table-wrapper::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.table-wrapper::-webkit-scrollbar-track { background: transparent; }
|
||||
.table-wrapper::-webkit-scrollbar-thumb { background: #c0c4cc; border-radius: 3px; }
|
||||
.table-wrapper:hover::-webkit-scrollbar-thumb { background: #a8abb2; }
|
||||
.table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
|
||||
.table th {
|
||||
background: #f5f7fa;
|
||||
@@ -547,7 +688,7 @@ onMounted(loadLatest)
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.6; }
|
||||
.empty-text { font-size: 14px; color: #909399; }
|
||||
.import-section.drag-active { border: 1px dashed #409EFF; border-radius: 6px; }
|
||||
.empty-abs { position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.empty-abs { position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||
|
||||
.image-container {
|
||||
display: flex;
|
||||
@@ -577,6 +718,7 @@ onMounted(loadLatest)
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { zebraApi, type ZebraOrder } from '../../api/zebra'
|
||||
import { zebraApi, type ZebraOrder, type BanmaAccount } from '../../api/zebra'
|
||||
import AccountManager from '../common/AccountManager.vue'
|
||||
|
||||
type Shop = { id: string; shopName: string }
|
||||
|
||||
const accounts = ref<BanmaAccount[]>([])
|
||||
const accountId = ref<number>()
|
||||
const isCollapsed = ref(false)
|
||||
|
||||
const shopList = ref<Shop[]>([])
|
||||
const selectedShops = ref<string[]>([])
|
||||
const dateRange = ref<string[]>([])
|
||||
@@ -21,6 +26,10 @@ const fetchCurrentPage = ref(1)
|
||||
const fetchTotalPages = ref(0)
|
||||
const fetchTotalItems = ref(0)
|
||||
const isFetching = ref(false)
|
||||
function selectAccount(id: number) {
|
||||
accountId.value = id
|
||||
loadShops()
|
||||
}
|
||||
|
||||
const paginatedData = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
@@ -48,6 +57,19 @@ async function loadShops() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
try {
|
||||
const res = await zebraApi.getAccounts()
|
||||
const list = (res as any)?.data ?? res
|
||||
accounts.value = Array.isArray(list) ? list : []
|
||||
const def = accounts.value.find(a => a.isDefault === 1) || accounts.value[0]
|
||||
accountId.value = def?.id
|
||||
await loadShops()
|
||||
} catch (e) {
|
||||
accounts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
@@ -133,52 +155,154 @@ async function exportToExcel() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadShops()
|
||||
await loadAccounts()
|
||||
try {
|
||||
const latest = await zebraApi.getLatestOrders()
|
||||
allOrderData.value = latest?.orders || []
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
// 账号对话框
|
||||
const accountDialogVisible = ref(false)
|
||||
const accountForm = ref<BanmaAccount>({ isDefault: 0, status: 1 })
|
||||
const isEditMode = ref(false)
|
||||
const formUsername = ref('')
|
||||
const formPassword = ref('')
|
||||
const rememberPwd = ref(true)
|
||||
const managerVisible = ref(false)
|
||||
|
||||
function openAddAccount() {
|
||||
isEditMode.value = false
|
||||
accountForm.value = { name: '', username: '', isDefault: 0, status: 1 }
|
||||
formUsername.value = ''
|
||||
formPassword.value = ''
|
||||
accountDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openManageAccount() {
|
||||
const cur = accounts.value.find(a => a.id === accountId.value)
|
||||
if (!cur) return
|
||||
isEditMode.value = true
|
||||
accountForm.value = { ...cur }
|
||||
formUsername.value = cur.username || ''
|
||||
formPassword.value = localStorage.getItem(`banma:pwd:${cur.username || ''}`) || ''
|
||||
accountDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitAccount() {
|
||||
if (!formUsername.value) { alert('请输入账号'); return }
|
||||
const payload: BanmaAccount = {
|
||||
id: accountForm.value.id,
|
||||
name: accountForm.value.name || formUsername.value,
|
||||
username: formUsername.value,
|
||||
isDefault: accountForm.value.isDefault || 0,
|
||||
status: accountForm.value.status || 1,
|
||||
}
|
||||
const { id } = await zebraApi.saveAccount(payload)
|
||||
if (rememberPwd.value && formPassword.value) {
|
||||
localStorage.setItem(`banma:pwd:${formUsername.value}`, formPassword.value)
|
||||
} else {
|
||||
localStorage.removeItem(`banma:pwd:${formUsername.value}`)
|
||||
}
|
||||
accountDialogVisible.value = false
|
||||
await loadAccounts()
|
||||
if (id) accountId.value = id
|
||||
}
|
||||
|
||||
async function removeCurrentAccount() {
|
||||
if (!isEditMode.value || !accountForm.value.id) return
|
||||
if (!confirm('确认删除该账号?')) return
|
||||
await zebraApi.removeAccount(accountForm.value.id)
|
||||
accountDialogVisible.value = false
|
||||
await loadAccounts()
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="zebra-root">
|
||||
<div class="main-container">
|
||||
|
||||
<!-- 筛选和操作区域 -->
|
||||
<div class="import-section">
|
||||
<div class="import-controls">
|
||||
<!-- 店铺选择 -->
|
||||
<el-select v-model="selectedShops" multiple placeholder="选择店铺" style="width: 260px;" :disabled="loading">
|
||||
<el-option v-for="shop in shopList" :key="shop.id" :label="shop.shopName" :value="shop.id"></el-option>
|
||||
</el-select>
|
||||
|
||||
<!-- 日期选择 -->
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 200px;"
|
||||
:disabled="loading"
|
||||
/>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" :disabled="loading" @click="fetchData">
|
||||
📂 {{ loading ? '处理中...' : '获取订单数据' }}
|
||||
</el-button>
|
||||
<el-button type="danger" :disabled="!loading" @click="stopFetch">停止获取</el-button>
|
||||
<el-button type="success" :disabled="exportLoading || !allOrderData.length" @click="exportToExcel">导出Excel</el-button>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<aside :class="['aside', { collapsed: isCollapsed }]">
|
||||
<div class="aside-header">
|
||||
<span>操作流程</span>
|
||||
<el-button link @click="isCollapsed = !isCollapsed">{{ isCollapsed ? '展开' : '收起' }}</el-button>
|
||||
</div>
|
||||
<div class="aside-steps">
|
||||
<section class="step step-accounts">
|
||||
<div class="step-index">1</div>
|
||||
<div class="step-body">
|
||||
<div class="step-title">需要查询的账号</div>
|
||||
<div class="tip">请选择需要查询数据的账号,如未添加账号,请点击“添加账号”。</div>
|
||||
<template v-if="accounts.length">
|
||||
<el-scrollbar :class="['account-list', { 'scroll-limit': accounts.length > 3 }]">
|
||||
<div>
|
||||
<div
|
||||
v-for="a in accounts"
|
||||
:key="a.id"
|
||||
:class="['acct-item', { selected: accountId === a.id }]"
|
||||
@click="selectAccount(Number(a.id))"
|
||||
>
|
||||
<span class="acct-row">
|
||||
<span :class="['status-dot', a.status === 1 ? 'on' : 'off']"></span>
|
||||
<img class="avatar" src="/image/img_v3_02qd_052605f0-4be3-44db-9691-35ee5ff6201g.jpg" alt="avatar" />
|
||||
<span class="acct-text">{{ a.name || a.username }}</span>
|
||||
<span v-if="a.isDefault===1" class="tag">默认</span>
|
||||
<span v-if="accountId === a.id" class="acct-check">✔️</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="placeholder-box">
|
||||
<img class="placeholder-img" src="/icon/image.png" alt="add-account" />
|
||||
<div class="placeholder-tip">请添加 斑马ERP 账号</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="step-actions btn-row sticky-actions">
|
||||
<el-button size="small" class="w50" @click="openAddAccount">添加账号</el-button>
|
||||
<el-button size="small" class="w50 btn-blue" @click="managerVisible = true">账号管理</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 进度条显示:移动到底部,以免挤压表头 -->
|
||||
</div>
|
||||
<section class="step">
|
||||
<div class="step-index">2</div>
|
||||
<div class="step-body">
|
||||
<div class="step-title">需要查询的日期</div>
|
||||
<div class="tip">请选择查询数据的日期范围。</div>
|
||||
<el-select v-model="selectedShops" multiple placeholder="选择店铺" :disabled="loading || !accounts.length" size="small" style="width: 100%">
|
||||
<el-option v-for="shop in shopList" :key="shop.id" :label="shop.shopName" :value="shop.id" />
|
||||
</el-select>
|
||||
<div style="height: 8px"></div>
|
||||
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" :disabled="loading || !accounts.length" size="small" style="width: 100%" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 数据显示区域 -->
|
||||
<div class="table-container">
|
||||
<section class="step">
|
||||
<div class="step-index">3</div>
|
||||
<div class="step-body">
|
||||
<div class="step-title">获取数据</div>
|
||||
<div class="tip">点击下方按钮,开始查询订单数据。</div>
|
||||
<div class="btn-col">
|
||||
<el-button size="small" class="w100 btn-blue" :disabled="loading || !accounts.length" @click="fetchData">{{ loading ? '处理中...' : '获取数据' }}</el-button>
|
||||
<el-button size="small" :disabled="!loading" @click="stopFetch" class="w100">停止获取</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step">
|
||||
<div class="step-index">4</div>
|
||||
<div class="step-body">
|
||||
<div class="step-title">导出数据</div>
|
||||
<div class="tip">点击下方按钮,可导出数据为 Excel。</div>
|
||||
<div class="btn-col">
|
||||
<el-button size="small" type="success" :disabled="exportLoading || !allOrderData.length" @click="exportToExcel" class="w100">导出数据</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="content">
|
||||
<!-- 数据表格(无数据时也显示表头) -->
|
||||
<div class="table-section">
|
||||
<div class="table-wrapper">
|
||||
@@ -237,17 +361,15 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div v-if="paginatedData.length === 0" class="empty-abs">
|
||||
<div class="empty-container">
|
||||
<div v-if="loading" class="empty-container">
|
||||
<div class="spinner">⟳</div>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
<div v-else class="empty-container">
|
||||
<div class="empty-icon">📄</div>
|
||||
<div class="empty-text">暂无数据,请获取订单</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格加载遮罩:仅在无数据时显示 -->
|
||||
<div v-if="loading && !allOrderData.length" class="table-loading">
|
||||
<div class="spinner">⟳</div>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部区域:进度条 + 分页器 -->
|
||||
@@ -269,6 +391,29 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账号新增/编辑对话框 -->
|
||||
<el-dialog v-model="accountDialogVisible" width="420px" class="add-account-dialog">
|
||||
<template #header>
|
||||
<div class="aad-header">
|
||||
<img class="aad-icon" src="/icon/image.png" alt="logo" />
|
||||
<div class="aad-title">添加账号</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="aad-row">
|
||||
<el-input v-model="formUsername" placeholder="请输入账号" />
|
||||
</div>
|
||||
<div class="aad-row">
|
||||
<el-input v-model="formPassword" placeholder="请输入密码" type="password" show-password />
|
||||
</div>
|
||||
<div class="aad-row aad-opts">
|
||||
<el-checkbox v-model="rememberPwd">保存密码</el-checkbox>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" class="btn-blue" style="width: 100%" @click="submitAccount">登录</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<AccountManager v-model="managerVisible" platform="zebra" @add="openAddAccount" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -280,26 +425,68 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.zebra-root { position: absolute; inset: 0; background: #f5f5f5; padding: 12px; box-sizing: border-box; }
|
||||
.main-container { background: #fff; border-radius: 4px; padding: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); height: 100%; display: flex; flex-direction: column; }
|
||||
.import-section { margin-bottom: 10px; flex-shrink: 0; }
|
||||
.import-controls { display: flex; align-items: flex-end; gap: 20px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.action-buttons { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.progress-section { margin: 15px 0 10px 0; }
|
||||
.progress-box { padding: 8px 0; }
|
||||
.progress-container { display: flex; align-items: center; position: relative; padding-right: 50px; margin-bottom: 8px; }
|
||||
.progress-bar { flex: 1; height: 3px; background: #ebeef5; border-radius: 2px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, #409EFF, #66b1ff); border-radius: 2px; transition: width 0.3s ease; }
|
||||
.progress-text { position: absolute; right: 0; font-size: 13px; color: #409EFF; font-weight: 500; }
|
||||
.current-status { font-size: 12px; color: #606266; padding-left: 2px; }
|
||||
.table-container { display: flex; flex-direction: column; flex: 1; min-height: 400px; overflow: hidden; }
|
||||
.empty-section { flex: 1; display: flex; justify-content: center; align-items: center; background: #fff; border: 1px solid #ebeef5; border-radius: 6px; }
|
||||
.empty-container { text-align: center; }
|
||||
.empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.6; }
|
||||
.empty-text { font-size: 14px; color: #909399; }
|
||||
.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; }
|
||||
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.table th { background: #f5f7fa; color: #909399; font-weight: 600; padding: 12px 8px; border-bottom: 2px solid #ebeef5; text-align: left; }
|
||||
.layout { background: #fff; border-radius: 4px; padding: 12px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); height: 100%; display: grid; grid-template-columns: 220px 1fr; gap: 12px; }
|
||||
.aside { border: 1px solid #ebeef5; border-radius: 4px; padding: 10px; display: flex; flex-direction: column; transition: width 0.2s ease; }
|
||||
.aside.collapsed { width: 56px; overflow: hidden; }
|
||||
.aside-header { display: flex; justify-content: space-between; align-items: center; font-weight: 600; color: #606266; margin-bottom: 8px; }
|
||||
.aside-steps { position: relative; }
|
||||
.step { display: grid; grid-template-columns: 24px 1fr; gap: 10px; position: relative; padding: 8px 0; }
|
||||
.step + .step { border-top: 1px dashed #ebeef5; }
|
||||
.step-index { width: 24px; height: 24px; background: #1677FF; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; margin-top: 2px; }
|
||||
.step-body { min-width: 0; text-align: left; }
|
||||
.step-title { font-size: 13px; color: #606266; margin-bottom: 6px; font-weight: 600; text-align: left; }
|
||||
.aside-steps:before { content: ''; position: absolute; left: 12px; top: 0; bottom: 0; width: 2px; background: #e5e7eb; }
|
||||
.account-list {height: auto; }
|
||||
.step-actions { margin-top: 8px; display: flex; gap: 8px; }
|
||||
.step-accounts { position: relative; }
|
||||
.sticky-actions { position: sticky; bottom: 0; background: #fafafa; padding-top: 8px; }
|
||||
.scroll-limit { max-height: 160px; }
|
||||
.btn-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.btn-col { display: flex; flex-direction: column; gap: 6px; }
|
||||
.w50 { width: 48%; }
|
||||
.w100 { width: 100%; }
|
||||
.placeholder-box { display:flex; align-items:center; justify-content:center; flex-direction:column; height: 140px; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; }
|
||||
.placeholder-img { width: 120px; opacity: 0.9; }
|
||||
.placeholder-tip { margin-top: 6px; font-size: 12px; color: #a8abb2; }
|
||||
.aside :deep(.el-date-editor) { width: 100%; }
|
||||
.aside :deep(.el-range-editor.el-input__wrapper) { width: 100%; box-sizing: border-box; }
|
||||
.aside :deep(.el-input),
|
||||
.aside :deep(.el-input__wrapper),
|
||||
.aside :deep(.el-select) { width: 100%; box-sizing: border-box; }
|
||||
.aside :deep(.el-button + .el-button) { margin-left: 0 !important; }
|
||||
.btn-row :deep(.el-button) { width: 100%; }
|
||||
.btn-col :deep(.el-button) { width: 100%; }
|
||||
.btn-blue { background: #1677FF; border-color: #1677FF; color: #fff; }
|
||||
.btn-blue:disabled { background: #a6c8ff; border-color: #a6c8ff; color: #fff; }
|
||||
.tip { color: #909399; font-size: 12px; margin-bottom: 8px; text-align: left; }
|
||||
.avatar { width: 18px; height: 18px; border-radius: 50%; margin-right: 6px; vertical-align: -2px; }
|
||||
.acct-text { vertical-align: middle; }
|
||||
.acct-row { display: grid; grid-template-columns: 8px 18px 1fr auto; align-items: center; gap: 6px; width: 100%; }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
|
||||
.status-dot.on { background: #22c55e; }
|
||||
.status-dot.off { background: #f87171; }
|
||||
.acct-item { padding: 6px 8px; border-radius: 8px; cursor: pointer; }
|
||||
.acct-item.selected { background: #eef5ff; box-shadow: inset 0 0 0 1px #d6e4ff; }
|
||||
.acct-check { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 50%; background: transparent; color: #111; font-size: 14px; }
|
||||
.account-list::-webkit-scrollbar { width: 0; height: 0; }
|
||||
.add-account-dialog .aad-header { display:flex; flex-direction: column; align-items:center; gap:8px; padding-top: 8px; width: 100%; }
|
||||
.add-account-dialog .aad-icon { width: 120px; height: auto; }
|
||||
.add-account-dialog .aad-title { font-weight: 600; font-size: 18px; text-align: center; }
|
||||
.add-account-dialog .aad-row { margin-top: 12px; }
|
||||
.add-account-dialog .aad-opts { display:flex; align-items:center; }
|
||||
|
||||
/* 居中 header,避免右上角关闭按钮影响视觉中心 */
|
||||
:deep(.add-account-dialog .el-dialog__header) { text-align: center; padding-right: 0; display: block; }
|
||||
.content { display: grid; grid-template-rows: 1fr auto; min-height: 0; }
|
||||
.table-section { min-height: 0; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; display: flex; flex-direction: column; }
|
||||
.table-wrapper { flex: 1; overflow: auto; overflow-x: auto; }
|
||||
.table-wrapper { scrollbar-width: thin; scrollbar-color: #c0c4cc transparent; }
|
||||
.table-wrapper::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.table-wrapper::-webkit-scrollbar-track { background: transparent; }
|
||||
.table-wrapper::-webkit-scrollbar-thumb { background: #c0c4cc; border-radius: 3px; }
|
||||
.table-wrapper:hover::-webkit-scrollbar-thumb { background: #a8abb2; }
|
||||
.table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.table th { background: #f5f7fa; color: #909399; font-weight: 600; padding: 12px 8px; border-bottom: 2px solid #ebeef5; text-align: left; white-space: nowrap; }
|
||||
.table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
|
||||
.table tbody tr:hover { background: #f9f9f9; }
|
||||
.truncate { max-width: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
@@ -310,14 +497,13 @@ export default {
|
||||
.table-loading { position: absolute; inset: 0; background: rgba(255, 255, 255, 0.95); display: flex; flex-direction: column; justify-content: center; align-items: center; font-size: 14px; color: #606266; }
|
||||
.spinner { font-size: 24px; animation: spin 1s linear infinite; margin-bottom: 8px; }
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
.pagination-fixed { flex-shrink: 0; padding: 8px 12px; background: #f9f9f9; border-radius: 4px; display: flex; justify-content: center; border-top: 1px solid #ebeef5; margin-top: 8px; }
|
||||
.tag { display: inline-block; padding: 2px 6px; font-size: 12px; background: #ecf5ff; color: #409EFF; border-radius: 3px; }
|
||||
.empty-tip { text-align: center; color: #909399; padding: 16px 0; }
|
||||
.empty-container { text-align: center; }
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.6; }
|
||||
.empty-text { font-size: 14px; color: #909399; }
|
||||
.empty-abs { position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.pagination-fixed { position: sticky; bottom: 0; z-index: 2; padding: 8px 12px; background: #f9f9f9; border-radius: 4px; display: flex; justify-content: center; border-top: 1px solid #ebeef5; margin-top: 8px; }
|
||||
.tag { display: inline-block; padding: 0 6px; margin-left: 6px; font-size: 12px; background: #ecf5ff; color: #409EFF; border-radius: 3px; }
|
||||
.empty-abs { position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.progress-bottom { display: flex; align-items: center; gap: 8px; margin-right: auto; }
|
||||
.progress-bottom .progress-bar { width: 100%; height: 6px; background: #e3eeff; border-radius: 999px; overflow: hidden; }
|
||||
.progress-bottom .progress-fill { height: 100%; background: #1677FF; border-radius: 999px; transition: width 0.3s ease; }
|
||||
.progress-bottom .progress-text { font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; text-align: right; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -120,9 +120,7 @@ public class AuthController {
|
||||
public JsonData saveCache(@RequestBody Map<String, Object> data) {
|
||||
String key = (String) data.get("key");
|
||||
String value = (String) data.get("value");
|
||||
|
||||
if (key == null || value == null) return JsonData.buildError("key和value不能为空");
|
||||
|
||||
CacheDataEntity entity = cacheDataRepository.findByCacheKey(key).orElse(new CacheDataEntity());
|
||||
entity.setCacheKey(key);
|
||||
entity.setCacheValue(value);
|
||||
@@ -157,6 +155,7 @@ public class AuthController {
|
||||
if (key == null || key.trim().isEmpty()) {
|
||||
return JsonData.buildError("key不能为空");
|
||||
}
|
||||
System.out.println("key: " + key);
|
||||
cacheDataRepository.deleteByCacheKey(key);
|
||||
return JsonData.buildSuccess("缓存数据删除成功");
|
||||
}
|
||||
@@ -165,18 +164,16 @@ public class AuthController {
|
||||
* 会话引导:检查SQLite中是否存在token
|
||||
*/
|
||||
@GetMapping("/session/bootstrap")
|
||||
public ResponseEntity<?> sessionBootstrap() {
|
||||
public JsonData sessionBootstrap() {
|
||||
Optional<CacheDataEntity> tokenEntity = cacheDataRepository.findByCacheKey("token");
|
||||
if (tokenEntity.isEmpty()) {
|
||||
return ResponseEntity.status(401).body(Map.of("code", 401, "message", "无可用会话,请重新登录"));
|
||||
return JsonData.buildError("无可用会话,请重新登录");
|
||||
}
|
||||
String token = tokenEntity.get().getCacheValue();
|
||||
if (token == null || token.isEmpty()) {
|
||||
return ResponseEntity.status(401).body(Map.of("code", 401, "message", "无可用会话,请重新登录"));
|
||||
return JsonData.buildError("无可用会话,请重新登录");
|
||||
}
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add(HttpHeaders.SET_COOKIE, buildHttpOnlyCookie("FX_TOKEN", token, 2 * 24 * 60 * 60));
|
||||
return ResponseEntity.ok().headers(headers).body(Map.of("code", 200, "message", "会话已恢复"));
|
||||
return JsonData.buildSuccess("会话已恢复");
|
||||
}
|
||||
|
||||
private String buildHttpOnlyCookie(String name, String value, int maxAgeSeconds) {
|
||||
|
||||
@@ -42,6 +42,11 @@ public class DeviceProxyController {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备心跳
|
||||
*/
|
||||
|
||||
@@ -83,6 +83,7 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service {
|
||||
*/
|
||||
@Override
|
||||
public SearchResult get1688Detail(String uploadedUrl) {
|
||||
uploadedUrl = uploadedUrl.split("\\?")[0];
|
||||
String fileName = "temp_" + System.currentTimeMillis() + ".png";
|
||||
List<String> detailUrls = new ArrayList<>();
|
||||
SearchResult result = new SearchResult();
|
||||
@@ -101,21 +102,36 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service {
|
||||
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("data", jsonData);
|
||||
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(formData, headers);
|
||||
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class);
|
||||
JsonNode root = objectMapper.readTree(response.getBody());
|
||||
Iterator<JsonNode> offerIterator = root.path("data").path("offerList").path("offers").elements();
|
||||
//运费
|
||||
Iterator<JsonNode> offerIterator = null;
|
||||
for (int retry = 0; retry < 3 && (offerIterator == null || !offerIterator.hasNext()); retry++) {
|
||||
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class);
|
||||
JsonNode root = objectMapper.readTree(response.getBody());
|
||||
offerIterator = root.path("data").path("offerList").path("offers").elements();
|
||||
}
|
||||
//运费 - 收集所有运费数据
|
||||
Set<Double> freight = new HashSet<>();
|
||||
for (int i = 0; i < 10 && offerIterator.hasNext(); i++) {
|
||||
while (offerIterator.hasNext()) {
|
||||
JsonNode offer = offerIterator.next();
|
||||
String offerId = offer.path("id").asText();
|
||||
String freightProvFirstFee = offer.path("freightProvFirstFee").asText();
|
||||
Optional.ofNullable(freightProvFirstFee)
|
||||
.map(s -> s.split(";", 2)[0])
|
||||
.map(s -> s.split(":", 2))
|
||||
.filter(parts -> parts.length == 2 && !parts[1].isBlank())
|
||||
.map(parts -> Double.parseDouble(parts[1]) / 100.0)
|
||||
.filter(fee -> fee > 0)
|
||||
.ifPresent(freight::add);
|
||||
}
|
||||
|
||||
offerIterator = null;
|
||||
for (int retry = 0; retry < 3 && (offerIterator == null || !offerIterator.hasNext()); retry++) {
|
||||
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class);
|
||||
JsonNode root = objectMapper.readTree(response.getBody());
|
||||
offerIterator = root.path("data").path("offerList").path("offers").elements();
|
||||
}
|
||||
|
||||
for (int i = 0; i < 10 && offerIterator.hasNext(); i++) {
|
||||
JsonNode offer = offerIterator.next();
|
||||
String offerId = offer.path("id").asText();
|
||||
prices.add(offer.path("normalPrice").asDouble());
|
||||
detailUrls.add(offerId);
|
||||
}
|
||||
@@ -136,12 +152,12 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println("url"+uploadedUrl);
|
||||
System.out.println("skuPrices:"+skuPrices);
|
||||
result.setSkuPrice(skuPrices);
|
||||
result.setMedian( median);
|
||||
result.setMapRecognitionLink( uploadImageBase64(imageUrl));
|
||||
System.out.println("运费"+freightFee);
|
||||
result.setFreight(freightFee.isEmpty() ? 0.0 :freightFee.get(freightFee.size()/2-1));
|
||||
result.setFreight(freightFee.isEmpty() ? 0.0 : freightFee.get(Math.max(0, freightFee.size()/2-1)));
|
||||
// String weight = getWeight(detailUrls);
|
||||
// result.setWeight(weight);
|
||||
return result;
|
||||
|
||||
@@ -81,6 +81,7 @@ public class AuthServiceImpl implements IAuthService {
|
||||
|
||||
if (accessToken != null) {
|
||||
saveTokenToCache(accessToken);
|
||||
registerDeviceOnLogin(username);
|
||||
}
|
||||
|
||||
result.put("success", true);
|
||||
@@ -201,6 +202,7 @@ public class AuthServiceImpl implements IAuthService {
|
||||
accessToken = newAccessToken;
|
||||
refreshToken = newRefreshToken;
|
||||
saveTokenToCache(newAccessToken);
|
||||
registerDeviceOnLogin(username);
|
||||
}
|
||||
|
||||
result.put("success", true);
|
||||
@@ -238,6 +240,9 @@ public class AuthServiceImpl implements IAuthService {
|
||||
*/
|
||||
public void logout() {
|
||||
try {
|
||||
// 通知服务器设备离线
|
||||
setDeviceOffline();
|
||||
|
||||
// 清除内存中的token
|
||||
accessToken = null;
|
||||
refreshToken = null;
|
||||
@@ -246,4 +251,28 @@ public class AuthServiceImpl implements IAuthService {
|
||||
cacheDataRepository.deleteByCacheKey("token");
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录时注册设备
|
||||
*/
|
||||
private void registerDeviceOnLogin(String username) {
|
||||
try {
|
||||
Map<String, Object> deviceData = new HashMap<>();
|
||||
deviceData.put("username", username);
|
||||
deviceData.put("deviceId", clientId);
|
||||
deviceData.put("os", System.getProperty("os.name"));
|
||||
apiForwarder.post("/monitor/device/register", deviceData, buildAuthHeader());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备离线
|
||||
*/
|
||||
private void setDeviceOffline() {
|
||||
try {
|
||||
Map<String, Object> offlineData = new HashMap<>();
|
||||
offlineData.put("deviceId", clientId);
|
||||
apiForwarder.post("/monitor/device/offline", offlineData, buildAuthHeader());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
@@ -198,11 +198,13 @@ public class ClientAccountController extends BaseController {
|
||||
Map<String, Object> claims = Jwts.parser().setSigningKey(jwtRsaKeyService.getPublicKey()).parseClaimsJws(token).getBody();
|
||||
String username = (String) claims.getOrDefault("sub", claims.get("subject"));
|
||||
String tokenClientId = (String) claims.get("clientId");
|
||||
|
||||
if (username == null || tokenClientId == null || !tokenClientId.equals(clientId)) {
|
||||
throw new RuntimeException("会话不匹配");
|
||||
}
|
||||
|
||||
SseEmitter emitter = sseHubService.register(username, clientId, 0L);
|
||||
try { emitter.send(SseEmitter.event().name("ready").data("ok")); } catch (Exception ignored) {}
|
||||
try { emitter.send(SseEmitter.event().data("{\"type\":\"ready\"}")); } catch (Exception ignored) {}
|
||||
return emitter;
|
||||
}
|
||||
|
||||
|
||||
@@ -114,11 +114,31 @@ public class ClientDeviceController {
|
||||
return AjaxResult.success();
|
||||
}
|
||||
if (!"removed".equals(exists.getStatus())) {
|
||||
// 先推送下线事件,再断开连接
|
||||
sseHubService.sendEvent(exists.getUsername(), deviceId, "DEVICE_REMOVED", "{}");
|
||||
// 立即断开SSE连接,防止重新上线
|
||||
sseHubService.disconnectDevice(exists.getUsername(), deviceId);
|
||||
// 更新设备状态
|
||||
exists.setStatus("removed");
|
||||
exists.setLastActiveAt(new java.util.Date());
|
||||
clientDeviceMapper.updateByDeviceId(exists);
|
||||
// 推送SSE下线事件
|
||||
try { sseHubService.sendEvent(exists.getUsername(), deviceId, "DEVICE_REMOVED", "{}"); } catch (Exception ignored) {}
|
||||
}
|
||||
return AjaxResult.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备离线
|
||||
*/
|
||||
@PostMapping("/offline")
|
||||
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");
|
||||
device.setLastActiveAt(new java.util.Date());
|
||||
clientDeviceMapper.updateByDeviceId(device);
|
||||
}
|
||||
return AjaxResult.success();
|
||||
}
|
||||
|
||||
@@ -1,430 +1,52 @@
|
||||
package com.ruoyi.web.controller.tool;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.context.request.async.DeferredResult;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import com.ruoyi.common.annotation.Anonymous;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.R;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import io.swagger.annotations.ApiParam;
|
||||
import javax.annotation.PostConstruct;
|
||||
import javax.annotation.PreDestroy;
|
||||
import com.ruoyi.system.domain.BanmaAccount;
|
||||
import com.ruoyi.system.service.IBanmaAccountService;
|
||||
|
||||
/**
|
||||
* 斑马订单控制器
|
||||
*
|
||||
* @author ruoyi
|
||||
* 斑马账号管理(数据库版,极简接口):
|
||||
* - 仅负责账号与 Token 的存取
|
||||
* - 不参与登录/刷新与数据采集,客户端自行处理
|
||||
*/
|
||||
@Api("斑马订单接口")
|
||||
@RestController
|
||||
@RequestMapping("/tool/banma")
|
||||
@Anonymous
|
||||
public class BanmaOrderController extends BaseController {
|
||||
private static String AUTH_TOKEN = "Bearer e5V8Vlaf9xh5i31xaI300wbdXEE3iLtgip+JXfzZsb7GShP2XCGhoVzTEVxyc8LH";
|
||||
private static final String LOGIN_URL = "https://banma365.cn/api/login";
|
||||
private static final String LOGIN_USERNAME = "大赢家网络科技(主账号)";
|
||||
private static final String LOGIN_PASSWORD = "banma123456";
|
||||
private static final String API_URL = "https://banma365.cn/api/order/list?recipientName=&page=%d&size=%d&markFlag=0&state=4&_t=%d";
|
||||
private static final String API_URL_WITH_TIME = "https://banma365.cn/api/order/list?recipientName=&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";
|
||||
private static final int CONNECTION_TIMEOUT = 999999999;
|
||||
private static final int READ_TIMEOUT = 999999999;
|
||||
private static final int DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
@Autowired
|
||||
private RestTemplate restTemplate;
|
||||
private IBanmaAccountService accountService;
|
||||
|
||||
@Autowired
|
||||
private SagawaExpressController sagawaExpressController;
|
||||
|
||||
private final ExecutorService executorService = Executors.newFixedThreadPool(10);
|
||||
|
||||
public BanmaOrderController() {
|
||||
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(CONNECTION_TIMEOUT);
|
||||
factory.setReadTimeout(READ_TIMEOUT);
|
||||
restTemplate = new RestTemplate(factory);
|
||||
}
|
||||
/**
|
||||
* 初始化方法,启动时刷新token
|
||||
* 查询账号列表(仅返回必要字段)
|
||||
*/
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
refreshToken();
|
||||
@GetMapping("/accounts")
|
||||
public R<?> listAccounts() {
|
||||
List<BanmaAccount> list = accountService.listSimple();
|
||||
return R.ok(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭线程池
|
||||
* 新增或编辑账号(含设为默认)
|
||||
*/
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
executorService.shutdownNow();
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 86400000 * 3)
|
||||
public void refreshToken() {
|
||||
try {
|
||||
// 1. 输入准备:构建请求参数
|
||||
Map<String, String> loginParams = new HashMap<>();
|
||||
loginParams.put("username", LOGIN_USERNAME);
|
||||
loginParams.put("password", LOGIN_PASSWORD);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Content-Type", "application/json");
|
||||
ResponseEntity<Map> response = restTemplate.postForEntity(
|
||||
LOGIN_URL,
|
||||
new HttpEntity<>(loginParams, headers),
|
||||
Map.class
|
||||
);
|
||||
Optional.ofNullable(response.getBody())
|
||||
.filter(body -> Integer.valueOf(0).equals(body.get("code")))
|
||||
.map(body -> (Map<String, Object>) body.get("data"))
|
||||
.map(data -> (String) data.get("token"))
|
||||
.filter(StringUtils::isNotEmpty)
|
||||
.ifPresent(token -> {
|
||||
AUTH_TOKEN = "Bearer " + token;
|
||||
logger.info("斑马token刷新成功: {}", token);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.error("斑马token刷新异常: {}", e.getMessage());
|
||||
}
|
||||
@PostMapping("/accounts")
|
||||
public R<?> saveAccount(@RequestBody BanmaAccount body) {
|
||||
Long id = accountService.saveOrUpdate(body);
|
||||
return R.ok(Map.of("id", id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建HTTP请求实体
|
||||
* 删除账号
|
||||
*/
|
||||
private HttpEntity<String> createHttpEntity() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", AUTH_TOKEN);
|
||||
return new HttpEntity<>(headers);
|
||||
@DeleteMapping("/accounts/{id}")
|
||||
public R<?> remove(@PathVariable Long id) {
|
||||
accountService.remove(id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理订单数据
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private CompletableFuture<Map<String, Object>> processOrderDataAsync(Map<String, Object> order) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
if (order == null) return null;
|
||||
|
||||
Map<String, Object> simplifiedOrder = new HashMap<>();
|
||||
|
||||
// 提取国际运单号和运费
|
||||
String trackingNumber = (String) order.get("internationalTrackingNumber");
|
||||
simplifiedOrder.put("internationalTrackingNumber", trackingNumber);
|
||||
simplifiedOrder.put("internationalShippingFee", order.get("internationalShippingFee"));
|
||||
|
||||
// 获取物流轨迹信息
|
||||
if (StringUtils.isNotEmpty(trackingNumber)) {
|
||||
simplifiedOrder.put("trackInfo", getTrackingInfo(trackingNumber));
|
||||
}
|
||||
|
||||
// 处理子订单信息
|
||||
Optional.ofNullable(order.get("subOrders"))
|
||||
.map(subOrders -> (List<Map<String, Object>>) subOrders)
|
||||
.filter(list -> !list.isEmpty())
|
||||
.map(list -> list.get(0))
|
||||
.ifPresent(subOrder -> extractSubOrderFields(simplifiedOrder, subOrder));
|
||||
return simplifiedOrder;
|
||||
}, executorService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取子订单字段
|
||||
*/
|
||||
private void extractSubOrderFields(Map<String, Object> simplifiedOrder, Map<String, Object> subOrder) {
|
||||
// 基础信息
|
||||
simplifiedOrder.put("orderedAt", subOrder.get("orderedAt"));
|
||||
simplifiedOrder.put("timeSinceOrder", subOrder.get("timeSinceOrder"));
|
||||
simplifiedOrder.put("productImage", subOrder.get("productImage"));
|
||||
simplifiedOrder.put("createdAt", subOrder.get("createdAt"));
|
||||
simplifiedOrder.put("poTrackingNumber", subOrder.get("poTrackingNumber"));
|
||||
// 商品信息
|
||||
simplifiedOrder.put("productTitle", subOrder.get("productTitle"));
|
||||
simplifiedOrder.put("shopOrderNumber", subOrder.get("shopOrderNumber"));
|
||||
simplifiedOrder.put("priceJpy", subOrder.get("priceJpy"));
|
||||
simplifiedOrder.put("productQuantity", subOrder.get("productQuantity"));
|
||||
simplifiedOrder.put("shippingFeeJpy", subOrder.get("shippingFeeJpy"));
|
||||
simplifiedOrder.put("productNumber", subOrder.get("productNumber"));
|
||||
|
||||
// 采购信息
|
||||
simplifiedOrder.put("poNumber", subOrder.get("poNumber"));
|
||||
simplifiedOrder.put("shippingFeeCny", subOrder.get("shippingFeeCny"));
|
||||
simplifiedOrder.put("poLogisticsCompany", subOrder.get("poLogisticsCompany"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取斑马订单数据 - 异步方法
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private CompletableFuture<List<Map<String, Object>>> fetchOrdersFromApiAsync(int page, int size, String startDate, String endDate) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
HttpEntity<String> entity = createHttpEntity();
|
||||
String url = buildApiUrl(page, size, startDate, endDate);
|
||||
|
||||
ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.GET, entity, Map.class);
|
||||
Map<String, Object> responseBody = response.getBody();
|
||||
|
||||
if (responseBody == null || !responseBody.containsKey("data")) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Map<String, Object> dataMap = (Map<String, Object>) responseBody.get("data");
|
||||
List<Map<String, Object>> orders = Optional.ofNullable(dataMap.get("list"))
|
||||
.map(list -> (List<Map<String, Object>>) list)
|
||||
.orElse(Collections.emptyList());
|
||||
|
||||
return orders;
|
||||
} catch (Exception e) {
|
||||
logger.error("获取订单数据失败: {}", e.getMessage());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}, executorService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建API URL
|
||||
*/
|
||||
private String buildApiUrl(int page, int size, String startDate, String endDate) {
|
||||
if (StringUtils.isNotEmpty(startDate) && StringUtils.isNotEmpty(endDate)) {
|
||||
String startTime = startDate + " 00:00:00";
|
||||
String endTime = endDate + " 23:59:59";
|
||||
return String.format(API_URL_WITH_TIME, page, size, startTime, endTime, System.currentTimeMillis());
|
||||
}
|
||||
return String.format(API_URL, page, size, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取物流轨迹信息
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private String getTrackingInfo(String trackingNumber) {
|
||||
try {
|
||||
R<Map<String, Object>> sagawaResult = sagawaExpressController.getTrackingInfo(trackingNumber);
|
||||
if (sagawaResult != null && sagawaResult.getCode() == 200) {
|
||||
Map<String, Object> sagawaData = sagawaResult.getData();
|
||||
if (sagawaData != null && "success".equals(sagawaData.get("status"))) {
|
||||
Map<String, String> trackInfo = (Map<String, String>) sagawaData.get("trackInfo");
|
||||
if (trackInfo != null) {
|
||||
return String.format("%s - %s - %s",
|
||||
trackInfo.get("status"),
|
||||
trackInfo.get("dateTime"),
|
||||
trackInfo.get("office"));
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
String url = String.format(TRACKING_URL, trackingNumber);
|
||||
ResponseEntity<Map> response = restTemplate.getForEntity(url, Map.class);
|
||||
Map<String, Object> responseBody = response.getBody();
|
||||
if (responseBody != null && Integer.valueOf(0).equals(responseBody.get("code"))) {
|
||||
return Optional.ofNullable(responseBody.get("data"))
|
||||
.map(data -> (List<Map<String, Object>>) data)
|
||||
.filter(list -> !list.isEmpty())
|
||||
.map(list -> list.get(0))
|
||||
.map(track -> (String) track.get("track"))
|
||||
.orElse(null);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("从斑马API获取物流信息失败: {}", e.getMessage());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("获取物流信息失败: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return "暂无物流信息";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取物流轨迹信息
|
||||
*/
|
||||
@ApiOperation("获取物流轨迹信息")
|
||||
@GetMapping("/tracking/{trackingNumber}")
|
||||
public R<String> getTracking(@PathVariable("trackingNumber") String trackingNumber) {
|
||||
try {
|
||||
String trackInfo = getTrackingInfo(trackingNumber);
|
||||
return trackInfo != null ? R.ok(trackInfo) : R.fail("未找到物流信息");
|
||||
} catch (Exception e) {
|
||||
return R.fail("获取物流信息失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有页的斑马订单 - 优化版本
|
||||
*/
|
||||
@ApiOperation("获取所有页的斑马订单")
|
||||
@GetMapping("/orders/all")
|
||||
@SuppressWarnings("unchecked")
|
||||
public DeferredResult<R<Map<String, Object>>> getAllOrders(
|
||||
@ApiParam("开始日期(yyyy-MM-dd)") @RequestParam(required = false) String startDate,
|
||||
@ApiParam("结束日期(yyyy-MM-dd)") @RequestParam(required = false) String endDate) {
|
||||
DeferredResult<R<Map<String, Object>>> deferredResult = new DeferredResult<>(9999000L);
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
HttpEntity<String> entity = createHttpEntity();
|
||||
String url = buildApiUrl(1, DEFAULT_PAGE_SIZE, startDate, endDate);
|
||||
ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.GET, entity, Map.class);
|
||||
Map<String, Object> responseBody = response.getBody();
|
||||
Map<String, Object> dataMap = (Map<String, Object>) responseBody.get("data");
|
||||
int totalCount = ((Number) dataMap.getOrDefault("total", 0)).intValue();
|
||||
|
||||
List<Map<String, Object>> orders = Optional.ofNullable(dataMap.get("list"))
|
||||
.map(list -> (List<Map<String, Object>>) list)
|
||||
.orElse(Collections.emptyList());
|
||||
|
||||
List<CompletableFuture<Map<String, Object>>> futures = orders.stream()
|
||||
.map(this::processOrderDataAsync)
|
||||
.toList();
|
||||
|
||||
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
|
||||
futures.toArray(new CompletableFuture[0])
|
||||
);
|
||||
|
||||
|
||||
// 收集所有处理结果
|
||||
CompletableFuture<List<Map<String, Object>>> resultsFuture = allFutures.thenApply(v ->
|
||||
futures.stream()
|
||||
.map(CompletableFuture::join)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList())
|
||||
);
|
||||
List<Map<String, Object>> processedOrders = resultsFuture.get();
|
||||
int totalPages = (int) Math.ceil((double) totalCount / DEFAULT_PAGE_SIZE);
|
||||
boolean hasMore = totalCount > 0 && 1 < totalPages;
|
||||
Map<String, Object> resultMap = new HashMap<>();
|
||||
resultMap.put("orders", processedOrders);
|
||||
resultMap.put("total", totalCount);
|
||||
resultMap.put("totalPages", totalPages);
|
||||
resultMap.put("hasMore", hasMore);
|
||||
resultMap.put("nextPage", 2);
|
||||
deferredResult.setResult(R.ok(resultMap));
|
||||
} catch (Exception e) {
|
||||
logger.error("获取订单数据失败: {}", e.getMessage());
|
||||
deferredResult.setResult(R.fail("获取订单失败: " + e.getMessage()));
|
||||
}
|
||||
}, executorService);
|
||||
return deferredResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下一页斑马订单 - 优化版本
|
||||
*/
|
||||
@ApiOperation("获取下一页斑马订单")
|
||||
@GetMapping("/orders/next")
|
||||
public DeferredResult<R<Map<String, Object>>> getNextPageOrders(
|
||||
@RequestParam(value = "page", defaultValue = "1") Integer page,
|
||||
@ApiParam("开始日期(yyyy-MM-dd)") @RequestParam(required = false) String startDate,
|
||||
@ApiParam("结束日期(yyyy-MM-dd)") @RequestParam(required = false) String endDate) {
|
||||
DeferredResult<R<Map<String, Object>>> deferredResult = new DeferredResult<>(999999999L);
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
// 获取总页数信息
|
||||
HttpEntity<String> entity = createHttpEntity();
|
||||
String url = buildApiUrl(1, DEFAULT_PAGE_SIZE, startDate, endDate);
|
||||
ResponseEntity<Map> countResponse = restTemplate.exchange(url, HttpMethod.GET, entity, Map.class);
|
||||
Map<String, Object> countResponseBody = countResponse.getBody();
|
||||
int totalPages = 1;
|
||||
|
||||
if (countResponseBody != null && countResponseBody.containsKey("data")) {
|
||||
Map<String, Object> dataMap = (Map<String, Object>) countResponseBody.get("data");
|
||||
int totalCount = ((Number) dataMap.getOrDefault("total", 0)).intValue();
|
||||
totalPages = (int) Math.ceil((double) totalCount / DEFAULT_PAGE_SIZE);
|
||||
}
|
||||
|
||||
// 获取当前页数据
|
||||
CompletableFuture<List<Map<String, Object>>> ordersFuture = fetchOrdersFromApiAsync(page, DEFAULT_PAGE_SIZE, startDate, endDate);
|
||||
List<Map<String, Object>> orders = ordersFuture.get();
|
||||
|
||||
// 并行处理订单数据
|
||||
List<CompletableFuture<Map<String, Object>>> processFutures = orders.stream()
|
||||
.map(this::processOrderDataAsync)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 等待所有处理完成
|
||||
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
|
||||
processFutures.toArray(new CompletableFuture[0])
|
||||
);
|
||||
|
||||
CompletableFuture<List<Map<String, Object>>> resultsFuture = allFutures.thenApply(v ->
|
||||
processFutures.stream()
|
||||
.map(CompletableFuture::join)
|
||||
.filter(order -> order != null)
|
||||
.collect(Collectors.toList())
|
||||
);
|
||||
|
||||
List<Map<String, Object>> processedOrders = resultsFuture.get();
|
||||
// 修改hasMore判断逻辑,根据当前页数和总页数判断
|
||||
boolean hasMore = page < totalPages;
|
||||
Map<String, Object> resultMap = new HashMap<>();
|
||||
resultMap.put("orders", processedOrders);
|
||||
resultMap.put("hasMore", hasMore);
|
||||
resultMap.put("nextPage", page + 1);
|
||||
resultMap.put("totalPages", totalPages);
|
||||
|
||||
deferredResult.setResult(R.ok(resultMap));
|
||||
} catch (Exception e) {
|
||||
logger.error("获取下一页订单失败: {}", e.getMessage());
|
||||
deferredResult.setResult(R.fail("获取订单失败: " + e.getMessage()));
|
||||
}
|
||||
}, executorService);
|
||||
|
||||
return deferredResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片代理接口
|
||||
*/
|
||||
@ApiOperation("图片代理接口")
|
||||
@GetMapping("/image-proxy")
|
||||
public void imageProxy(@RequestParam("url") String imageUrl, javax.servlet.http.HttpServletResponse response) {
|
||||
if (StringUtils.isEmpty(imageUrl)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(999999999);
|
||||
factory.setReadTimeout(999999999);
|
||||
RestTemplate proxyTemplate = new RestTemplate(factory);
|
||||
ResponseEntity<byte[]> imageResponse = proxyTemplate.getForEntity(imageUrl, byte[].class);
|
||||
byte[] imageBytes = imageResponse.getBody();
|
||||
if (imageBytes != null) {
|
||||
String contentType = Optional.ofNullable(imageResponse.getHeaders().getContentType())
|
||||
.map(Object::toString)
|
||||
.orElse("image/jpeg");
|
||||
response.setContentType(contentType);
|
||||
response.setContentLength(imageBytes.length);
|
||||
response.getOutputStream().write(imageBytes);
|
||||
response.getOutputStream().flush();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("图片代理请求失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动刷新token接口
|
||||
*/
|
||||
@GetMapping("/refresh-token")
|
||||
public R<String> manualRefreshToken() {
|
||||
refreshToken();
|
||||
return R.ok("Token刷新请求已执行");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.ruoyi.web.sse;
|
||||
|
||||
import com.ruoyi.system.domain.ClientDevice;
|
||||
import com.ruoyi.system.mapper.ClientDeviceMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@@ -12,6 +16,9 @@ public class SseHubService {
|
||||
|
||||
private final Map<String, SseEmitter> sessionEmitters = new ConcurrentHashMap<>();
|
||||
|
||||
@Autowired
|
||||
private ClientDeviceMapper clientDeviceMapper;
|
||||
|
||||
public String buildSessionKey(String username, String clientId) {
|
||||
return (username == null ? "" : username) + ":" + (clientId == null ? "" : clientId);
|
||||
}
|
||||
@@ -20,18 +27,36 @@ public class SseHubService {
|
||||
String key = buildSessionKey(username, clientId);
|
||||
SseEmitter emitter = new SseEmitter(timeoutMs != null ? timeoutMs : 0L);
|
||||
sessionEmitters.put(key, emitter);
|
||||
emitter.onCompletion(() -> sessionEmitters.remove(key));
|
||||
emitter.onTimeout(() -> sessionEmitters.remove(key));
|
||||
|
||||
// SSE连接建立 = 设备上线
|
||||
updateDeviceStatus(clientId, "online");
|
||||
|
||||
emitter.onCompletion(() -> {
|
||||
sessionEmitters.remove(key);
|
||||
updateDeviceStatus(clientId, "offline");
|
||||
});
|
||||
emitter.onTimeout(() -> {
|
||||
sessionEmitters.remove(key);
|
||||
updateDeviceStatus(clientId, "offline");
|
||||
});
|
||||
emitter.onError((throwable) -> {
|
||||
sessionEmitters.remove(key);
|
||||
updateDeviceStatus(clientId, "offline");
|
||||
});
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
public void sendEvent(String username, String clientId, String type, String message) {
|
||||
String key = buildSessionKey(username, clientId);
|
||||
SseEmitter emitter = sessionEmitters.get(key);
|
||||
|
||||
if (emitter == null) return;
|
||||
|
||||
try {
|
||||
String data = message != null ? message : "{}";
|
||||
emitter.send(SseEmitter.event().name("event").data("{\"type\":\"" + type + "\",\"message\":" + escapeJson(data) + "}"));
|
||||
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) {}
|
||||
@@ -53,6 +78,39 @@ public class SseHubService {
|
||||
private String escapeJson(String raw) {
|
||||
return "\"" + raw.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制断开指定设备的SSE连接
|
||||
*/
|
||||
public void disconnectDevice(String username, String clientId) {
|
||||
String key = buildSessionKey(username, clientId);
|
||||
SseEmitter emitter = sessionEmitters.remove(key);
|
||||
if (emitter != null) {
|
||||
try {
|
||||
emitter.complete();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新设备状态
|
||||
*/
|
||||
private void updateDeviceStatus(String deviceId, String status) {
|
||||
try {
|
||||
ClientDevice device = clientDeviceMapper.selectByDeviceId(deviceId);
|
||||
if (device != null) {
|
||||
// 如果设备被移除,断开SSE连接
|
||||
if ("removed".equals(status)) {
|
||||
disconnectDevice(device.getUsername(), deviceId);
|
||||
}
|
||||
device.setStatus(status);
|
||||
device.setLastActiveAt(new Date());
|
||||
clientDeviceMapper.updateByDeviceId(device);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 静默处理,不影响SSE主流程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user