Files
erp_sb/electron-vue-template/src/renderer/App.vue
2025-10-09 11:18:26 +08:00

792 lines
20 KiB
Vue

<script setup lang="ts">
import {onMounted, ref, computed, defineAsyncComponent, type Component, onUnmounted} from 'vue'
import {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'
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 UpdateDialog = defineAsyncComponent(() => import('./components/common/UpdateDialog.vue'))
const SettingsDialog = defineAsyncComponent(() => import('./components/common/SettingsDialog.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'])
const currentHistoryIndex = ref(0)
// 应用状态
const activeMenu = ref('rakuten')
const isAuthenticated = ref(false)
const showAuthDialog = ref(false)
const showRegDialog = ref(false)
const zhCnLocale = zhCn
const currentUsername = ref('')
const showDeviceDialog = ref(false)
const deviceLoading = ref(false)
const devices = ref<DeviceItem[]>([])
const deviceQuota = ref<DeviceQuota>({limit: 0, used: 0})
const userPermissions = ref<string>('')
// 更新对话框状态
const showUpdateDialog = ref(false)
// 设置对话框状态
const showSettingsDialog = ref(false)
// 菜单配置 - 复刻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'},
]
// 权限检查 - 复刻ERP客户端逻辑
function hasPermission(module: string) {
// 默认显示的基础菜单(未登录时也显示)
const defaultModules = ['rakuten', 'amazon', 'zebra']
if (!isAuthenticated.value) {
return defaultModules.includes(module)
}
const permissions = userPermissions.value
if (!permissions) {
return defaultModules.includes(module) // 没有权限信息时显示默认菜单
}
return permissions.includes(module)
}
const visibleMenus = computed(() => menuConfig.filter(item => hasPermission(item.key)))
const canGoBack = computed(() => currentHistoryIndex.value > 0)
const canGoForward = computed(() => currentHistoryIndex.value < navigationHistory.value.length - 1)
function showContent() {
const loading = document.getElementById('loading')
if (loading) {
loading.style.opacity = '0'
setTimeout(() => {
loading.style.display = 'none'
}, 100)
}
const app = document.getElementById('app-root')
if (app) app.style.opacity = '1'
}
function addToHistory(menu: string) {
if (navigationHistory.value[currentHistoryIndex.value] !== menu) {
navigationHistory.value = navigationHistory.value.slice(0, currentHistoryIndex.value + 1)
navigationHistory.value.push(menu)
currentHistoryIndex.value = navigationHistory.value.length - 1
}
}
function goBack() {
if (canGoBack.value) {
currentHistoryIndex.value--
activeMenu.value = navigationHistory.value[currentHistoryIndex.value]
}
}
function goForward() {
if (canGoForward.value) {
currentHistoryIndex.value++
activeMenu.value = navigationHistory.value[currentHistoryIndex.value]
}
}
function reloadPage() {
window.location.reload()
}
function handleMenuSelect(key: string) {
// 检查是否需要认证
const authRequiredMenus = ['rakuten', 'amazon', 'zebra', 'shopee']
if (!isAuthenticated.value && authRequiredMenus.includes(key)) {
showAuthDialog.value = true
return
}
activeMenu.value = key
addToHistory(key)
}
async function handleLoginSuccess(data: { token: string; permissions?: string }) {
isAuthenticated.value = true
showAuthDialog.value = false
showRegDialog.value = false
try {
await authApi.saveToken(data.token)
const username = getUsernameFromToken(data.token)
currentUsername.value = username
userPermissions.value = data?.permissions || ''
await deviceApi.register({username})
SSEManager.connect()
} catch (e: any) {
isAuthenticated.value = false
showAuthDialog.value = true
await authApi.deleteTokenCache()
ElMessage.error(e?.message || '设备注册失败')
}
}
async function logout() {
try {
const deviceId = await getClientIdFromToken()
if (deviceId) await deviceApi.offline({ deviceId })
} catch (error) {
console.warn('离线通知失败:', error)
}
try {
const tokenRes: any = await authApi.getToken()
const token = typeof tokenRes === 'string' ? tokenRes : tokenRes?.data
if (token) await authApi.logout(token)
} catch {}
await authApi.deleteTokenCache()
isAuthenticated.value = false
currentUsername.value = ''
userPermissions.value = ''
showAuthDialog.value = true
showDeviceDialog.value = false
SSEManager.disconnect()
}
async function handleUserClick() {
if (!isAuthenticated.value) {
showAuthDialog.value = true
return
}
try {
await ElMessageBox.confirm('确认退出登录?', '提示', {
type: 'warning',
confirmButtonText: '退出',
cancelButtonText: '取消'
})
await logout()
ElMessage.success('已退出登录')
} catch {}
}
function showRegisterDialog() {
showAuthDialog.value = false
showRegDialog.value = true
}
function backToLogin() {
showRegDialog.value = false
showAuthDialog.value = true
}
async function checkAuth() {
try {
await authApi.sessionBootstrap().catch(() => undefined)
const tokenRes: any = await authApi.getToken()
const token = typeof tokenRes === 'string' ? tokenRes : tokenRes?.data
if (token) {
await authApi.verifyToken(token)
isAuthenticated.value = true
currentUsername.value = getUsernameFromToken(token) || ''
SSEManager.connect()
return
}
} catch {
await authApi.deleteTokenCache()
}
if (['rakuten', 'amazon', 'zebra', 'shopee'].includes(activeMenu.value)) {
showAuthDialog.value = true
}
}
async function getClientIdFromToken(token?: string) {
try {
let t = token
if (!t) {
const tokenRes: any = await authApi.getToken()
t = typeof tokenRes === 'string' ? tokenRes : tokenRes?.data
}
if (!t) return ''
const payload = JSON.parse(atob(t.split('.')[1]))
return payload.clientId || ''
} catch {
return ''
}
}
function getUsernameFromToken(token: string) {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return payload.username || ''
} catch {
return ''
}
}
// SSE管理器
const SSEManager = {
connection: null as EventSource | null,
async connect() {
if (this.connection) return
try {
const tokenRes: any = await authApi.getToken()
const token = typeof tokenRes === 'string' ? tokenRes : tokenRes?.data
if (!token) {
console.warn('SSE连接失败: 没有有效的 token')
return
}
const clientId = await getClientIdFromToken(token)
if (!clientId) {
console.warn('SSE连接失败: 无法从 token 获取 clientId')
return
}
let sseUrl = 'http://192.168.1.89:8080/monitor/account/events'
try {
const resp = await fetch('/api/config/server')
if (resp.ok) sseUrl = (await resp.json()).sseUrl || sseUrl
} catch {}
const src = new EventSource(`${sseUrl}?clientId=${clientId}&token=${token}`)
this.connection = src
src.onopen = () => console.log('SSE连接已建立')
src.onmessage = (e) => this.handleMessage(e)
src.onerror = () => this.handleError()
} catch (e: any) {
console.warn('SSE连接失败:', e?.message || e)
this.disconnect()
}
},
handleMessage(e: MessageEvent) {
try {
if (e.type === 'ping') return
console.log('SSE消息:', e.data)
const payload = JSON.parse(e.data)
switch (payload.type) {
case 'ready':
console.log('SSE连接已就绪')
break
case 'DEVICE_REMOVED':
logout()
ElMessage.warning('您的设备已被移除,请重新登录')
break
case 'FORCE_LOGOUT':
logout()
ElMessage.warning('会话已失效,请重新登录')
break
case 'PERMISSIONS_UPDATED':
checkAuth()
break
}
} catch (err) {
console.error('SSE消息处理失败:', err, '原始数据:', e.data)
}
},
handleError() {
if (!this.connection) return
try { this.connection.close() } catch {}
this.connection = null
console.warn('SSE连接失败,已断开')
},
disconnect() {
if (!this.connection) return
try { this.connection.close() } catch {}
this.connection = null
},
}
async function openDeviceManager() {
if (!isAuthenticated.value) {
showAuthDialog.value = true
return
}
showDeviceDialog.value = true
await fetchDeviceData()
}
function openSettings() {
showSettingsDialog.value = true
}
async function fetchDeviceData() {
if (!currentUsername.value) {
ElMessage.warning('未获取到用户名,请重新登录')
return
}
try {
deviceLoading.value = true
const [quotaRes, listRes] = await Promise.all([
deviceApi.getQuota(currentUsername.value),
deviceApi.list(currentUsername.value),
]) as any[]
deviceQuota.value = quotaRes?.data || quotaRes || {limit: 0, used: 0}
const clientId = await getClientIdFromToken()
const list = listRes?.data || listRes || []
devices.value = list.map(d => ({...d, isCurrent: d.deviceId === clientId}))
} catch (e: any) {
ElMessage.error(e?.message || '获取设备列表失败')
} finally {
deviceLoading.value = false
}
}
async function confirmRemoveDevice(row: DeviceItem & { isCurrent?: boolean }) {
try {
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)
const clientId = await getClientIdFromToken()
if (row.deviceId === clientId) await logout()
ElMessage.success('已移除设备')
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('移除设备失败: ' + (e?.message || '未知错误'))
}
}
onMounted(async () => {
showContent()
await checkAuth()
})
onUnmounted(() => {
SSEManager.disconnect()
})
</script>
<template>
<div>
<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>
<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"
@open-settings="openSettings"/>
<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 v-if="activeDashboard">
<component :is="activeDashboard" :key="activeMenu"/>
</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>
<!-- 认证组件 -->
<LoginDialog
v-model="showAuthDialog"
@login-success="handleLoginSuccess"
@show-register="showRegisterDialog"/>
<RegisterDialog
v-model="showRegDialog"
@login-success="handleLoginSuccess"
@back-to-login="backToLogin"/>
<!-- 更新对话框 -->
<UpdateDialog v-model="showUpdateDialog" />
<!-- 设置对话框 -->
<SettingsDialog v-model="showSettingsDialog" />
<!-- 设备管理弹框 -->
<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>
</template>
<style scoped>
.root {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
background-color: #f5f5f5;
opacity: 0;
transition: opacity 0.1s ease;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100%;
position: fixed;
top: 0;
left: 0;
background-color: #f5f5f5;
z-index: 9999;
transition: opacity 0.1s ease;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 5px solid #e6e6e6;
border-top: 5px solid #409EFF;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.erp-container {
display: flex;
height: 100vh;
}
.sidebar {
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;
}
.user-avatar {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 0;
border-bottom: 1px solid #e8eaec;
margin: 0 0 12px 0;
}
.user-avatar img {
width: 50px;
height: 50px;
border-radius: 50%;
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;
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
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;
}
.main-content {
flex: 1;
min-width: 0;
position: relative;
display: flex;
flex-direction: column;
}
.content-body {
position: relative;
flex: 1;
background: #fff;
min-height: 0;
overflow: hidden;
}
.dashboard-home {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
z-index: 100;
}
.icon-container {
display: flex;
justify-content: center;
}
.main-icon {
width: 400px;
height: 400px;
border-radius: 20px;
object-fit: contain;
}
.placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.placeholder-card {
background: #ffffff;
border: 1px solid #e8eaec;
border-radius: 12px;
padding: 24px 28px;
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;
}
.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; }
/* 浮动版本信息 */
.version-info {
position: fixed;
right: 10px;
bottom: 10px;
background: rgba(255,255,255,0.9);
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
color: #909399;
z-index: 1000;
cursor: pointer;
user-select: none;
}
</style>