```
feat(api): 添加OLT端口管理和设备发现功能 - 在check.py中更新discover_olt方法,使用scan_and_discover替代check_olt_devices - 在olt.py中添加get_olt_ports和toggle_olt_port接口,支持获取和控制OLT端口状态 - 添加TogglePortRequest模型用于端口操作请求验证 - 更新SSHService类,新增get_olt_ports和toggle_olt_port方法 - 优化CheckService中的MAC地址索引逻辑,支持模型信息更新 ```
This commit is contained in:
@@ -1,34 +1,176 @@
|
||||
<template>
|
||||
<el-container>
|
||||
<el-header>
|
||||
<div class="header-content">
|
||||
<h3>H3C ONU 设备管理系统</h3>
|
||||
<el-button @click="handleLogout">退出登录</el-button>
|
||||
<div class="app-shell">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="2" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.9"/>
|
||||
<rect x="14" y="2" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="2" y="14" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="14" y="14" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-title">H3C ONU</span>
|
||||
<span class="brand-sub">设备管理系统</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-container>
|
||||
<el-aside width="200px">
|
||||
<el-menu router>
|
||||
<el-menu-item index="/dashboard">仪表板</el-menu-item>
|
||||
<el-menu-item index="/devices">设备列表</el-menu-item>
|
||||
<el-menu-item index="/import">数据导入</el-menu-item>
|
||||
<el-menu-item index="/charts">统计图表</el-menu-item>
|
||||
<el-menu-item index="/olt">OLT管理</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main>
|
||||
|
||||
<div class="sidebar-status">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">系统运行中</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<router-link
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
>
|
||||
<span class="nav-icon" v-html="item.icon"></span>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
<span class="nav-indicator"></span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button class="logout-btn" @click="handleLogout">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="main-area">
|
||||
<!-- 顶部栏 -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="breadcrumb">
|
||||
<span class="breadcrumb-root">控制台</span>
|
||||
<span class="breadcrumb-sep">/</span>
|
||||
<span class="breadcrumb-current">{{ currentPageName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="topbar-time">{{ currentTime }}</div>
|
||||
<!-- 主题切换 -->
|
||||
<button class="theme-toggle" @click="themeStore.toggle()" :title="themeStore.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'">
|
||||
<!-- 深色时显示「太阳」图标,点击切换到浅色 -->
|
||||
<svg v-if="themeStore.theme === 'dark'" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
<!-- 浅色时显示「月亮」图标,点击切换到深色 -->
|
||||
<svg v-else width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
<span class="theme-label">{{ themeStore.theme === 'dark' ? '浅色' : '深色' }}</span>
|
||||
</button>
|
||||
<div class="topbar-badge">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
v0.5.0
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<main class="page-content">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const currentTime = ref('')
|
||||
|
||||
const updateTime = () => {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}
|
||||
let timer = null
|
||||
onMounted(() => { updateTime(); timer = setInterval(updateTime, 1000) })
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
label: '统计概览',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>`
|
||||
},
|
||||
{
|
||||
path: '/devices',
|
||||
label: '设备列表',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
|
||||
<line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/>
|
||||
</svg>`
|
||||
},
|
||||
{
|
||||
path: '/olt',
|
||||
label: 'OLT 管理',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<circle cx="7" cy="9" r="1.2" fill="currentColor"/><circle cx="11" cy="9" r="1.2" fill="currentColor"/>
|
||||
<circle cx="7" cy="15" r="1.2" fill="currentColor"/><circle cx="11" cy="15" r="1.2" fill="currentColor"/>
|
||||
<line x1="15" y1="7" x2="20" y2="7"/><line x1="15" y1="12" x2="20" y2="12"/><line x1="15" y1="17" x2="20" y2="17"/>
|
||||
</svg>`
|
||||
},
|
||||
{
|
||||
path: '/charts',
|
||||
label: '统计图表',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/><line x1="2" y1="20" x2="22" y2="20"/>
|
||||
</svg>`
|
||||
},
|
||||
{
|
||||
path: '/import',
|
||||
label: '数据导入',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>`
|
||||
},
|
||||
]
|
||||
|
||||
const isActive = (path) => route.path === path || route.path.startsWith(path + '/')
|
||||
|
||||
const pageNameMap = {
|
||||
'/dashboard': '统计概览',
|
||||
'/devices': '设备列表',
|
||||
'/olt': 'OLT 管理',
|
||||
'/charts': '统计图表',
|
||||
'/import': '数据导入',
|
||||
}
|
||||
const currentPageName = computed(() => pageNameMap[route.path] || '页面')
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
@@ -37,21 +179,283 @@ const handleLogout = () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-header {
|
||||
background: #545c64;
|
||||
color: #fff;
|
||||
.app-shell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
/* ── 侧边栏 ── */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
padding: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 品牌 */
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 22px 20px 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--border-accent);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* 状态指示 */
|
||||
.sidebar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 6px var(--success);
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* 导航 */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 12px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.18s ease;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border-accent);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-icon { opacity: 1; }
|
||||
|
||||
.nav-label { flex: 1; }
|
||||
|
||||
.nav-indicator {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-indicator { opacity: 1; }
|
||||
|
||||
/* 底部 */
|
||||
.sidebar-footer {
|
||||
padding: 14px 10px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: all 0.18s;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
.logout-btn:hover {
|
||||
background: var(--danger-dim);
|
||||
border-color: rgba(239,68,68,0.25);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ── 主区域 ── */
|
||||
.main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 顶部栏 */
|
||||
.topbar {
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.topbar-left, .topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.breadcrumb-root {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
color: var(--border-strong);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.topbar-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.topbar-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 页面内容 */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 主题切换按钮 */
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.theme-label {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './styles/theme.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const STORAGE_KEY = 'onu-theme'
|
||||
const theme = ref(localStorage.getItem(STORAGE_KEY) || 'dark')
|
||||
|
||||
const applyTheme = (t) => {
|
||||
document.documentElement.setAttribute('data-theme', t)
|
||||
}
|
||||
|
||||
// 初始化时立即应用
|
||||
applyTheme(theme.value)
|
||||
|
||||
// 切换
|
||||
const toggle = () => {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
// 监听变化:同步到 DOM + localStorage
|
||||
watch(theme, (t) => {
|
||||
applyTheme(t)
|
||||
localStorage.setItem(STORAGE_KEY, t)
|
||||
})
|
||||
|
||||
return { theme, toggle }
|
||||
})
|
||||
@@ -0,0 +1,732 @@
|
||||
/* ============================================================
|
||||
H3C ONU 管理系统 — 全局主题
|
||||
工业深色风格 · 深海蓝黑 + 青绿主调
|
||||
============================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
/* ── 基础色板 ── */
|
||||
--bg-base: #0a0e1a;
|
||||
--bg-surface: #0f1525;
|
||||
--bg-elevated: #141c30;
|
||||
--bg-card: #18213a;
|
||||
--bg-hover: #1e2a42;
|
||||
--bg-active: #1a3050;
|
||||
|
||||
/* ── 边框 ── */
|
||||
--border-subtle: rgba(255,255,255,0.05);
|
||||
--border-default: rgba(255,255,255,0.10);
|
||||
--border-strong: rgba(255,255,255,0.18);
|
||||
--border-accent: rgba(0,210,180,0.35);
|
||||
|
||||
/* ── 主色调:青绿 ── */
|
||||
--accent: #00d2b4;
|
||||
--accent-dim: rgba(0,210,180,0.15);
|
||||
--accent-glow: rgba(0,210,180,0.30);
|
||||
--accent-dark: #009e88;
|
||||
|
||||
/* ── 状态色 ── */
|
||||
--success: #22c55e;
|
||||
--success-dim: rgba(34,197,94,0.15);
|
||||
--danger: #ef4444;
|
||||
--danger-dim: rgba(239,68,68,0.15);
|
||||
--warning: #f59e0b;
|
||||
--warning-dim: rgba(245,158,11,0.15);
|
||||
--info: #3b82f6;
|
||||
--info-dim: rgba(59,130,246,0.15);
|
||||
|
||||
/* ── 文字 ── */
|
||||
--text-primary: #e8edf5;
|
||||
--text-secondary: #8a9ab8;
|
||||
--text-muted: #4a5a78;
|
||||
--text-accent: #00d2b4;
|
||||
|
||||
/* ── 字体 ── */
|
||||
--font-sans: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Consolas', monospace;
|
||||
|
||||
/* ── 圆角 ── */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
/* ── 阴影 ── */
|
||||
--shadow-card: 0 4px 24px rgba(0,0,0,0.4), 0 1px 4px rgba(0,0,0,0.3);
|
||||
--shadow-glow: 0 0 20px rgba(0,210,180,0.15);
|
||||
}
|
||||
|
||||
/* ── 全局重置 ── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── Element Plus 深色覆盖 ── */
|
||||
|
||||
/* 容器 */
|
||||
.el-container { background: var(--bg-base) !important; }
|
||||
.el-main { background: var(--bg-base) !important; padding: 24px !important; }
|
||||
|
||||
/* 卡片 */
|
||||
.el-card {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
box-shadow: var(--shadow-card) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-card__header {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
padding: 14px 20px !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-card__body { color: var(--text-primary) !important; }
|
||||
|
||||
/* 表格 */
|
||||
.el-table {
|
||||
background: transparent !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
overflow: hidden !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
}
|
||||
.el-table th.el-table__cell {
|
||||
background: var(--bg-elevated) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.05em !important;
|
||||
text-transform: uppercase !important;
|
||||
border-bottom: 1px solid var(--border-default) !important;
|
||||
}
|
||||
.el-table td.el-table__cell {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-table tr { background: transparent !important; }
|
||||
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
||||
background: rgba(255,255,255,0.02) !important;
|
||||
}
|
||||
.el-table__body tr:hover > td.el-table__cell {
|
||||
background: var(--bg-hover) !important;
|
||||
}
|
||||
.el-table__empty-block { background: transparent !important; }
|
||||
.el-table__empty-text { color: var(--text-muted) !important; }
|
||||
.el-table__inner-wrapper::before { background: var(--border-default) !important; }
|
||||
|
||||
/* 分页 */
|
||||
.el-pagination {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
.el-pagination .el-pager li {
|
||||
background: var(--bg-elevated) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
margin: 0 2px !important;
|
||||
}
|
||||
.el-pagination .el-pager li.is-active {
|
||||
background: var(--accent-dim) !important;
|
||||
color: var(--accent) !important;
|
||||
border-color: var(--border-accent) !important;
|
||||
}
|
||||
.el-pagination .el-pager li:hover {
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
.el-pagination button {
|
||||
background: var(--bg-elevated) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
}
|
||||
.el-pagination button:hover { color: var(--accent) !important; }
|
||||
.el-pagination .el-select .el-input__wrapper {
|
||||
background: var(--bg-elevated) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
}
|
||||
.el-pagination .el-select .el-input__inner { color: var(--text-secondary) !important; }
|
||||
|
||||
/* 输入框 */
|
||||
.el-input__wrapper {
|
||||
background: var(--bg-elevated) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
box-shadow: none !important;
|
||||
transition: border-color 0.2s !important;
|
||||
}
|
||||
.el-input__wrapper:hover { border-color: var(--border-strong) !important; }
|
||||
.el-input__wrapper.is-focus {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px var(--accent-dim) !important;
|
||||
}
|
||||
.el-input__inner {
|
||||
color: var(--text-primary) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
.el-input__inner::placeholder { color: var(--text-muted) !important; }
|
||||
.el-textarea__inner {
|
||||
background: var(--bg-elevated) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-textarea__inner:focus {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px var(--accent-dim) !important;
|
||||
}
|
||||
|
||||
/* Select 下拉 */
|
||||
.el-select-dropdown {
|
||||
background: var(--bg-elevated) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
box-shadow: var(--shadow-card) !important;
|
||||
}
|
||||
.el-select-dropdown__item {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
.el-select-dropdown__item:hover,
|
||||
.el-select-dropdown__item.hover {
|
||||
background: var(--bg-hover) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-select-dropdown__item.selected {
|
||||
color: var(--accent) !important;
|
||||
background: var(--accent-dim) !important;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.el-button {
|
||||
border-radius: var(--radius-sm) !important;
|
||||
font-family: var(--font-sans) !important;
|
||||
font-weight: 500 !important;
|
||||
transition: all 0.2s !important;
|
||||
}
|
||||
.el-button--default {
|
||||
background: var(--bg-elevated) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
.el-button--default:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
border-color: var(--border-strong) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-button--primary {
|
||||
background: var(--accent) !important;
|
||||
border-color: var(--accent) !important;
|
||||
color: #0a0e1a !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
.el-button--primary:hover {
|
||||
background: #00f0cc !important;
|
||||
border-color: #00f0cc !important;
|
||||
box-shadow: 0 0 12px var(--accent-glow) !important;
|
||||
}
|
||||
.el-button--success {
|
||||
background: var(--success) !important;
|
||||
border-color: var(--success) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.el-button--danger {
|
||||
background: var(--danger) !important;
|
||||
border-color: var(--danger) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.el-button--warning {
|
||||
background: var(--warning) !important;
|
||||
border-color: var(--warning) !important;
|
||||
color: #0a0e1a !important;
|
||||
}
|
||||
.el-button--info {
|
||||
background: var(--info) !important;
|
||||
border-color: var(--info) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.el-button.is-plain.el-button--danger {
|
||||
background: var(--danger-dim) !important;
|
||||
border-color: var(--danger) !important;
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
.el-button.is-plain.el-button--warning {
|
||||
background: var(--warning-dim) !important;
|
||||
border-color: var(--warning) !important;
|
||||
color: var(--warning) !important;
|
||||
}
|
||||
.el-button.is-plain.el-button--primary {
|
||||
background: var(--accent-dim) !important;
|
||||
border-color: var(--accent) !important;
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
.el-tag {
|
||||
border-radius: var(--radius-sm) !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.03em !important;
|
||||
}
|
||||
.el-tag--success {
|
||||
background: var(--success-dim) !important;
|
||||
border-color: rgba(34,197,94,0.3) !important;
|
||||
color: var(--success) !important;
|
||||
}
|
||||
.el-tag--danger {
|
||||
background: var(--danger-dim) !important;
|
||||
border-color: rgba(239,68,68,0.3) !important;
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
.el-tag--warning {
|
||||
background: var(--warning-dim) !important;
|
||||
border-color: rgba(245,158,11,0.3) !important;
|
||||
color: var(--warning) !important;
|
||||
}
|
||||
.el-tag--info {
|
||||
background: rgba(255,255,255,0.06) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
/* 对话框 */
|
||||
.el-dialog {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.6) !important;
|
||||
}
|
||||
.el-dialog__header {
|
||||
background: var(--bg-elevated) !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
padding: 16px 20px !important;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0 !important;
|
||||
}
|
||||
.el-dialog__title {
|
||||
color: var(--text-primary) !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
.el-dialog__headerbtn .el-dialog__close { color: var(--text-muted) !important; }
|
||||
.el-dialog__headerbtn:hover .el-dialog__close { color: var(--text-primary) !important; }
|
||||
.el-dialog__body { color: var(--text-primary) !important; padding: 20px !important; }
|
||||
.el-dialog__footer {
|
||||
border-top: 1px solid var(--border-subtle) !important;
|
||||
padding: 14px 20px !important;
|
||||
}
|
||||
.el-overlay { background: rgba(0,0,0,0.7) !important; }
|
||||
|
||||
/* 表单 */
|
||||
.el-form-item__label { color: var(--text-secondary) !important; }
|
||||
.el-form--inline .el-form-item { margin-bottom: 0 !important; }
|
||||
|
||||
/* 描述列表 */
|
||||
.el-descriptions__header { color: var(--text-primary) !important; }
|
||||
.el-descriptions__body { background: transparent !important; }
|
||||
.el-descriptions__body .el-descriptions__table { background: transparent !important; }
|
||||
.el-descriptions__cell { background: transparent !important; }
|
||||
.el-descriptions--bordered .el-descriptions__cell {
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-descriptions--bordered .el-descriptions__label {
|
||||
background: var(--bg-elevated) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.el-progress-bar__outer {
|
||||
background: var(--bg-elevated) !important;
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
|
||||
/* 统计数字 */
|
||||
.el-statistic__head { color: var(--text-muted) !important; font-size: 12px !important; }
|
||||
.el-statistic__content { color: var(--text-primary) !important; }
|
||||
.el-statistic__number { color: var(--text-primary) !important; }
|
||||
|
||||
/* 警告框 */
|
||||
.el-alert {
|
||||
border-radius: var(--radius-md) !important;
|
||||
border: 1px solid !important;
|
||||
}
|
||||
.el-alert--success {
|
||||
background: var(--success-dim) !important;
|
||||
border-color: rgba(34,197,94,0.3) !important;
|
||||
}
|
||||
.el-alert--error {
|
||||
background: var(--danger-dim) !important;
|
||||
border-color: rgba(239,68,68,0.3) !important;
|
||||
}
|
||||
.el-alert--warning {
|
||||
background: var(--warning-dim) !important;
|
||||
border-color: rgba(245,158,11,0.3) !important;
|
||||
}
|
||||
.el-alert__title { color: var(--text-primary) !important; }
|
||||
.el-alert__description { color: var(--text-secondary) !important; }
|
||||
|
||||
/* 链接 */
|
||||
.el-link { font-weight: 500 !important; }
|
||||
.el-link--primary { color: var(--accent) !important; }
|
||||
.el-link--primary:hover { color: #00f0cc !important; }
|
||||
|
||||
/* 分割线 */
|
||||
.el-divider { border-color: var(--border-subtle) !important; }
|
||||
.el-divider__text {
|
||||
background: var(--bg-card) !important;
|
||||
color: var(--text-muted) !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
/* 消息提示 */
|
||||
.el-message {
|
||||
background: var(--bg-elevated) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
box-shadow: var(--shadow-card) !important;
|
||||
}
|
||||
.el-message__content { color: var(--text-primary) !important; }
|
||||
|
||||
/* 确认框 */
|
||||
.el-message-box {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
}
|
||||
.el-message-box__header { background: var(--bg-elevated) !important; border-radius: var(--radius-lg) var(--radius-lg) 0 0 !important; }
|
||||
.el-message-box__title { color: var(--text-primary) !important; }
|
||||
.el-message-box__content { color: var(--text-secondary) !important; }
|
||||
|
||||
/* 滚动条 */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* 上传 */
|
||||
.el-upload { display: inline-block !important; }
|
||||
|
||||
/* 徽章 */
|
||||
.el-badge__content { font-size: 10px !important; }
|
||||
|
||||
|
||||
/* ============================================================
|
||||
浅色主题 — 精工白瓷 · 云白 + 深青绿主调
|
||||
[data-theme="light"] 挂载在 <html> 上,覆盖 :root 变量
|
||||
============================================================ */
|
||||
|
||||
[data-theme="light"] {
|
||||
/* ── 基础色板 ── */
|
||||
--bg-base: #f4f6f9;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-elevated: #f0f2f7;
|
||||
--bg-card: #ffffff;
|
||||
--bg-hover: #e8ecf4;
|
||||
--bg-active: #dde5f5;
|
||||
|
||||
/* ── 边框 ── */
|
||||
--border-subtle: rgba(0,0,0,0.06);
|
||||
--border-default: rgba(0,0,0,0.10);
|
||||
--border-strong: rgba(0,0,0,0.18);
|
||||
--border-accent: rgba(0,160,140,0.35);
|
||||
|
||||
/* ── 主色调:深青绿(比深色版略深,在白底上有足够对比) ── */
|
||||
--accent: #00a08c;
|
||||
--accent-dim: rgba(0,160,140,0.10);
|
||||
--accent-glow: rgba(0,160,140,0.25);
|
||||
--accent-dark: #007a6b;
|
||||
|
||||
/* ── 状态色(加深版,确保白底可读性) ── */
|
||||
--success: #16a34a;
|
||||
--success-dim: rgba(22,163,74,0.10);
|
||||
--danger: #dc2626;
|
||||
--danger-dim: rgba(220,38,38,0.08);
|
||||
--warning: #d97706;
|
||||
--warning-dim: rgba(217,119,6,0.10);
|
||||
--info: #2563eb;
|
||||
--info-dim: rgba(37,99,235,0.10);
|
||||
|
||||
/* ── 文字 ── */
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #374151;
|
||||
--text-muted: #9ca3af;
|
||||
--text-accent: #00a08c;
|
||||
|
||||
/* ── 阴影(浅色版用更细腻的阴影) ── */
|
||||
--shadow-card: 0 1px 4px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.06);
|
||||
--shadow-glow: 0 0 16px rgba(0,160,140,0.12);
|
||||
}
|
||||
|
||||
/* ── 浅色主题下的全局背景 ── */
|
||||
[data-theme="light"] body,
|
||||
[data-theme="light"] #app {
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── 浅色主题下的 Element Plus 覆盖 ── */
|
||||
|
||||
/* 容器 */
|
||||
[data-theme="light"] .el-container { background: var(--bg-base) !important; }
|
||||
[data-theme="light"] .el-main { background: var(--bg-base) !important; }
|
||||
|
||||
/* 卡片 */
|
||||
[data-theme="light"] .el-card {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
box-shadow: var(--shadow-card) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-card__header {
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-card__body { color: var(--text-primary) !important; }
|
||||
|
||||
/* 表格 */
|
||||
[data-theme="light"] .el-table {
|
||||
background: var(--bg-card) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
}
|
||||
[data-theme="light"] .el-table th.el-table__cell {
|
||||
background: var(--bg-elevated) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border-bottom: 1px solid var(--border-default) !important;
|
||||
}
|
||||
[data-theme="light"] .el-table td.el-table__cell {
|
||||
background: var(--bg-card) !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-table tr { background: var(--bg-card) !important; }
|
||||
[data-theme="light"] .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
||||
background: #f8fafc !important;
|
||||
}
|
||||
[data-theme="light"] .el-table__body tr:hover > td.el-table__cell {
|
||||
background: var(--bg-hover) !important;
|
||||
}
|
||||
[data-theme="light"] .el-table__empty-block { background: var(--bg-card) !important; }
|
||||
[data-theme="light"] .el-table__empty-text { color: var(--text-muted) !important; }
|
||||
[data-theme="light"] .el-table__inner-wrapper::before { background: var(--border-default) !important; }
|
||||
|
||||
/* 分页 */
|
||||
[data-theme="light"] .el-pagination { color: var(--text-secondary) !important; }
|
||||
[data-theme="light"] .el-pagination .el-pager li {
|
||||
background: var(--bg-card) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
}
|
||||
[data-theme="light"] .el-pagination .el-pager li.is-active {
|
||||
background: var(--accent-dim) !important;
|
||||
color: var(--accent) !important;
|
||||
border-color: var(--border-accent) !important;
|
||||
}
|
||||
[data-theme="light"] .el-pagination .el-pager li:hover { color: var(--accent) !important; }
|
||||
[data-theme="light"] .el-pagination button {
|
||||
background: var(--bg-card) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
}
|
||||
[data-theme="light"] .el-pagination button:hover { color: var(--accent) !important; }
|
||||
[data-theme="light"] .el-pagination .el-select .el-input__wrapper {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
}
|
||||
[data-theme="light"] .el-pagination .el-select .el-input__inner { color: var(--text-secondary) !important; }
|
||||
|
||||
/* 输入框 */
|
||||
[data-theme="light"] .el-input__wrapper {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
[data-theme="light"] .el-input__wrapper:hover { border-color: var(--border-strong) !important; }
|
||||
[data-theme="light"] .el-input__wrapper.is-focus {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px var(--accent-dim) !important;
|
||||
}
|
||||
[data-theme="light"] .el-input__inner {
|
||||
color: var(--text-primary) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
[data-theme="light"] .el-input__inner::placeholder { color: var(--text-muted) !important; }
|
||||
[data-theme="light"] .el-textarea__inner {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-textarea__inner:focus {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px var(--accent-dim) !important;
|
||||
}
|
||||
|
||||
/* Select 下拉 */
|
||||
[data-theme="light"] .el-select-dropdown {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12) !important;
|
||||
}
|
||||
[data-theme="light"] .el-select-dropdown__item { color: var(--text-secondary) !important; }
|
||||
[data-theme="light"] .el-select-dropdown__item:hover,
|
||||
[data-theme="light"] .el-select-dropdown__item.hover {
|
||||
background: var(--bg-hover) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-select-dropdown__item.selected {
|
||||
color: var(--accent) !important;
|
||||
background: var(--accent-dim) !important;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
[data-theme="light"] .el-button--default {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-button--default:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
border-color: var(--border-strong) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-button--primary {
|
||||
background: var(--accent) !important;
|
||||
border-color: var(--accent) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
[data-theme="light"] .el-button--primary:hover {
|
||||
background: #00c4aa !important;
|
||||
border-color: #00c4aa !important;
|
||||
box-shadow: 0 0 10px var(--accent-glow) !important;
|
||||
}
|
||||
[data-theme="light"] .el-button.is-plain.el-button--danger {
|
||||
background: var(--danger-dim) !important;
|
||||
border-color: var(--danger) !important;
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
[data-theme="light"] .el-button.is-plain.el-button--warning {
|
||||
background: var(--warning-dim) !important;
|
||||
border-color: var(--warning) !important;
|
||||
color: var(--warning) !important;
|
||||
}
|
||||
[data-theme="light"] .el-button.is-plain.el-button--primary {
|
||||
background: var(--accent-dim) !important;
|
||||
border-color: var(--accent) !important;
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
[data-theme="light"] .el-tag--info {
|
||||
background: rgba(0,0,0,0.04) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
/* 对话框 */
|
||||
[data-theme="light"] .el-dialog {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.15) !important;
|
||||
}
|
||||
[data-theme="light"] .el-dialog__header {
|
||||
background: var(--bg-elevated) !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
}
|
||||
[data-theme="light"] .el-dialog__title { color: var(--text-primary) !important; }
|
||||
[data-theme="light"] .el-dialog__headerbtn .el-dialog__close { color: var(--text-muted) !important; }
|
||||
[data-theme="light"] .el-dialog__headerbtn:hover .el-dialog__close { color: var(--text-primary) !important; }
|
||||
[data-theme="light"] .el-dialog__body { color: var(--text-primary) !important; }
|
||||
[data-theme="light"] .el-dialog__footer { border-top: 1px solid var(--border-subtle) !important; }
|
||||
[data-theme="light"] .el-overlay { background: rgba(0,0,0,0.4) !important; }
|
||||
|
||||
/* 表单 */
|
||||
[data-theme="light"] .el-form-item__label { color: var(--text-secondary) !important; }
|
||||
|
||||
/* 描述列表 */
|
||||
[data-theme="light"] .el-descriptions__body { background: transparent !important; }
|
||||
[data-theme="light"] .el-descriptions__body .el-descriptions__table { background: transparent !important; }
|
||||
[data-theme="light"] .el-descriptions--bordered .el-descriptions__cell {
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-descriptions--bordered .el-descriptions__label {
|
||||
background: var(--bg-elevated) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
[data-theme="light"] .el-progress-bar__outer { background: var(--bg-elevated) !important; }
|
||||
|
||||
/* 统计数字 */
|
||||
[data-theme="light"] .el-statistic__head { color: var(--text-muted) !important; }
|
||||
[data-theme="light"] .el-statistic__content { color: var(--text-primary) !important; }
|
||||
[data-theme="light"] .el-statistic__number { color: var(--text-primary) !important; }
|
||||
|
||||
/* 警告框 */
|
||||
[data-theme="light"] .el-alert__title { color: var(--text-primary) !important; }
|
||||
[data-theme="light"] .el-alert__description { color: var(--text-secondary) !important; }
|
||||
[data-theme="light"] .el-alert--success {
|
||||
background: var(--success-dim) !important;
|
||||
border-color: rgba(22,163,74,0.25) !important;
|
||||
}
|
||||
[data-theme="light"] .el-alert--error {
|
||||
background: var(--danger-dim) !important;
|
||||
border-color: rgba(220,38,38,0.25) !important;
|
||||
}
|
||||
[data-theme="light"] .el-alert--warning {
|
||||
background: var(--warning-dim) !important;
|
||||
border-color: rgba(217,119,6,0.25) !important;
|
||||
}
|
||||
|
||||
/* 链接 */
|
||||
[data-theme="light"] .el-link--primary { color: var(--accent) !important; }
|
||||
[data-theme="light"] .el-link--primary:hover { color: var(--accent-dark) !important; }
|
||||
|
||||
/* 分割线 */
|
||||
[data-theme="light"] .el-divider { border-color: var(--border-subtle) !important; }
|
||||
[data-theme="light"] .el-divider__text {
|
||||
background: var(--bg-card) !important;
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* 消息提示 */
|
||||
[data-theme="light"] .el-message {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.12) !important;
|
||||
}
|
||||
[data-theme="light"] .el-message__content { color: var(--text-primary) !important; }
|
||||
|
||||
/* 确认框 */
|
||||
[data-theme="light"] .el-message-box {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
}
|
||||
[data-theme="light"] .el-message-box__header {
|
||||
background: var(--bg-elevated) !important;
|
||||
}
|
||||
[data-theme="light"] .el-message-box__title { color: var(--text-primary) !important; }
|
||||
[data-theme="light"] .el-message-box__content { color: var(--text-secondary) !important; }
|
||||
|
||||
/* 滚动条(浅色版) */
|
||||
[data-theme="light"] ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); }
|
||||
[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); }
|
||||
|
||||
/* 主题过渡动画 */
|
||||
html {
|
||||
transition: background-color 0.25s ease, color 0.25s ease;
|
||||
}
|
||||
*, *::before, *::after {
|
||||
transition-property: background-color, border-color, color, box-shadow;
|
||||
transition-duration: 0.2s;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
+176
-30
@@ -1,14 +1,37 @@
|
||||
<template>
|
||||
<div class="charts-container">
|
||||
<el-card class="chart-card">
|
||||
<h3>设备状态趋势</h3>
|
||||
<div ref="trendChart" style="height: 300px"></div>
|
||||
</el-card>
|
||||
<div class="charts-page">
|
||||
<div class="page-header">
|
||||
<div class="page-title-group">
|
||||
<h1 class="page-title">统计图表</h1>
|
||||
<span class="page-subtitle">设备状态趋势与区域分布</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="chart-card">
|
||||
<h3>区域设备分布</h3>
|
||||
<div ref="regionChart" style="height: 300px"></div>
|
||||
</el-card>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 6px">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
7 天状态趋势
|
||||
</span>
|
||||
</div>
|
||||
<div ref="trendChart" class="chart-area"></div>
|
||||
</div>
|
||||
|
||||
<div class="chart-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 6px">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 1 10 10"/>
|
||||
</svg>
|
||||
区域设备分布
|
||||
</span>
|
||||
</div>
|
||||
<div ref="regionChart" class="chart-area"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,34 +43,114 @@ import { getTrend, getByRegion } from '../api/stats'
|
||||
const trendChart = ref(null)
|
||||
const regionChart = ref(null)
|
||||
|
||||
const chartTheme = {
|
||||
backgroundColor: 'transparent',
|
||||
textStyle: { color: '#8a9ab8', fontFamily: 'Noto Sans SC, sans-serif', fontSize: 12 },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', top: '12%', containLabel: true },
|
||||
tooltip: {
|
||||
backgroundColor: '#141c30',
|
||||
borderColor: 'rgba(255,255,255,0.10)',
|
||||
textStyle: { color: '#e8edf5' },
|
||||
extraCssText: 'border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.4);'
|
||||
},
|
||||
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } },
|
||||
axisTick: { lineStyle: { color: 'rgba(255,255,255,0.08)' } },
|
||||
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)', type: 'dashed' } },
|
||||
}
|
||||
|
||||
const initTrendChart = async () => {
|
||||
const { data } = await getTrend(7)
|
||||
const chart = echarts.init(trendChart.value)
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['在线', '离线'] },
|
||||
xAxis: { type: 'category', data: data.map(d => d.date) },
|
||||
yAxis: { type: 'value' },
|
||||
...chartTheme,
|
||||
tooltip: { ...chartTheme.tooltip, trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['在线', '离线'],
|
||||
textStyle: { color: '#8a9ab8' },
|
||||
top: 4,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => d.date),
|
||||
axisLine: chartTheme.axisLine,
|
||||
axisTick: chartTheme.axisTick,
|
||||
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
||||
splitLine: chartTheme.splitLine,
|
||||
},
|
||||
series: [
|
||||
{ name: '在线', type: 'line', data: data.map(d => d.online), smooth: true },
|
||||
{ name: '离线', type: 'line', data: data.map(d => d.offline), smooth: true }
|
||||
]
|
||||
{
|
||||
name: '在线',
|
||||
type: 'line',
|
||||
data: data.map(d => d.online),
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { color: '#00d2b4', width: 2 },
|
||||
itemStyle: { color: '#00d2b4' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(0,210,180,0.20)' },
|
||||
{ offset: 1, color: 'rgba(0,210,180,0.00)' },
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '离线',
|
||||
type: 'line',
|
||||
data: data.map(d => d.offline),
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { color: '#ef4444', width: 2 },
|
||||
itemStyle: { color: '#ef4444' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(239,68,68,0.15)' },
|
||||
{ offset: 1, color: 'rgba(239,68,68,0.00)' },
|
||||
])
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const initRegionChart = async () => {
|
||||
const { data } = await getByRegion()
|
||||
const chart = echarts.init(regionChart.value)
|
||||
|
||||
const colors = ['#00d2b4', '#3b82f6', '#a855f7', '#f59e0b', '#22c55e', '#ef4444', '#ec4899']
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { orient: 'vertical', left: 'left' },
|
||||
...chartTheme,
|
||||
tooltip: { ...chartTheme.tooltip, trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: '5%',
|
||||
top: 'middle',
|
||||
textStyle: { color: '#8a9ab8', fontSize: 12 },
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
},
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: data.map(d => ({ value: d.total, name: d.region }))
|
||||
}]
|
||||
radius: ['40%', '68%'],
|
||||
center: ['38%', '52%'],
|
||||
data: data.map((d, i) => ({
|
||||
value: d.total,
|
||||
name: d.region,
|
||||
itemStyle: { color: colors[i % colors.length] },
|
||||
})),
|
||||
label: { show: false },
|
||||
labelLine: { show: false },
|
||||
emphasis: {
|
||||
itemStyle: { shadowBlur: 16, shadowColor: 'rgba(0,0,0,0.4)' },
|
||||
scaleSize: 6,
|
||||
},
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,17 +161,60 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.charts-container {
|
||||
.charts-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
||||
gap: 20px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(480px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 20px;
|
||||
.chart-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 20px;
|
||||
.panel-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
height: 320px;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+481
-202
@@ -1,102 +1,190 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<span class="page-title">统计信息</span>
|
||||
<el-button size="small" :loading="loading" @click="loadData">刷新</el-button>
|
||||
<div class="page-title-group">
|
||||
<h1 class="page-title">统计概览</h1>
|
||||
<span class="page-subtitle">实时监控 ONU 设备在线状态</span>
|
||||
</div>
|
||||
<button class="refresh-btn" :class="{ loading }" @click="loadData">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ spinning: loading }">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
{{ loading ? '刷新中…' : '刷新数据' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 顶部汇总卡片 -->
|
||||
<el-row :gutter="16" style="margin-bottom: 24px">
|
||||
<el-col :span="6" v-for="card in summaryCards" :key="card.key">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ card.label }}</div>
|
||||
<div class="stat-rate" :style="{ color: rateColor(card.rate) }">
|
||||
{{ card.total > 0 ? card.rate.toFixed(1) + '%' : '-' }}
|
||||
<!-- 汇总卡片 -->
|
||||
<div class="stat-grid">
|
||||
<div
|
||||
v-for="(card, i) in summaryCards"
|
||||
:key="card.key"
|
||||
class="stat-card"
|
||||
:class="card.key"
|
||||
:style="{ animationDelay: i * 80 + 'ms' }"
|
||||
>
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-label">{{ card.label }}</span>
|
||||
<div class="stat-icon" :style="{ color: rateColor(card.rate) }">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="dot online" />
|
||||
<span class="count-text">在线 {{ card.online }}</span>
|
||||
<span class="dot offline" style="margin-left: 12px" />
|
||||
<span class="count-text">离线 {{ card.offline }}</span>
|
||||
</div>
|
||||
<div class="stat-total">共 {{ card.total }} 台</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="stat-rate" :style="{ color: rateColor(card.rate) }">
|
||||
{{ card.total > 0 ? card.rate.toFixed(1) + '%' : '—' }}
|
||||
</div>
|
||||
<div class="stat-meta">
|
||||
<span class="meta-item online">
|
||||
<span class="led online"></span>{{ card.online }} 在线
|
||||
</span>
|
||||
<span class="meta-sep">/</span>
|
||||
<span class="meta-item offline">
|
||||
<span class="led offline"></span>{{ card.offline }} 离线
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-bar-wrap">
|
||||
<div
|
||||
class="stat-bar-fill"
|
||||
:style="{ width: card.total > 0 ? card.rate + '%' : '0%', background: rateColor(card.rate) }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="stat-total">共 {{ card.total }} 台设备</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细列表 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-card class="list-card">
|
||||
<template #header><span class="card-title">城区学校在线率</span></template>
|
||||
<div class="school-list">
|
||||
<template v-if="data.urban?.schools?.length">
|
||||
<div v-for="s in data.urban.schools" :key="s.name" class="school-item">
|
||||
<div class="school-header">
|
||||
<span class="school-name school-link" @click="goToSchool(s.name)">{{ s.name }}</span>
|
||||
<span class="school-rate" :style="{ color: rateColor(rate(s)) }">{{ rate(s).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<el-progress :percentage="rate(s)" :stroke-width="5" :show-text="false" :color="rateColor(rate(s))" />
|
||||
<div class="school-sub">在线 {{ s.online }} / 共 {{ s.total }}</div>
|
||||
<!-- 学校列表 -->
|
||||
<div class="list-grid">
|
||||
<!-- 城区 -->
|
||||
<div class="list-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title-group">
|
||||
<span class="panel-dot urban"></span>
|
||||
<span class="panel-title">城区学校</span>
|
||||
</div>
|
||||
<span class="panel-count">{{ data.urban?.schools?.length || 0 }} 所</span>
|
||||
</div>
|
||||
<div class="school-list">
|
||||
<template v-if="data.urban?.schools?.length">
|
||||
<div
|
||||
v-for="s in data.urban.schools"
|
||||
:key="s.name"
|
||||
class="school-row"
|
||||
@click="goToSchool(s.name)"
|
||||
>
|
||||
<div class="school-info">
|
||||
<span class="school-name">{{ s.name }}</span>
|
||||
<span class="school-stat">{{ s.online }}/{{ s.total }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-text">暂无数据</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card class="list-card">
|
||||
<template #header><span class="card-title">城郊学校在线率</span></template>
|
||||
<div class="school-list">
|
||||
<template v-if="data.suburban?.schools?.length">
|
||||
<div v-for="s in data.suburban.schools" :key="s.name" class="school-item">
|
||||
<div class="school-header">
|
||||
<span class="school-name school-link" @click="goToSchool(s.name)">{{ s.name }}</span>
|
||||
<span class="school-rate" :style="{ color: rateColor(rate(s)) }">{{ rate(s).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<el-progress :percentage="rate(s)" :stroke-width="5" :show-text="false" :color="rateColor(rate(s))" />
|
||||
<div class="school-sub">在线 {{ s.online }} / 共 {{ s.total }}</div>
|
||||
<div class="school-bar-wrap">
|
||||
<div
|
||||
class="school-bar-fill"
|
||||
:style="{ width: rate(s) + '%', background: rateColor(rate(s)) }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-text">暂无数据</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<span class="school-pct" :style="{ color: rateColor(rate(s)) }">
|
||||
{{ rate(s).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-hint">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card class="list-card">
|
||||
<template #header><span class="card-title">乡镇在线率</span></template>
|
||||
<div class="school-list">
|
||||
<template v-if="data.rural?.towns?.length">
|
||||
<div v-for="t in data.rural.towns" :key="t.region" class="school-item town-item" @click="openTown(t)">
|
||||
<div class="school-header">
|
||||
<span class="school-name town-name">{{ t.region }}</span>
|
||||
<span class="school-rate" :style="{ color: rateColor(rate(t)) }">
|
||||
{{ rate(t).toFixed(1) }}%
|
||||
<el-icon style="vertical-align: -2px; margin-left: 2px"><ArrowRight /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
<el-progress :percentage="rate(t)" :stroke-width="5" :show-text="false" :color="rateColor(rate(t))" />
|
||||
<div class="school-sub">在线 {{ t.online }} / 共 {{ t.total }}</div>
|
||||
<!-- 城郊 -->
|
||||
<div class="list-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title-group">
|
||||
<span class="panel-dot suburban"></span>
|
||||
<span class="panel-title">城郊学校</span>
|
||||
</div>
|
||||
<span class="panel-count">{{ data.suburban?.schools?.length || 0 }} 所</span>
|
||||
</div>
|
||||
<div class="school-list">
|
||||
<template v-if="data.suburban?.schools?.length">
|
||||
<div
|
||||
v-for="s in data.suburban.schools"
|
||||
:key="s.name"
|
||||
class="school-row"
|
||||
@click="goToSchool(s.name)"
|
||||
>
|
||||
<div class="school-info">
|
||||
<span class="school-name">{{ s.name }}</span>
|
||||
<span class="school-stat">{{ s.online }}/{{ s.total }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-text">暂无数据</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="school-bar-wrap">
|
||||
<div
|
||||
class="school-bar-fill"
|
||||
:style="{ width: rate(s) + '%', background: rateColor(rate(s)) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="school-pct" :style="{ color: rateColor(rate(s)) }">
|
||||
{{ rate(s).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-hint">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 乡镇学校详情 -->
|
||||
<el-dialog v-model="townVisible" :title="selectedTown?.region + ' — 学校在线率'" width="480px" destroy-on-close>
|
||||
<div class="school-list" style="max-height: 500px; overflow-y: auto; padding-right: 4px">
|
||||
<div v-for="s in selectedTown?.schools" :key="s.name" class="school-item">
|
||||
<div class="school-header">
|
||||
<span class="school-name school-link" @click="goToSchool(s.name)">{{ s.name }}</span>
|
||||
<span class="school-rate" :style="{ color: rateColor(rate(s)) }">{{ rate(s).toFixed(1) }}%</span>
|
||||
<!-- 乡镇 -->
|
||||
<div class="list-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title-group">
|
||||
<span class="panel-dot rural"></span>
|
||||
<span class="panel-title">乡镇地区</span>
|
||||
</div>
|
||||
<el-progress :percentage="rate(s)" :stroke-width="5" :show-text="false" :color="rateColor(rate(s))" />
|
||||
<div class="school-sub">在线 {{ s.online }} / 共 {{ s.total }}</div>
|
||||
<span class="panel-count">{{ data.rural?.towns?.length || 0 }} 个</span>
|
||||
</div>
|
||||
<div class="school-list">
|
||||
<template v-if="data.rural?.towns?.length">
|
||||
<div
|
||||
v-for="t in data.rural.towns"
|
||||
:key="t.region"
|
||||
class="school-row town-row"
|
||||
@click="openTown(t)"
|
||||
>
|
||||
<div class="school-info">
|
||||
<span class="school-name">{{ t.region }}</span>
|
||||
<span class="school-stat">{{ t.online }}/{{ t.total }}</span>
|
||||
</div>
|
||||
<div class="school-bar-wrap">
|
||||
<div
|
||||
class="school-bar-fill"
|
||||
:style="{ width: rate(t) + '%', background: rateColor(rate(t)) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="school-pct" :style="{ color: rateColor(rate(t)) }">
|
||||
{{ rate(t).toFixed(1) }}%
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="vertical-align: -1px; margin-left: 2px">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-hint">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 乡镇详情对话框 -->
|
||||
<el-dialog
|
||||
v-model="townVisible"
|
||||
:title="selectedTown?.region + ' · 学校在线率'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="school-list dialog-list">
|
||||
<div v-for="s in selectedTown?.schools" :key="s.name" class="school-row" @click="goToSchool(s.name)">
|
||||
<div class="school-info">
|
||||
<span class="school-name">{{ s.name }}</span>
|
||||
<span class="school-stat">{{ s.online }}/{{ s.total }}</span>
|
||||
</div>
|
||||
<div class="school-bar-wrap">
|
||||
<div class="school-bar-fill" :style="{ width: rate(s) + '%', background: rateColor(rate(s)) }"></div>
|
||||
</div>
|
||||
<span class="school-pct" :style="{ color: rateColor(rate(s)) }">{{ rate(s).toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -108,12 +196,10 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ArrowRight } from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import request from '../utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref({})
|
||||
const townVisible = ref(false)
|
||||
@@ -125,9 +211,9 @@ const rate = (item) => {
|
||||
}
|
||||
|
||||
const rateColor = (r) => {
|
||||
if (r >= 90) return '#67c23a'
|
||||
if (r >= 70) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
if (r >= 90) return '#22c55e'
|
||||
if (r >= 70) return '#f59e0b'
|
||||
return '#ef4444'
|
||||
}
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
@@ -162,152 +248,345 @@ onMounted(loadData)
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ── 页头 ── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-rate {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.dot.online { background: #67c23a; }
|
||||
.dot.offline { background: #f56c6c; }
|
||||
|
||||
.count-text {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.school-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.school-item {
|
||||
.page-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.town-item {
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.town-item:hover {
|
||||
background: #f5f7fa;
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.school-header {
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.refresh-btn.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* ── 汇总卡片 ── */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
animation: card-in 0.35s ease both;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.02) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--border-accent);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.stat-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.stat-rate {
|
||||
font-size: 38px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-bottom: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.stat-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta-sep {
|
||||
color: var(--border-strong);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.led {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.led.online {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 5px var(--success);
|
||||
}
|
||||
|
||||
.led.offline {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.stat-bar-wrap {
|
||||
height: 3px;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ── 学校列表 ── */
|
||||
.list-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.panel-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-dot.urban { background: #3b82f6; box-shadow: 0 0 6px #3b82f6; }
|
||||
.panel-dot.suburban { background: #a855f7; box-shadow: 0 0 6px #a855f7; }
|
||||
.panel-dot.rural { background: #f59e0b; box-shadow: 0 0 6px #f59e0b; }
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.panel-count {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-hover);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.school-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
max-height: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dialog-list {
|
||||
max-height: 450px;
|
||||
}
|
||||
|
||||
.school-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 64px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.school-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.town-row {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.town-row:hover {
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.school-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.school-name {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.school-row:hover .school-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.school-stat {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.school-link {
|
||||
cursor: pointer;
|
||||
color: #409eff;
|
||||
}
|
||||
.school-link:hover {
|
||||
text-decoration: underline;
|
||||
.school-bar-wrap {
|
||||
height: 3px;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.town-name {
|
||||
font-weight: 500;
|
||||
.school-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.school-rate {
|
||||
font-size: 13px;
|
||||
.school-pct {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.school-sub {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
color: #c0c4cc;
|
||||
font-size: 13px;
|
||||
padding: 40px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
+456
-155
@@ -1,90 +1,155 @@
|
||||
<template>
|
||||
<div class="device-list">
|
||||
<el-card>
|
||||
<el-form inline>
|
||||
<el-form-item label="区域">
|
||||
<el-select v-model="filters.region" placeholder="全部区域" clearable @change="search" style="width: 180px">
|
||||
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="全部" clearable @change="search" style="width: 100px">
|
||||
<el-option label="在线" value="online" />
|
||||
<el-option label="离线" value="offline" />
|
||||
<el-option label="未知" value="unknown" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="搜索">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="MAC/区域/学校/楼宇/房间号"
|
||||
clearable
|
||||
@clear="search"
|
||||
@keyup.enter="search"
|
||||
style="width: 260px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item style="margin-left: auto">
|
||||
<el-button type="danger" plain @click="clearStatus" :loading="clearing">清空状态</el-button>
|
||||
<el-button type="warning" @click="refreshAllStatus" :loading="refreshing" style="margin-left: 8px">
|
||||
{{ refreshing ? '更新中...' : '全部更新' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!-- 页头 -->
|
||||
<div class="page-header">
|
||||
<div class="page-title-group">
|
||||
<h1 class="page-title">设备列表</h1>
|
||||
<span class="page-subtitle">ONU 终端设备管理</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="danger" plain size="small" @click="clearStatus" :loading="clearing">
|
||||
清空状态
|
||||
</el-button>
|
||||
<el-button type="warning" size="small" @click="refreshAllStatus" :loading="refreshing">
|
||||
{{ refreshing ? '更新中…' : '全部更新' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="devices" border stripe>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="region" label="区域" width="100" />
|
||||
<el-table-column prop="school_name" label="学校名称" width="180" />
|
||||
<el-table-column prop="building" label="楼宇" width="130" />
|
||||
<el-table-column prop="place_type" label="场所类型" width="130" />
|
||||
<el-table-column prop="room_number" label="房间号" width="100" />
|
||||
<el-table-column prop="mac_address" label="MAC地址" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="openDetail(row)">
|
||||
{{ formatMac(row.mac_address) }}
|
||||
</el-link>
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">区域</label>
|
||||
<el-select
|
||||
v-model="filters.region"
|
||||
placeholder="全部区域"
|
||||
clearable
|
||||
@change="search"
|
||||
size="small"
|
||||
style="width: 160px"
|
||||
>
|
||||
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">状态</label>
|
||||
<el-select
|
||||
v-model="filters.status"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
@change="search"
|
||||
size="small"
|
||||
style="width: 110px"
|
||||
>
|
||||
<el-option label="在线" value="online" />
|
||||
<el-option label="离线" value="offline" />
|
||||
<el-option label="未知" value="unknown" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">搜索</label>
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="MAC / 区域 / 学校 / 楼宇 / 房间"
|
||||
clearable
|
||||
size="small"
|
||||
@clear="search"
|
||||
@keyup.enter="search"
|
||||
style="width: 280px"
|
||||
>
|
||||
<template #prefix>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.status === 'online' ? 'success' : row.status === 'offline' ? 'danger' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ row.status === 'online' ? '在线' : row.status === 'offline' ? '离线' : '未知' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="distance_m" label="距离" width="90" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ row.distance_m ? row.distance_m + 'm' : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="notes" label="备注" min-width="100" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-input>
|
||||
</div>
|
||||
<el-button type="primary" size="small" @click="search">查询</el-button>
|
||||
</div>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
:total="total"
|
||||
@current-change="loadDevices"
|
||||
@size-change="handleSizeChange"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
style="margin-top: 20px; justify-content: flex-end"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 检查结果对话框 -->
|
||||
<el-dialog v-model="checkResultVisible" title="全部更新结果" width="700px" destroy-on-close>
|
||||
<div v-if="checkInProgress" style="text-align: center; padding: 40px 0; color: #909399">
|
||||
正在扫描所有 OLT,请稍候...
|
||||
<!-- 数据面板 -->
|
||||
<div class="data-panel">
|
||||
<!-- 统计行 -->
|
||||
<div class="data-stats">
|
||||
<span class="data-total">共 <strong>{{ total }}</strong> 条记录</span>
|
||||
<div class="status-legend">
|
||||
<span class="legend-item">
|
||||
<span class="led online"></span>在线
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span class="led offline"></span>离线
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span class="led unknown"></span>未知
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
:data="devices"
|
||||
border
|
||||
stripe
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column type="index" label="#" width="52" align="center">
|
||||
<template #default="{ $index }">
|
||||
<span class="row-index">{{ (page - 1) * pageSize + $index + 1 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="region" label="区域" width="90" />
|
||||
<el-table-column prop="school_name" label="学校名称" width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="building" label="楼宇" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="place_type" label="场所类型" width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="room_number" label="房间号" width="90" />
|
||||
<el-table-column prop="mac_address" label="MAC 地址" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="mac-link" @click="openDetail(row)">
|
||||
{{ formatMac(row.mac_address) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="76" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="status-badge" :class="row.status">
|
||||
<span class="status-dot-sm"></span>
|
||||
{{ row.status === 'online' ? '在线' : row.status === 'offline' ? '离线' : '未知' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="distance_m" label="距离" width="80" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val">{{ row.distance_m ? row.distance_m + 'm' : '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="notes" label="备注" min-width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span style="color: var(--text-muted); font-size: 12px">{{ row.notes || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
:total="total"
|
||||
@current-change="loadDevices"
|
||||
@size-change="handleSizeChange"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 检查结果对话框 -->
|
||||
<el-dialog v-model="checkResultVisible" title="全部更新结果" width="680px" destroy-on-close>
|
||||
<div v-if="checkInProgress" class="dialog-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>正在扫描所有 OLT,请稍候…</span>
|
||||
</div>
|
||||
<div v-else-if="checkResult">
|
||||
<el-alert
|
||||
:type="checkResult.success ? 'success' : 'error'"
|
||||
@@ -93,27 +158,34 @@
|
||||
:closable="false"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<template v-if="checkResult.success">
|
||||
<div style="display: flex; gap: 24px; margin-bottom: 16px">
|
||||
<el-statistic title="在线设备" :value="checkResult.total_online || 0" />
|
||||
<el-statistic title="离线设备" :value="checkResult.total_offline || 0" />
|
||||
<el-statistic title="失败 OLT" :value="(checkResult.errors || []).length" />
|
||||
<div class="result-stats">
|
||||
<div class="result-stat">
|
||||
<span class="rs-val online">{{ checkResult.total_online || 0 }}</span>
|
||||
<span class="rs-label">在线设备</span>
|
||||
</div>
|
||||
<div class="result-stat">
|
||||
<span class="rs-val offline">{{ checkResult.total_offline || 0 }}</span>
|
||||
<span class="rs-label">离线设备</span>
|
||||
</div>
|
||||
<div class="result-stat">
|
||||
<span class="rs-val warn">{{ (checkResult.errors || []).length }}</span>
|
||||
<span class="rs-label">失败 OLT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="checkResult.results || []" border stripe size="small" max-height="300">
|
||||
<el-table-column prop="olt_location" label="OLT名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="在线" width="80" align="center">
|
||||
<el-table :data="checkResult.results || []" border size="small" max-height="280">
|
||||
<el-table-column prop="olt_location" label="OLT 名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="在线" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="success" size="small">{{ row.online || 0 }}</el-tag>
|
||||
<span class="mono-val" style="color: var(--success)">{{ row.online || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="离线" width="80" align="center">
|
||||
<el-table-column label="离线" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="danger" size="small">{{ row.offline || 0 }}</el-tag>
|
||||
<span class="mono-val" style="color: var(--danger)">{{ row.offline || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.success ? 'success' : 'danger'" size="small">
|
||||
{{ row.success ? '成功' : '失败' }}
|
||||
@@ -123,7 +195,6 @@
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="checkResultVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="checkResultVisible = false; loadDevices()" v-if="!checkInProgress">
|
||||
@@ -135,35 +206,29 @@
|
||||
<!-- 设备详情对话框 -->
|
||||
<el-dialog v-model="detailVisible" title="设备详情" width="500px" destroy-on-close>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="MAC地址" :span="2">
|
||||
{{ formatMac(selectedDevice.mac_address) }}
|
||||
<el-descriptions-item label="MAC 地址" :span="2">
|
||||
<span class="mono-val accent">{{ formatMac(selectedDevice.mac_address) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="区域">{{ selectedDevice.region }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学校">{{ selectedDevice.school_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="楼宇">{{ selectedDevice.building || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="房间号">{{ selectedDevice.room_number || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="楼宇">{{ selectedDevice.building || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="房间号">{{ selectedDevice.room_number || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="selectedDevice.status === 'online' ? 'success' : selectedDevice.status === 'offline' ? 'danger' : 'info'" size="small">
|
||||
<span class="status-badge" :class="selectedDevice.status">
|
||||
<span class="status-dot-sm"></span>
|
||||
{{ selectedDevice.status === 'online' ? '在线' : selectedDevice.status === 'offline' ? '离线' : '未知' }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="距离">
|
||||
{{ selectedDevice.distance_m ? selectedDevice.distance_m + ' 米' : '-' }}
|
||||
<span class="mono-val">{{ selectedDevice.distance_m ? selectedDevice.distance_m + ' 米' : '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="端口" :span="2">
|
||||
{{ formatPort(selectedDevice) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="型号" :span="2">
|
||||
{{ selectedDevice.model || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="所属OLT" :span="2">
|
||||
{{ selectedDevice.olt_location || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">
|
||||
{{ selectedDevice.notes || '-' }}
|
||||
<span class="mono-val">{{ formatPort(selectedDevice) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="型号" :span="2">{{ selectedDevice.model || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属 OLT" :span="2">{{ selectedDevice.olt_location || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ selectedDevice.notes || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template #footer>
|
||||
<el-button type="info" :loading="deviceRefreshing" @click="doRefreshDevice" :disabled="!selectedDevice.olt_id">更新</el-button>
|
||||
<el-button type="primary" @click="openEdit">编辑</el-button>
|
||||
@@ -175,23 +240,13 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<el-dialog v-model="editVisible" title="编辑设备信息" width="480px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="80px">
|
||||
<el-form-item label="区域">
|
||||
<el-input v-model="editForm.region" />
|
||||
</el-form-item>
|
||||
<el-form-item label="学校">
|
||||
<el-input v-model="editForm.school_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="楼宇">
|
||||
<el-input v-model="editForm.building" />
|
||||
</el-form-item>
|
||||
<el-form-item label="房间号">
|
||||
<el-input v-model="editForm.room_number" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="editForm.notes" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
<el-dialog v-model="editVisible" title="编辑设备信息" width="460px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="70px">
|
||||
<el-form-item label="区域"><el-input v-model="editForm.region" /></el-form-item>
|
||||
<el-form-item label="学校"><el-input v-model="editForm.school_name" /></el-form-item>
|
||||
<el-form-item label="楼宇"><el-input v-model="editForm.building" /></el-form-item>
|
||||
<el-form-item label="房间号"><el-input v-model="editForm.room_number" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="editForm.notes" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
@@ -200,7 +255,7 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 业务下发对话框 -->
|
||||
<el-dialog v-model="provisionVisible" title="业务下发" width="560px" destroy-on-close>
|
||||
<el-dialog v-model="provisionVisible" title="业务下发" width="540px" destroy-on-close>
|
||||
<el-alert
|
||||
v-if="provisionResult"
|
||||
:type="provisionResult.success ? 'success' : 'error'"
|
||||
@@ -210,25 +265,30 @@
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<el-descriptions :column="2" border v-if="selectedDevice.mac_address">
|
||||
<el-descriptions-item label="MAC地址">{{ formatMac(selectedDevice.mac_address) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="端口">{{ formatPort(selectedDevice) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学校">{{ selectedDevice.school_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="MAC 地址">
|
||||
<span class="mono-val accent">{{ formatMac(selectedDevice.mac_address) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="端口">
|
||||
<span class="mono-val">{{ formatPort(selectedDevice) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="学校">{{ selectedDevice.school_name || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="selectedDevice.status === 'online' ? 'success' : selectedDevice.status === 'offline' ? 'danger' : 'info'" size="small">
|
||||
<span class="status-badge" :class="selectedDevice.status">
|
||||
<span class="status-dot-sm"></span>
|
||||
{{ selectedDevice.status === 'online' ? '在线' : selectedDevice.status === 'offline' ? '离线' : '未知' }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider>将执行的配置</el-divider>
|
||||
<el-text type="info" size="small">
|
||||
<pre style="margin: 0; font-size: 12px; line-height: 1.6">system-view
|
||||
<div class="cli-block">
|
||||
<pre>system-view
|
||||
interface {{ formatPort(selectedDevice) }}
|
||||
uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010
|
||||
port link-type trunk
|
||||
undo port trunk permit vlan 1
|
||||
port trunk permit vlan 2000 3000 to 3010 4094
|
||||
save force</pre>
|
||||
</el-text>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="provisionVisible = false; provisionResult = null">关闭</el-button>
|
||||
<el-button
|
||||
@@ -259,29 +319,24 @@ const pageSize = ref(20)
|
||||
const regions = ref([])
|
||||
const filters = ref({ region: '', keyword: '', school_name: '', status: '' })
|
||||
|
||||
// 详情对话框
|
||||
const detailVisible = ref(false)
|
||||
const selectedDevice = ref({})
|
||||
const deviceRefreshing = ref(false)
|
||||
|
||||
// 业务下发对话框
|
||||
const provisionVisible = ref(false)
|
||||
const provisionLoading = ref(false)
|
||||
const provisionResult = ref(null)
|
||||
const refreshing = ref(false)
|
||||
const clearing = ref(false)
|
||||
|
||||
// 编辑对话框
|
||||
const editVisible = ref(false)
|
||||
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', notes: '' })
|
||||
const editSaving = ref(false)
|
||||
|
||||
// 检查结果对话框
|
||||
const checkResultVisible = ref(false)
|
||||
const checkResult = ref(null)
|
||||
const checkInProgress = ref(false)
|
||||
|
||||
// 清空所有设备状态
|
||||
const clearStatus = async () => {
|
||||
clearing.value = true
|
||||
try {
|
||||
@@ -295,13 +350,11 @@ const clearStatus = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新所有设备状态
|
||||
const refreshAllStatus = async () => {
|
||||
refreshing.value = true
|
||||
checkResultVisible.value = true
|
||||
checkInProgress.value = true
|
||||
checkResult.value = null
|
||||
|
||||
try {
|
||||
const { data } = await deviceApi.refreshAllStatus()
|
||||
checkResult.value = {
|
||||
@@ -319,7 +372,6 @@ const refreshAllStatus = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化 MAC 地址
|
||||
const formatMac = (mac) => {
|
||||
if (!mac) return ''
|
||||
const clean = mac.toUpperCase().replace(/[:-]/g, '')
|
||||
@@ -329,27 +381,25 @@ const formatMac = (mac) => {
|
||||
clean.substring(8, 12).toLowerCase()
|
||||
}
|
||||
|
||||
// 格式化端口显示
|
||||
const formatPort = (device) => {
|
||||
if (device.port_id) return `Onu${device.port_id}`
|
||||
if (!device.slot_number && !device.port_number) return '-'
|
||||
if (!device.slot_number && !device.port_number) return '—'
|
||||
return `Onu${device.slot_number || '?'}/${device.port_number || '?'}`
|
||||
}
|
||||
|
||||
// 打开详情对话框
|
||||
const openDetail = (row) => {
|
||||
selectedDevice.value = { ...row }
|
||||
provisionResult.value = null
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
// 单台设备状态更新
|
||||
const doRefreshDevice = async () => {
|
||||
deviceRefreshing.value = true
|
||||
try {
|
||||
const { data } = await deviceApi.refreshDevice(selectedDevice.value.id)
|
||||
selectedDevice.value.status = data.status
|
||||
selectedDevice.value.distance_m = data.distance_m
|
||||
if (data.model) selectedDevice.value.model = data.model
|
||||
ElMessage.success(`更新完成:${data.status === 'online' ? '在线' : '离线'}${data.distance_m ? ',距离 ' + data.distance_m + 'm' : ''}`)
|
||||
loadDevices()
|
||||
} catch (error) {
|
||||
@@ -359,7 +409,6 @@ const doRefreshDevice = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const openEdit = () => {
|
||||
editForm.value = {
|
||||
region: selectedDevice.value.region || '',
|
||||
@@ -386,13 +435,11 @@ const saveEdit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开业务下发对话框
|
||||
const openProvision = () => {
|
||||
provisionResult.value = null
|
||||
provisionVisible.value = true
|
||||
}
|
||||
|
||||
// 执行业务下发
|
||||
const doProvision = async () => {
|
||||
provisionLoading.value = true
|
||||
try {
|
||||
@@ -408,7 +455,6 @@ const doProvision = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载区域列表
|
||||
const loadRegions = async () => {
|
||||
const { data } = await deviceApi.getRegions()
|
||||
regions.value = data
|
||||
@@ -449,6 +495,261 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.device-list {
|
||||
padding: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 页头 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 18px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 数据面板 */
|
||||
.data-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.data-total {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.data-total strong {
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.status-legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.led {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.led.online { background: var(--success); box-shadow: 0 0 5px var(--success); }
|
||||
.led.offline { background: var(--danger); }
|
||||
.led.unknown { background: var(--text-muted); }
|
||||
|
||||
/* 表格中的元素 */
|
||||
.row-index {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mac-link {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.02em;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.mac-link:hover {
|
||||
color: #00f0cc;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge.online {
|
||||
background: var(--success-dim);
|
||||
color: var(--success);
|
||||
border: 1px solid rgba(34,197,94,0.25);
|
||||
}
|
||||
|
||||
.status-badge.offline {
|
||||
background: var(--danger-dim);
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(239,68,68,0.25);
|
||||
}
|
||||
|
||||
.status-badge.unknown {
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.status-dot-sm {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.online .status-dot-sm { box-shadow: 0 0 4px currentColor; }
|
||||
|
||||
.mono-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mono-val.accent { color: var(--accent); font-size: 13px; }
|
||||
|
||||
/* 分页 */
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* 对话框内元素 */
|
||||
.dialog-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 48px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid var(--border-default);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.result-stats {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
padding: 16px 0;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.result-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rs-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rs-val.online { color: var(--success); }
|
||||
.rs-val.offline { color: var(--danger); }
|
||||
.rs-val.warn { color: var(--warning); }
|
||||
|
||||
.rs-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* CLI 预览块 */
|
||||
.cli-block {
|
||||
background: #0a0e1a;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cli-block pre {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: #a3e6d8;
|
||||
line-height: 1.7;
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,81 +1,143 @@
|
||||
<template>
|
||||
<div class="import-page">
|
||||
<el-card>
|
||||
<div class="header">
|
||||
<h2>数据导入</h2>
|
||||
<el-button type="success" @click="handleDownloadTemplate">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载模板
|
||||
</el-button>
|
||||
<div class="page-header">
|
||||
<div class="page-title-group">
|
||||
<h1 class="page-title">数据导入</h1>
|
||||
<span class="page-subtitle">批量导入 ONU 设备信息</span>
|
||||
</div>
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
accept=".xlsx,.xls"
|
||||
>
|
||||
<el-button type="primary">选择文件</el-button>
|
||||
</el-upload>
|
||||
<el-button @click="handleUpload" :loading="uploading" style="margin-top: 20px">
|
||||
开始导入
|
||||
</el-button>
|
||||
<div v-if="result" style="margin-top: 20px">
|
||||
<el-alert
|
||||
:title="`成功: ${result.success}`"
|
||||
type="success"
|
||||
:closable="false"
|
||||
style="margin-bottom: 10px"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="result.failed && result.failed.length > 0"
|
||||
:title="`失败: ${result.failed.length}`"
|
||||
type="error"
|
||||
:closable="false"
|
||||
style="margin-bottom: 10px"
|
||||
/>
|
||||
<el-table
|
||||
v-if="result.failed && result.failed.length > 0"
|
||||
:data="result.failed"
|
||||
border
|
||||
style="margin-top: 10px"
|
||||
max-height="300"
|
||||
>
|
||||
<el-table-column label="MAC地址" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.record?.mac_address || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="record.school_name" label="学校名称" width="180" />
|
||||
<el-table-column prop="error" label="失败原因" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="import-grid">
|
||||
<!-- 上传区 -->
|
||||
<div class="import-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">上传文件</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="step-hint">
|
||||
<div class="step-item">
|
||||
<span class="step-num">1</span>
|
||||
<span>下载模板文件,按格式填写设备信息</span>
|
||||
</div>
|
||||
<div class="step-item">
|
||||
<span class="step-num">2</span>
|
||||
<span>选择填写好的 Excel 文件(.xlsx / .xls)</span>
|
||||
</div>
|
||||
<div class="step-item">
|
||||
<span class="step-num">3</span>
|
||||
<span>点击「开始导入」,等待处理完成</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
:show-file-list="false"
|
||||
accept=".xlsx,.xls"
|
||||
drag
|
||||
class="upload-zone"
|
||||
>
|
||||
<div class="upload-inner">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="upload-icon">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<div class="upload-text">
|
||||
<span v-if="!selectedFile">拖拽文件到此处,或 <em>点击选择</em></span>
|
||||
<span v-else class="file-selected">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="color: var(--success); vertical-align: -2px">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
{{ selectedFile.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="upload-sub">支持 .xlsx .xls 格式</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<div class="action-row">
|
||||
<el-button @click="handleDownloadTemplate" size="default">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 5px">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
下载模板
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="default"
|
||||
:loading="uploading"
|
||||
:disabled="!selectedFile"
|
||||
@click="handleUpload"
|
||||
>
|
||||
{{ uploading ? '导入中…' : '开始导入' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 结果区 -->
|
||||
<div class="import-panel result-panel" v-if="result">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">导入结果</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="result-summary">
|
||||
<div class="rs-card success">
|
||||
<span class="rs-num">{{ result.success }}</span>
|
||||
<span class="rs-label">成功导入</span>
|
||||
</div>
|
||||
<div class="rs-card" :class="result.failed?.length ? 'failed' : 'empty'">
|
||||
<span class="rs-num">{{ result.failed?.length || 0 }}</span>
|
||||
<span class="rs-label">导入失败</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="result.failed?.length" class="failed-table-wrap">
|
||||
<div class="failed-title">失败记录详情</div>
|
||||
<el-table :data="result.failed" border size="small" max-height="280">
|
||||
<el-table-column label="MAC 地址" width="155">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val">{{ row.record?.mac_address || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="record.school_name" label="学校名称" width="160" />
|
||||
<el-table-column prop="error" label="失败原因">
|
||||
<template #default="{ row }">
|
||||
<span style="color: var(--danger); font-size: 12px">{{ row.error }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import * as importApi from '../api/import'
|
||||
|
||||
const file = ref(null)
|
||||
const selectedFile = ref(null)
|
||||
const uploading = ref(false)
|
||||
const result = ref(null)
|
||||
|
||||
const handleFileChange = (uploadFile) => {
|
||||
file.value = uploadFile.raw
|
||||
selectedFile.value = uploadFile
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file.value) {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.value)
|
||||
|
||||
formData.append('file', selectedFile.value.raw)
|
||||
try {
|
||||
const { data } = await importApi.uploadExcel(formData)
|
||||
result.value = data
|
||||
@@ -109,12 +171,219 @@ const handleDownloadTemplate = async () => {
|
||||
|
||||
<style scoped>
|
||||
.import-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.import-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.import-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
|
||||
/* 步骤提示 */
|
||||
.step-hint {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--border-accent);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* 上传区域 */
|
||||
.upload-zone :deep(.el-upload) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-zone :deep(.el-upload-dragger) {
|
||||
background: var(--bg-elevated) !important;
|
||||
border: 2px dashed var(--border-default) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
transition: border-color 0.2s, background 0.2s !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.upload-zone :deep(.el-upload-dragger:hover) {
|
||||
border-color: var(--accent) !important;
|
||||
background: var(--accent-dim) !important;
|
||||
}
|
||||
|
||||
.upload-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 36px 24px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.upload-text em {
|
||||
font-style: normal;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--success);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 操作行 */
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 结果 */
|
||||
.result-summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.rs-card {
|
||||
flex: 1;
|
||||
padding: 18px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.rs-card.success {
|
||||
background: var(--success-dim);
|
||||
border-color: rgba(34,197,94,0.25);
|
||||
}
|
||||
|
||||
.rs-card.failed {
|
||||
background: var(--danger-dim);
|
||||
border-color: rgba(239,68,68,0.25);
|
||||
}
|
||||
|
||||
.rs-card.empty {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.rs-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rs-card.success .rs-num { color: var(--success); }
|
||||
.rs-card.failed .rs-num { color: var(--danger); }
|
||||
.rs-card.empty .rs-num { color: var(--text-muted); }
|
||||
|
||||
.rs-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.failed-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mono-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
+197
-24
@@ -1,23 +1,52 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<el-card class="login-card">
|
||||
<h2>H3C ONU 设备管理系统</h2>
|
||||
<div v-if="!error">
|
||||
<el-icon class="is-loading" style="font-size: 24px; margin-bottom: 12px"><Loading /></el-icon>
|
||||
<p>正在跳转到登录页面...</p>
|
||||
<div class="login-bg">
|
||||
<!-- 背景网格 -->
|
||||
<div class="bg-grid"></div>
|
||||
|
||||
<!-- 中心卡片 -->
|
||||
<div class="login-card">
|
||||
<div class="card-brand">
|
||||
<div class="brand-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="2" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.9"/>
|
||||
<rect x="14" y="2" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="2" y="14" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="14" y="14" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="brand-name">H3C ONU</div>
|
||||
<div class="brand-sub">设备管理系统</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-alert type="error" :title="error" :closable="false" style="margin-bottom: 16px; text-align: left" />
|
||||
<el-button type="primary" @click="doRedirect">重试</el-button>
|
||||
|
||||
<div v-if="!error" class="login-status">
|
||||
<div class="spin-ring"></div>
|
||||
<p class="status-text">正在跳转到登录页面…</p>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-else class="login-error">
|
||||
<div class="error-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="error-msg">{{ error }}</p>
|
||||
<button class="retry-btn" @click="doRedirect">重试连接</button>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<span class="version-tag">v0.5.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getLoginUrl } from '../api/auth'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
|
||||
const error = ref('')
|
||||
|
||||
@@ -35,21 +64,165 @@ onMounted(doRedirect)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
.login-bg {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-base);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 背景网格 */
|
||||
.bg-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,210,180,0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,210,180,0.04) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, black 30%, transparent 100%);
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 380px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 16px;
|
||||
padding: 40px 36px;
|
||||
box-shadow: 0 32px 80px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,210,180,0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
/* 品牌 */
|
||||
.card-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--border-accent);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.login-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.spin-ring {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 2px solid var(--border-default);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.status-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.login-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--danger-dim);
|
||||
border: 1px solid rgba(239,68,68,0.25);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #0a0e1a;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: #00f0cc;
|
||||
box-shadow: 0 0 16px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.card-footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.login-card {
|
||||
width: 400px;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
|
||||
.version-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
padding: 3px 10px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
p { color: #666; margin-bottom: 0; }
|
||||
</style>
|
||||
|
||||
+719
-212
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user