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:
2026-04-04 22:13:22 +08:00
parent 381ea7085d
commit dfa8fa62a8
21 changed files with 4121 additions and 733 deletions
+433 -29
View File
@@ -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>
+1
View File
@@ -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'
+27
View File
@@ -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 }
})
+732
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+326 -57
View File
@@ -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
View File
@@ -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>
File diff suppressed because it is too large Load Diff