792 lines
20 KiB
Vue
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> |