Files
erp_sb/electron-vue-template/src/renderer/App.vue
2025-09-27 17:41:43 +08:00

827 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 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)
// 菜单配置 - 复刻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
try {
// 保存token到本地数据库
await authApi.saveToken(data.token)
const username = getUsernameFromToken(data.token)
currentUsername.value = username
userPermissions.value = data?.permissions || ''
await deviceApi.register({username})
// 建立SSE连接
SSEManager.connect()
} catch (e: any) {
// 设备注册失败时回滚登录状态
isAuthenticated.value = false
showAuthDialog.value = true
await authApi.deleteTokenCache()
ElMessage({
message: e?.message || '设备注册失败,请重试',
type: 'error'
})
}
}
async function logout() {
// 主动设置设备离线
try {
const deviceId = await getClientIdFromToken()
if (deviceId) {
await deviceApi.offline({ deviceId })
}
} catch (error) {
console.warn('离线通知失败:', error)
}
const token = await authApi.getToken()
if (token) {
await authApi.logout(token)
}
await authApi.deleteTokenCache()
// 清理前端状态
isAuthenticated.value = false
currentUsername.value = ''
userPermissions.value = ''
showAuthDialog.value = true
showDeviceDialog.value = false
// 关闭SSE连接
SSEManager.disconnect()
}
async function handleUserClick() {
if (!isAuthenticated.value) {
showAuthDialog.value = true
return
}
try {
await ElMessageBox.confirm('确认退出登录?', '提示', {
type: 'warning',
confirmButtonText: '退出',
cancelButtonText: '取消'
})
await logout()
ElMessage({
message: '已退出登录',
type: 'success'
})
} catch {
}
}
function showRegisterDialog() {
showAuthDialog.value = false
showRegDialog.value = true
}
function handleRegisterSuccess() {
showRegDialog.value = false
showAuthDialog.value = true
}
function backToLogin() {
showRegDialog.value = false
showAuthDialog.value = true
}
async function checkAuth() {
const authRequiredMenus = ['rakuten', 'amazon', 'zebra', 'shopee']
try {
await authApi.sessionBootstrap().catch(() => undefined)
const token = await authApi.getToken()
if (token) {
const response = await authApi.verifyToken(token)
if (response?.success) {
isAuthenticated.value = true
currentUsername.value = getUsernameFromToken(token) || ''
SSEManager.connect()
return
}
await authApi.deleteTokenCache()
}
} catch {
// 忽略
}
if (authRequiredMenus.includes(activeMenu.value)) {
showAuthDialog.value = true
}
}
async function getClientIdFromToken(token?: string) {
try {
let t = token
if (!t) {
t = await authApi.getToken()
}
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 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')
if (resp.ok) {
const config = await resp.json()
sseUrl = config.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)
}
},
handleMessage(e: MessageEvent) {
try {
// 处理ping心跳
if (e.type === 'ping') {
return // ping消息自动保持连接无需处理
}
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({
message: '您的设备已被移除,请重新登录',
type: 'warning'
})
break
case 'FORCE_LOGOUT':
logout()
ElMessage({
message: '会话已失效,请重新登录',
type: 'warning'
})
break
case 'PERMISSIONS_UPDATED':
checkAuth()
break
}
} catch (err) {
console.error('SSE消息处理失败:', err, '原始数据:', e.data)
}
},
handleError() {
this.disconnect()
setTimeout(() => this.connect(), 3000)
},
disconnect() {
if (this.connection) {
try {
this.connection.close()
} catch {
}
this.connection = null
}
},
}
async function openDeviceManager() {
if (!isAuthenticated.value) {
showAuthDialog.value = true
return
}
showDeviceDialog.value = true
await fetchDeviceData()
}
async function fetchDeviceData() {
if (!currentUsername.value) {
ElMessage({
message: '未获取到用户名,请重新登录',
type: 'warning'
})
return
}
try {
deviceLoading.value = true
const [quota, list] = await Promise.all([
deviceApi.getQuota(currentUsername.value),
deviceApi.list(currentUsername.value),
])
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({
message: e?.message || '获取设备列表失败',
type: 'error'
})
} 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)
// 如果是本机设备被移除执行logout
const clientId = await getClientIdFromToken()
if (row.deviceId === clientId) {
await logout()
}
ElMessage({
message: '已移除设备',
type: 'success'
})
} catch (e: any) {
ElMessage({
message: '移除设备失败: ' + ((e as any)?.message || '未知错误'),
type: 'error'
})
}
}
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"/>
<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"
@register-success="handleRegisterSuccess"
@back-to-login="backToLogin"/>
<!-- 更新对话框 -->
<UpdateDialog v-model="showUpdateDialog" />
<!-- 设备管理弹框 -->
<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>