This commit is contained in:
2025-09-23 17:20:58 +08:00
parent ca2b70dfbe
commit 5f3e9a08f6
25 changed files with 1471 additions and 1095 deletions

View File

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

View File

@@ -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');
},
};

View File

@@ -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',

View File

@@ -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);
},

View File

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

View File

@@ -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: {

View File

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

View File

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

View File

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