Files
erp_sb/electron-vue-template/src/renderer/App.vue
zhangzijienbplus 1be22664c4 feat(subscription): 添加订阅功能并优化过期处理逻辑
- 扩展 trialExpiredType 类型,新增 'subscribe' 状态以支持主动订阅场景
- 新增 openSubscriptionDialog 方法,用于处理 VIP 状态点击事件
- 优化 VIP 状态卡片 UI,添加悬停与点击效果,提升交互体验
- 调整过期状态样式,保持水平布局并移除冗余按钮样式
- 在 Rakuten 组件中引入请求中断机制,提升任务控制灵活性- 更新 TrialExpiredDialog 组件,支持订阅类型提示与微信复制反馈- 修复部分 API 调用未传递 signal 参数的问题,增强请求管理能力
- 切换 Ruoyi 服务地址至生产环境配置,确保接口通信正常
- 移除部分无用代码与样式,精简组件结构
2025-10-21 11:33:41 +08:00

1007 lines
27 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, type Component, onUnmounted, provide} 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'
import {getOrCreateDeviceId} from './utils/deviceId'
import {getToken, setToken, removeToken, getUsernameFromToken, getClientIdFromToken} from './utils/token'
import {CONFIG} from './api/http'
import {getSettings} from './utils/settings'
import LoginDialog from './components/auth/LoginDialog.vue'
import RegisterDialog from './components/auth/RegisterDialog.vue'
import NavigationBar from './components/layout/NavigationBar.vue'
import RakutenDashboard from './components/rakuten/RakutenDashboard.vue'
import AmazonDashboard from './components/amazon/AmazonDashboard.vue'
import ZebraDashboard from './components/zebra/ZebraDashboard.vue'
import UpdateDialog from './components/common/UpdateDialog.vue'
import SettingsDialog from './components/common/SettingsDialog.vue'
import TrialExpiredDialog from './components/common/TrialExpiredDialog.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>('')
// VIP状态
const vipExpireTime = ref<Date | null>(null)
const deviceTrialExpired = ref(false)
const accountType = ref<string>('trial')
const vipStatus = computed(() => {
if (!vipExpireTime.value) return { isVip: false, daysLeft: 0, status: 'expired' }
const now = new Date()
const expire = new Date(vipExpireTime.value)
const daysLeft = Math.ceil((expire.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (daysLeft <= 0) return { isVip: false, daysLeft: 0, status: 'expired' }
if (daysLeft <= 7) return { isVip: true, daysLeft, status: 'warning' }
if (daysLeft <= 30) return { isVip: true, daysLeft, status: 'normal' }
return { isVip: true, daysLeft, status: 'active' }
})
// 功能可用性账号VIP + 设备试用期)
const canUseFunctions = computed(() => {
// 付费账号不受设备限制
if (accountType.value === 'paid') return vipStatus.value.isVip
// 试用账号需要账号VIP有效 且 设备未过期
return vipStatus.value.isVip && !deviceTrialExpired.value
})
// 更新对话框状态
const showUpdateDialog = ref(false)
const updateDialogRef = ref()
// 设置对话框状态
const showSettingsDialog = ref(false)
// 试用期过期对话框
const showTrialExpiredDialog = ref(false)
const trialExpiredType = ref<'device' | 'account' | 'both' | 'subscribe'>('device')
// 菜单配置 - 复刻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; expireTime?: string; accountType?: string; deviceTrialExpired?: boolean }) {
try {
setToken(data.token)
isAuthenticated.value = true
showAuthDialog.value = false
showRegDialog.value = false
currentUsername.value = getUsernameFromToken(data.token)
userPermissions.value = data.permissions || ''
vipExpireTime.value = data.expireTime ? new Date(data.expireTime) : null
accountType.value = data.accountType || 'trial'
deviceTrialExpired.value = data.deviceTrialExpired || false
const deviceId = await getOrCreateDeviceId()
await deviceApi.register({
username: currentUsername.value,
deviceId,
os: navigator.platform
})
SSEManager.connect()
// 根据不同场景显示提示
const accountExpired = vipExpireTime.value && new Date() > vipExpireTime.value
const deviceExpired = deviceTrialExpired.value
const isPaid = accountType.value === 'paid'
if (deviceExpired && accountExpired) {
// 场景4: 试用已到期,请订阅
trialExpiredType.value = 'both'
showTrialExpiredDialog.value = true
} else if (accountExpired) {
// 场景3: 账号试用已到期,请订阅
trialExpiredType.value = 'account'
showTrialExpiredDialog.value = true
} else if (deviceExpired) {
// 场景2: 设备试用已到期,请更换设备或订阅
trialExpiredType.value = 'device'
showTrialExpiredDialog.value = true
}
} catch (e: any) {
isAuthenticated.value = false
showAuthDialog.value = true
removeToken()
ElMessage.error(e?.message || '设备注册失败')
}
}
function clearLocalAuth() {
removeToken()
isAuthenticated.value = false
currentUsername.value = ''
userPermissions.value = ''
vipExpireTime.value = null
deviceTrialExpired.value = false
accountType.value = 'trial'
showAuthDialog.value = true
showDeviceDialog.value = false
SSEManager.disconnect()
}
async function logout() {
try {
const deviceId = getClientIdFromToken()
if (deviceId) await deviceApi.remove({ deviceId, username: currentUsername.value })
} catch (error) {
console.warn('离线通知失败:', error)
}
clearLocalAuth()
}
async function handleUserClick() {
if (!isAuthenticated.value) {
showAuthDialog.value = true
return
}
try {
await ElMessageBox.confirm('确认退出登录?', '提示', {
type: 'warning',
confirmButtonText: '退出',
cancelButtonText: '取消'
})
await logout()
} catch {}
}
function showRegisterDialog() {
showAuthDialog.value = false
showRegDialog.value = true
}
function backToLogin() {
showRegDialog.value = false
showAuthDialog.value = true
}
async function checkAuth() {
try {
const token = getToken()
if (!token) {
if (['rakuten', 'amazon', 'zebra', 'shopee'].includes(activeMenu.value)) {
showAuthDialog.value = true
}
return
}
const res = await authApi.verifyToken(token)
isAuthenticated.value = true
currentUsername.value = getUsernameFromToken(token)
userPermissions.value = res.data.permissions || ''
deviceTrialExpired.value = res.data.deviceTrialExpired || false
accountType.value = res.data.accountType || 'trial'
if (res.data.expireTime) {
vipExpireTime.value = new Date(res.data.expireTime)
}
SSEManager.connect()
} catch {
removeToken()
if (['rakuten', 'amazon', 'zebra', 'shopee'].includes(activeMenu.value)) {
showAuthDialog.value = true
}
}
}
// 刷新VIP状态采集前调用
async function refreshVipStatus() {
try {
const token = getToken()
if (!token) return false
const res = await authApi.verifyToken(token)
deviceTrialExpired.value = res.data.deviceTrialExpired || false
accountType.value = res.data.accountType || 'trial'
if (res.data.expireTime) {
vipExpireTime.value = new Date(res.data.expireTime)
}
return true
} catch {
return false
}
}
// 判断过期类型
function checkExpiredType(): 'device' | 'account' | 'both' | 'subscribe' {
const accountExpired = vipExpireTime.value && new Date() > vipExpireTime.value
const deviceExpired = deviceTrialExpired.value
if (deviceExpired && accountExpired) return 'both'
if (accountExpired) return 'account'
if (deviceExpired) return 'device'
return 'account' // 默认
}
// 打开订阅对话框
function openSubscriptionDialog() {
// 如果VIP有效显示订阅/续费提示;如果已过期,显示过期提示
if (vipStatus.value.isVip) {
trialExpiredType.value = 'subscribe'
} else {
trialExpiredType.value = checkExpiredType()
}
showTrialExpiredDialog.value = true
}
// 提供给子组件使用
provide('refreshVipStatus', refreshVipStatus)
provide('checkExpiredType', checkExpiredType)
const SSEManager = {
connection: null as EventSource | null,
async connect() {
if (this.connection) return
try {
const token = getToken()
if (!token) return
const clientId = getClientIdFromToken()
if (!clientId) return
const src = new EventSource(`${CONFIG.SSE_URL}?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':
clearLocalAuth()
ElMessage.warning('会话已失效,请重新登录')
break
case 'FORCE_LOGOUT':
logout()
ElMessage.warning('会话已失效,请重新登录')
break
case 'PERMISSIONS_UPDATED':
checkAuth()
break
case 'ACCOUNT_EXPIRED':
vipExpireTime.value = null
ElMessage.warning('您的VIP已过期部分功能将受限')
break
case 'VIP_RENEWED':
checkAuth()
ElMessage.success('VIP已续费成功')
break
}
} catch (err) {
console.error('SSE消息处理失败:', err, '原始数据:', e.data)
}
},
handleError() {
console.warn('SSE连接失败,已断开')
this.disconnect()
},
disconnect() {
if (this.connection) {
this.connection.close()
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)
])
deviceQuota.value = quotaRes.data
const clientId = getClientIdFromToken()
devices.value = listRes.data.map(d => ({...d, isCurrent: d.deviceId === clientId}))
} catch (e: any) {
ElMessage.error(e?.message || '获取设备列表失败')
} finally {
deviceLoading.value = false
}
}
async function confirmRemoveDevice(row: DeviceItem) {
try {
await ElMessageBox.confirm('确定要移除该设备吗?', '你确定要移除设备吗?', {
confirmButtonText: '确定移除',
cancelButtonText: '取消',
type: 'warning'
})
await deviceApi.remove({deviceId: row.deviceId, username: currentUsername.value})
devices.value = devices.value.filter(d => d.deviceId !== row.deviceId)
deviceQuota.value.used = Math.max(0, deviceQuota.value.used - 1)
if (row.deviceId === getClientIdFromToken()) {
clearLocalAuth()
}
ElMessage.success('已移除设备')
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('移除设备失败: ' + (e?.message || '未知错误'))
}
}
onMounted(async () => {
showContent()
await checkAuth()
// 检查是否有待安装的更新
await checkPendingUpdate()
})
async function checkPendingUpdate() {
try {
const result = await (window as any).electronAPI.checkPendingUpdate()
if (result && result.hasPendingUpdate) {
// 有待安装的更新,直接弹出安装对话框
showUpdateDialog.value = true
}
} catch (error) {
console.error('检查待安装更新失败:', error)
}
}
// 处理自动更新配置变化
function handleAutoUpdateChanged(enabled: boolean) {
if (enabled && updateDialogRef.value) {
updateDialogRef.value.checkForUpdatesNow()
}
}
// 处理打开更新对话框
function handleOpenUpdateDialog() {
showUpdateDialog.value = true
}
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>
<!-- VIP状态卡片 -->
<div v-if="isAuthenticated" class="vip-status-card" :class="'vip-' + vipStatus.status" @click="openSubscriptionDialog">
<div class="vip-info">
<div class="vip-status-text">
<template v-if="vipStatus.isVip">
<span v-if="vipStatus.status === 'warning'">即将到期</span>
<span v-else>订阅中</span>
</template>
</div>
<div class="vip-expire-date" v-if="vipExpireTime">
有效期至{{ vipExpireTime ? new Date(vipExpireTime).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }) : '-' }}
</div>
</div>
</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"
@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" :is-vip="canUseFunctions"/>
</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 ref="updateDialogRef" v-model="showUpdateDialog" />
<!-- 设置对话框 -->
<SettingsDialog v-model="showSettingsDialog" @auto-update-changed="handleAutoUpdateChanged" @open-update-dialog="handleOpenUpdateDialog" />
<!-- 试用期过期弹框 -->
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType" />
<!-- 设备管理弹框 -->
<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;
display: flex;
flex-direction: column;
}
.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;
}
/* VIP状态卡片样式 */
.vip-status-card {
margin-top: auto;
width: 100%;
min-height: 64px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
box-sizing: border-box;
background: linear-gradient(135deg, #FFFAF0 0%, #FFE4B5 50%, #FFD700 100%);
box-shadow: 0 2px 8px rgba(255, 215, 0, 0.15);
transition: all 0.3s ease;
position: relative;
cursor: pointer;
user-select: none;
}
.vip-status-card:hover {
box-shadow: 0 3px 10px rgba(255, 215, 0, 0.25);
transform: translateY(-1px);
}
.vip-status-card:active {
transform: translateY(0);
}
/* 正常状态和警告状态 - 统一温暖金色渐变 */
.vip-status-card.vip-active,
.vip-status-card.vip-normal,
.vip-status-card.vip-warning {
background: linear-gradient(135deg, #FFFAF0 0%, #FFE4B5 50%, #FFD700 100%);
box-shadow: 0 2px 8px rgba(255, 215, 0, 0.15);
}
/* 过期状态 - 灰色,保持水平布局 */
.vip-status-card.vip-expired {
background: linear-gradient(135deg, #FAFAFA 0%, #E8E8E8 100%);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
.vip-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
text-align: left;
}
.vip-status-text {
font-size: 13px;
font-weight: 600;
color: #8B6914;
text-align: left;
letter-spacing: 0.3px;
}
.vip-expire-date {
font-size: 10px;
color: #A67C00;
line-height: 1.3;
text-align: left;
opacity: 0.9;
}
.vip-status-card.vip-expired .vip-info {
align-items: flex-start;
}
.vip-status-card.vip-expired .vip-status-text {
color: #909399;
}
.vip-status-card.vip-expired .vip-expire-date {
color: #B0B0B0;
}
</style>
<style>
/* 全局样式:限制图片预览器大小 */
.el-image-viewer__img {
max-width: 50vw !important;
max-height: 50vh !important;
width: auto !important;
height: auto !important;
}
</style>