初始化
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const getLoginUrl = () => request.get('/auth/login')
|
||||
|
||||
export const handleCallback = (code, state) => request.post('/auth/callback', { code, state })
|
||||
|
||||
export const getProfile = () => request.get('/auth/profile')
|
||||
@@ -0,0 +1,3 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const triggerCheck = () => request.post('/check/status')
|
||||
@@ -0,0 +1,17 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const getDevices = (params) => request.get('/devices', { params })
|
||||
|
||||
export const getDevice = (id) => request.get(`/devices/${id}`)
|
||||
|
||||
export const getRegions = () => request.get('/devices/regions')
|
||||
|
||||
export const updateDevice = (id, data) => request.put(`/devices/${id}`, data)
|
||||
|
||||
export const provisionService = (data) => request.post('/provision/service', data)
|
||||
|
||||
export const clearAllStatus = () => request.delete('/devices/status/all')
|
||||
|
||||
export const refreshAllStatus = () => request.post('/olt/quick-scan')
|
||||
|
||||
export const refreshDevice = (id) => request.post(`/devices/${id}/refresh`)
|
||||
@@ -0,0 +1,13 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const uploadExcel = (formData) => {
|
||||
return request.post('/import/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
export const downloadTemplate = () => {
|
||||
return request.get('/import/template', {
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const getSummary = () => request.get('/stats/summary')
|
||||
|
||||
export const getTrend = (days = 7) => request.get('/stats/trend', { params: { days } })
|
||||
|
||||
export const getByRegion = () => request.get('/stats/by-region')
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<el-container>
|
||||
<el-header>
|
||||
<div class="header-content">
|
||||
<h3>H3C ONU 设备管理系统</h3>
|
||||
<el-button @click="handleLogout">退出登录</el-button>
|
||||
</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>
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-header {
|
||||
background: #545c64;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Layout from '../components/Layout.vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/Login.vue')
|
||||
},
|
||||
{
|
||||
path: '/callback',
|
||||
name: 'Callback',
|
||||
component: () => import('../views/Callback.vue')
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/dashboard',
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue')
|
||||
},
|
||||
{
|
||||
path: '/devices',
|
||||
name: 'DeviceList',
|
||||
component: () => import('../views/DeviceList.vue')
|
||||
},
|
||||
{
|
||||
path: '/import',
|
||||
name: 'ImportData',
|
||||
component: () => import('../views/ImportData.vue')
|
||||
},
|
||||
{
|
||||
path: '/charts',
|
||||
name: 'Charts',
|
||||
component: () => import('../views/Charts.vue')
|
||||
},
|
||||
{
|
||||
path: '/olt',
|
||||
name: 'OltManage',
|
||||
component: () => import('../views/OltManage.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
if (to.meta.requiresAuth && !authStore.token) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && authStore.token) {
|
||||
next('/dashboard')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as authApi from '../api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const user = ref(null)
|
||||
|
||||
const setToken = (newToken) => {
|
||||
token.value = newToken
|
||||
localStorage.setItem('token', newToken)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
return { token, user, setToken, logout }
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
request.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="callback-container">
|
||||
<el-card>
|
||||
<div v-if="loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<p>正在登录...</p>
|
||||
</div>
|
||||
<div v-else-if="error">
|
||||
<p>登录失败: {{ error }}</p>
|
||||
<el-button @click="$router.push('/login')">返回登录</el-button>
|
||||
<el-button type="info" @click="showDetails = !showDetails">详细信息</el-button>
|
||||
<pre v-if="showDetails" class="error-details">{{ errorDetails }}</pre>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { handleCallback } from '../api/auth'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const errorDetails = ref('')
|
||||
const showDetails = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const code = new URLSearchParams(window.location.search).get('code')
|
||||
const state = new URLSearchParams(window.location.search).get('state')
|
||||
|
||||
if (!code || !state) {
|
||||
error.value = '缺少认证参数'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await handleCallback(code, state)
|
||||
authStore.setToken(data.access_token)
|
||||
router.push('/dashboard')
|
||||
} catch (err) {
|
||||
error.value = err.message || '登录失败'
|
||||
errorDetails.value = err.response?.data?.detail || JSON.stringify(err, null, 2)
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.callback-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.error-details {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="charts-container">
|
||||
<el-card class="chart-card">
|
||||
<h3>设备状态趋势</h3>
|
||||
<div ref="trendChart" style="height: 300px"></div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="chart-card">
|
||||
<h3>区域设备分布</h3>
|
||||
<div ref="regionChart" style="height: 300px"></div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { getTrend, getByRegion } from '../api/stats'
|
||||
|
||||
const trendChart = ref(null)
|
||||
const regionChart = ref(null)
|
||||
|
||||
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' },
|
||||
series: [
|
||||
{ name: '在线', type: 'line', data: data.map(d => d.online), smooth: true },
|
||||
{ name: '离线', type: 'line', data: data.map(d => d.offline), smooth: true }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const initRegionChart = async () => {
|
||||
const { data } = await getByRegion()
|
||||
const chart = echarts.init(regionChart.value)
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { orient: 'vertical', left: 'left' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: data.map(d => ({ value: d.total, name: d.region }))
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initTrendChart()
|
||||
initRegionChart()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.charts-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div class="page-header">
|
||||
<span class="page-title">统计信息</span>
|
||||
<el-button size="small" :loading="loading" @click="loadData">刷新</el-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>
|
||||
<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>
|
||||
|
||||
<!-- 详细列表 -->
|
||||
<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>
|
||||
</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>
|
||||
</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.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>
|
||||
</template>
|
||||
<div v-else class="empty-text">暂无数据</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 乡镇学校详情 -->
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="townVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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)
|
||||
const selectedTown = ref(null)
|
||||
|
||||
const rate = (item) => {
|
||||
if (!item || !item.total) return 0
|
||||
return (item.online / item.total) * 100
|
||||
}
|
||||
|
||||
const rateColor = (r) => {
|
||||
if (r >= 90) return '#67c23a'
|
||||
if (r >= 70) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
}
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{ key: 'overall', label: '总体在线率', ...(data.value.overall || { total: 0, online: 0, offline: 0 }), rate: rate(data.value.overall) },
|
||||
{ key: 'urban', label: '城区在线率', ...(data.value.urban || { total: 0, online: 0, offline: 0 }), rate: rate(data.value.urban) },
|
||||
{ key: 'suburban', label: '城郊在线率', ...(data.value.suburban || { total: 0, online: 0, offline: 0 }), rate: rate(data.value.suburban) },
|
||||
{ key: 'rural', label: '乡镇在线率', ...(data.value.rural || { total: 0, online: 0, offline: 0 }), rate: rate(data.value.rural) },
|
||||
])
|
||||
|
||||
const openTown = (town) => {
|
||||
selectedTown.value = town
|
||||
townVisible.value = true
|
||||
}
|
||||
|
||||
const goToSchool = (schoolName) => {
|
||||
townVisible.value = false
|
||||
router.push({ path: '/devices', query: { school_name: schoolName } })
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data: d } = await request.get('/stats/dashboard')
|
||||
data.value = d
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.school-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.school-name {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.school-link {
|
||||
cursor: pointer;
|
||||
color: #409eff;
|
||||
}
|
||||
.school-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.town-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.school-rate {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.school-sub {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
text-align: center;
|
||||
color: #c0c4cc;
|
||||
font-size: 13px;
|
||||
padding: 40px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,454 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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-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>
|
||||
|
||||
<div v-else-if="checkResult">
|
||||
<el-alert
|
||||
:type="checkResult.success ? 'success' : 'error'"
|
||||
:title="checkResult.success ? '更新完成' : '更新失败'"
|
||||
:description="checkResult.error || ''"
|
||||
: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>
|
||||
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="success" size="small">{{ row.online || 0 }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="离线" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="danger" size="small">{{ row.offline || 0 }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.success ? 'success' : 'danger'" size="small">
|
||||
{{ row.success ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="checkResultVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="checkResultVisible = false; loadDevices()" v-if="!checkInProgress">
|
||||
刷新列表
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 设备详情对话框 -->
|
||||
<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>
|
||||
<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="状态">
|
||||
<el-tag :type="selectedDevice.status === 'online' ? 'success' : selectedDevice.status === 'offline' ? 'danger' : 'info'" size="small">
|
||||
{{ selectedDevice.status === 'online' ? '在线' : selectedDevice.status === 'offline' ? '离线' : '未知' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="距离">
|
||||
{{ selectedDevice.distance_m ? selectedDevice.distance_m + ' 米' : '-' }}
|
||||
</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 || '-' }}
|
||||
</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>
|
||||
<el-button type="success" @click="openProvision" :disabled="selectedDevice.status !== 'online' || !selectedDevice.port_id">
|
||||
业务下发
|
||||
</el-button>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</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-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSaving" @click="saveEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 业务下发对话框 -->
|
||||
<el-dialog v-model="provisionVisible" title="业务下发" width="560px" destroy-on-close>
|
||||
<el-alert
|
||||
v-if="provisionResult"
|
||||
:type="provisionResult.success ? 'success' : 'error'"
|
||||
:title="provisionResult.success ? '下发成功' : '下发失败'"
|
||||
:description="provisionResult.message"
|
||||
:closable="false"
|
||||
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="状态">
|
||||
<el-tag :type="selectedDevice.status === 'online' ? 'success' : selectedDevice.status === 'offline' ? 'danger' : 'info'" size="small">
|
||||
{{ selectedDevice.status === 'online' ? '在线' : selectedDevice.status === 'offline' ? '离线' : '未知' }}
|
||||
</el-tag>
|
||||
</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
|
||||
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>
|
||||
<template #footer>
|
||||
<el-button @click="provisionVisible = false; provisionResult = null">关闭</el-button>
|
||||
<el-button
|
||||
type="success"
|
||||
@click="doProvision"
|
||||
:loading="provisionLoading"
|
||||
:disabled="selectedDevice.status !== 'online' || !selectedDevice.port_id"
|
||||
>
|
||||
确认下发
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as deviceApi from '../api/device'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const devices = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
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 {
|
||||
await deviceApi.clearAllStatus()
|
||||
ElMessage.success('已清空所有设备状态')
|
||||
loadDevices()
|
||||
} catch {
|
||||
ElMessage.error('清空失败')
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新所有设备状态
|
||||
const refreshAllStatus = async () => {
|
||||
refreshing.value = true
|
||||
checkResultVisible.value = true
|
||||
checkInProgress.value = true
|
||||
checkResult.value = null
|
||||
|
||||
try {
|
||||
const { data } = await deviceApi.refreshAllStatus()
|
||||
checkResult.value = {
|
||||
success: true,
|
||||
total_online: data.total_online,
|
||||
total_offline: data.total_offline,
|
||||
results: data.results,
|
||||
errors: data.errors,
|
||||
}
|
||||
} catch (error) {
|
||||
checkResult.value = { success: false, error: error.response?.data?.detail || '更新失败' }
|
||||
} finally {
|
||||
checkInProgress.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化 MAC 地址
|
||||
const formatMac = (mac) => {
|
||||
if (!mac) return ''
|
||||
const clean = mac.toUpperCase().replace(/[:-]/g, '')
|
||||
if (clean.length !== 12) return mac.toLowerCase()
|
||||
return clean.substring(0, 4).toLowerCase() + '-' +
|
||||
clean.substring(4, 8).toLowerCase() + '-' +
|
||||
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 '-'
|
||||
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
|
||||
ElMessage.success(`更新完成:${data.status === 'online' ? '在线' : '离线'}${data.distance_m ? ',距离 ' + data.distance_m + 'm' : ''}`)
|
||||
loadDevices()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '更新失败')
|
||||
} finally {
|
||||
deviceRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const openEdit = () => {
|
||||
editForm.value = {
|
||||
region: selectedDevice.value.region || '',
|
||||
school_name: selectedDevice.value.school_name || '',
|
||||
building: selectedDevice.value.building || '',
|
||||
room_number: selectedDevice.value.room_number || '',
|
||||
notes: selectedDevice.value.notes || '',
|
||||
}
|
||||
detailVisible.value = false
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
const saveEdit = async () => {
|
||||
editSaving.value = true
|
||||
try {
|
||||
await deviceApi.updateDevice(selectedDevice.value.id, editForm.value)
|
||||
ElMessage.success('保存成功')
|
||||
editVisible.value = false
|
||||
loadDevices()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开业务下发对话框
|
||||
const openProvision = () => {
|
||||
provisionResult.value = null
|
||||
provisionVisible.value = true
|
||||
}
|
||||
|
||||
// 执行业务下发
|
||||
const doProvision = async () => {
|
||||
provisionLoading.value = true
|
||||
try {
|
||||
const { data } = await deviceApi.provisionService({ device_id: selectedDevice.value.id })
|
||||
provisionResult.value = { success: true, message: data.message }
|
||||
ElMessage.success(data.message)
|
||||
} catch (error) {
|
||||
const msg = error.response?.data?.detail || '下发失败'
|
||||
provisionResult.value = { success: false, message: msg }
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
provisionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载区域列表
|
||||
const loadRegions = async () => {
|
||||
const { data } = await deviceApi.getRegions()
|
||||
regions.value = data
|
||||
}
|
||||
|
||||
const loadDevices = async () => {
|
||||
const { data } = await deviceApi.getDevices({
|
||||
skip: (page.value - 1) * pageSize.value,
|
||||
limit: pageSize.value,
|
||||
region: filters.value.region || undefined,
|
||||
keyword: filters.value.keyword || undefined,
|
||||
school_name: filters.value.school_name || undefined,
|
||||
status: filters.value.status || undefined,
|
||||
})
|
||||
devices.value = data.items
|
||||
total.value = data.total
|
||||
}
|
||||
|
||||
const search = () => {
|
||||
page.value = 1
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
const handleSizeChange = (val) => {
|
||||
pageSize.value = val
|
||||
page.value = 1
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.school_name) {
|
||||
filters.value.keyword = route.query.school_name
|
||||
}
|
||||
loadRegions()
|
||||
loadDevices()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.device-list {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<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>
|
||||
<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>
|
||||
</el-card>
|
||||
</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 uploading = ref(false)
|
||||
const result = ref(null)
|
||||
|
||||
const handleFileChange = (uploadFile) => {
|
||||
file.value = uploadFile.raw
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file.value) {
|
||||
ElMessage.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.value)
|
||||
|
||||
try {
|
||||
const { data } = await importApi.uploadExcel(formData)
|
||||
result.value = data
|
||||
ElMessage.success('导入完成')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '导入失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadTemplate = async () => {
|
||||
try {
|
||||
const response = await importApi.downloadTemplate()
|
||||
const blob = new Blob([response.data], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'onu_import_template.xlsx'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
ElMessage.error('模板下载失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.import-page {
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<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>
|
||||
<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>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getLoginUrl } from '../api/auth'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
|
||||
const error = ref('')
|
||||
|
||||
const doRedirect = async () => {
|
||||
error.value = ''
|
||||
try {
|
||||
const { data } = await getLoginUrl()
|
||||
window.location.href = data.url
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.detail || e.message || '获取登录地址失败,请检查后端服务是否正常'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(doRedirect)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
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;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
p { color: #666; margin-bottom: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,644 @@
|
||||
<template>
|
||||
<div class="olt-manage">
|
||||
<el-button type="primary" @click="openAddDialog">添加 OLT 设备</el-button>
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="handleImportFile"
|
||||
accept=".xlsx,.xls"
|
||||
style="display: inline-block; margin-left: 10px"
|
||||
>
|
||||
<el-button type="success" :loading="importing">批量导入</el-button>
|
||||
</el-upload>
|
||||
<el-button @click="downloadTemplate">下载模板</el-button>
|
||||
<el-button type="danger" plain @click="openDuplicateMacs" style="margin-left: 10px">
|
||||
重复 MAC 地址
|
||||
<el-badge v-if="duplicateCount > 0" :value="duplicateCount" style="margin-left: 4px" />
|
||||
</el-button>
|
||||
<el-button type="warning" plain @click="openNewDevices" style="margin-left: 10px">
|
||||
新增设备
|
||||
<el-badge v-if="newDeviceCount > 0" :value="newDeviceCount" style="margin-left: 4px" />
|
||||
</el-button>
|
||||
<el-button type="danger" plain @click="runLoopbackDetection" :loading="loopDetecting" style="margin-left: 10px">
|
||||
环路检测
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="runQuickScan" :loading="quickScanning" style="margin-left: 10px">
|
||||
快速扫描
|
||||
</el-button>
|
||||
|
||||
<el-table :data="devices" style="margin-top: 20px">
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="ip_address" label="IP 地址" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="openDetail(row)">{{ row.ip_address }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户名" width="120" />
|
||||
<el-table-column prop="location" label="安装位置" width="150" />
|
||||
<el-table-column prop="slot_command" label="槽位命令" width="150" />
|
||||
<el-table-column prop="description" label="描述" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="环路状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="loopStatusMap[row.id]"
|
||||
type="danger"
|
||||
size="small"
|
||||
style="cursor: pointer"
|
||||
@click="showLoopDetail(row.id)"
|
||||
>
|
||||
环路 {{ loopStatusMap[row.id].length }} 个
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 快速扫描结果对话框 -->
|
||||
<el-dialog v-model="quickScanVisible" title="快速扫描结果" width="700px" destroy-on-close>
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 16px">
|
||||
<el-statistic title="在线设备" :value="quickScanResult.total_online || 0" />
|
||||
<el-statistic title="离线设备" :value="quickScanResult.total_offline || 0" />
|
||||
<el-statistic title="新入库设备" :value="quickScanResult.total_new || 0" />
|
||||
<el-statistic title="失败 OLT" :value="(quickScanResult.errors || []).length" />
|
||||
</div>
|
||||
<el-table :data="quickScanResult.results || []" border size="small" max-height="360">
|
||||
<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 }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="离线" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="danger" size="small">{{ row.offline }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新入库" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.new_discovered > 0" type="warning" size="small">{{ row.new_discovered }}</el-tag>
|
||||
<span v-else style="color: #c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.success ? 'success' : 'danger'" size="small">
|
||||
{{ row.success ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="error" label="错误信息" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.error || '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="quickScanVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 环路检测结果对话框 -->
|
||||
<el-dialog v-model="loopVisible" title="环路检测结果" width="860px" destroy-on-close>
|
||||
<div v-for="item in loopResults" :key="item.olt_id" style="margin-bottom: 16px">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px">
|
||||
<span style="font-weight: 600">{{ item.olt_location }}</span>
|
||||
<el-tag v-if="item.error" type="warning" size="small">连接失败</el-tag>
|
||||
<el-tag v-else-if="item.has_loop" type="danger" size="small">检测到环路 {{ item.loop_interfaces.length }} 个</el-tag>
|
||||
<el-tag v-else type="success" size="small">正常</el-tag>
|
||||
<span v-if="item.error" style="color: #e6a23c; font-size: 12px">{{ item.error }}</span>
|
||||
</div>
|
||||
<el-table v-if="item.has_loop" :data="item.loop_interfaces" border size="small">
|
||||
<el-table-column prop="interface" label="端口" width="140" />
|
||||
<el-table-column prop="mac_address" label="MAC 地址" width="160">
|
||||
<template #default="{ row }">{{ row.mac_address || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="region" label="区域" width="100">
|
||||
<template #default="{ row }">{{ row.region || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="school_name" label="学校" min-width="140">
|
||||
<template #default="{ row }">{{ row.school_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="building" label="楼宇" width="100">
|
||||
<template #default="{ row }">{{ row.building || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="room_number" label="房间号" width="90">
|
||||
<template #default="{ row }">{{ row.room_number || '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="loopVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 单台 OLT 环路详情对话框 -->
|
||||
<el-dialog v-model="loopDetailVisible" title="环路详情" width="700px" destroy-on-close>
|
||||
<el-table :data="loopDetailInterfaces" border size="small">
|
||||
<el-table-column prop="interface" label="端口" width="140" />
|
||||
<el-table-column prop="mac_address" label="MAC 地址" width="160">
|
||||
<template #default="{ row }">{{ row.mac_address || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="region" label="区域" width="100">
|
||||
<template #default="{ row }">{{ row.region || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="school_name" label="学校" min-width="140">
|
||||
<template #default="{ row }">{{ row.school_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="building" label="楼宇" width="100">
|
||||
<template #default="{ row }">{{ row.building || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="room_number" label="房间号" width="90">
|
||||
<template #default="{ row }">{{ row.room_number || '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="loopDetailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加/编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑 OLT 设备' : '添加 OLT 设备'" width="500px" destroy-on-close>
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="IP 地址">
|
||||
<el-input v-model="form.ip_address" :disabled="isEdit" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" placeholder="不修改请留空" />
|
||||
</el-form-item>
|
||||
<el-form-item label="安装位置">
|
||||
<el-input v-model="form.location" />
|
||||
</el-form-item>
|
||||
<el-form-item label="槽位命令">
|
||||
<el-input v-model="form.slot_command" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveDevice">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="detailVisible" title="OLT 设备详情" width="500px" destroy-on-close>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="IP 地址" :span="2">{{ selectedDevice.ip_address }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">{{ selectedDevice.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="安装位置">{{ selectedDevice.location || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="槽位命令">{{ selectedDevice.slot_command || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">{{ selectedDevice.description || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button type="success" :loading="scanning" @click="scanOlt">扫描发现设备</el-button>
|
||||
<el-button type="warning" :loading="discovering" @click="discoverOlt">扫描并入库</el-button>
|
||||
<el-button type="primary" @click="openEdit">编辑</el-button>
|
||||
<el-button type="danger" @click="confirmDelete">删除</el-button>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 扫描结果对话框 -->
|
||||
<el-dialog v-model="scanVisible" title="扫描结果" width="800px" destroy-on-close>
|
||||
<div style="margin-bottom: 12px">
|
||||
<el-tag>共发现 {{ scanResult.total }} 台</el-tag>
|
||||
<el-tag type="warning" style="margin-left: 8px">新设备 {{ scanResult.new }} 台</el-tag>
|
||||
<el-tag v-if="scanResult.duplicates?.length" type="danger" style="margin-left: 8px">
|
||||
重复 MAC {{ scanResult.duplicates.length }} 个
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-table :data="scanResult.devices" max-height="400">
|
||||
<el-table-column prop="mac_address" label="MAC 地址" width="160" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'online' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'online' ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="distance_m" label="距离" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ row.distance_m || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="端口" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.port_id || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="loid" label="LOID" width="80" />
|
||||
<el-table-column prop="model" label="型号" />
|
||||
<el-table-column label="是否新设备" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_new" type="warning" size="small">新发现</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button type="warning" :loading="discovering" @click="discoverOlt">确认入库新设备</el-button>
|
||||
<el-button @click="scanVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重复 MAC 地址对话框 -->
|
||||
<el-dialog v-model="dupVisible" title="重复 MAC 地址" width="800px" destroy-on-close>
|
||||
<div style="margin-bottom: 12px; color: #909399; font-size: 13px">
|
||||
以下 MAC 地址在 OLT 上出现在多个端口,可能是设备更换端口后旧记录未清除。对离线的旧端口执行"清除"可恢复其默认配置。
|
||||
</div>
|
||||
<el-table :data="dupList" max-height="450" empty-text="暂无重复 MAC 记录">
|
||||
<el-table-column prop="mac_address" label="MAC 地址" width="160" />
|
||||
<el-table-column label="出现的端口" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div v-for="p in row.ports" :key="p.port_id" style="display: inline-flex; align-items: center; margin-right: 8px; margin-bottom: 4px">
|
||||
<el-tag
|
||||
:type="p.status === 'online' ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ p.port_id }} ({{ p.status === 'online' ? '在线' : '离线' }})
|
||||
</el-tag>
|
||||
<el-button
|
||||
v-if="p.status !== 'online'"
|
||||
type="danger"
|
||||
size="small"
|
||||
text
|
||||
:loading="clearingPort === `${row.id}-${p.port_id}`"
|
||||
style="margin-left: 2px; padding: 0 4px"
|
||||
@click="clearPort(row, p.port_id)"
|
||||
>清除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后发现" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString() : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" size="small" text @click="deleteDupRecord(row.id)">忽略</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="dupVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新增设备对话框 -->
|
||||
<el-dialog v-model="newDevVisible" title="新增设备" width="900px" destroy-on-close>
|
||||
<div style="margin-bottom: 12px; color: #909399; font-size: 13px">
|
||||
以下设备是 OLT 扫描新发现的,请补全区域和学校信息后确认入库。
|
||||
</div>
|
||||
<el-table :data="newDevList" max-height="450" empty-text="暂无新增设备">
|
||||
<el-table-column prop="mac_address" label="MAC 地址" width="160" />
|
||||
<el-table-column prop="port_id" label="端口" width="100" />
|
||||
<el-table-column prop="model" label="型号" width="160" />
|
||||
<el-table-column label="区域 *" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.region" size="small" placeholder="必填" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="学校 *" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.school_name" size="small" placeholder="必填" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="楼宇" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.building" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="房间号" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.room_number" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" text :loading="savingDev === row.id" @click="saveNewDevice(row)">保存</el-button>
|
||||
<el-button type="info" size="small" text @click="dismissNewDevice(row.id)">忽略</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="newDevVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '../utils/request'
|
||||
|
||||
const devices = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const scanVisible = ref(false)
|
||||
const dupVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const selectedDevice = ref({})
|
||||
const scanning = ref(false)
|
||||
const discovering = ref(false)
|
||||
const importing = ref(false)
|
||||
const scanResult = ref({ total: 0, new: 0, devices: [], duplicates: [] })
|
||||
const dupList = ref([])
|
||||
const duplicateCount = ref(0)
|
||||
const clearingPort = ref('')
|
||||
const newDevList = ref([])
|
||||
const newDeviceCount = ref(0)
|
||||
const newDevVisible = ref(false)
|
||||
const savingDev = ref(null)
|
||||
const loopDetecting = ref(false)
|
||||
const loopVisible = ref(false)
|
||||
const loopResults = ref([])
|
||||
const loopStatusMap = ref({}) // olt_id -> loop_interfaces[]
|
||||
const loopDetailVisible = ref(false)
|
||||
const loopDetailInterfaces = ref([])
|
||||
const quickScanning = ref(false)
|
||||
const quickScanVisible = ref(false)
|
||||
const quickScanResult = ref({ total_online: 0, total_offline: 0, total_new: 0, results: [], errors: [] })
|
||||
const form = ref({ ip_address: '', username: '', password: '', slot_command: 'display onu slot', location: '', description: '' })
|
||||
|
||||
const fetchDevices = async () => {
|
||||
const { data } = await request.get('/olt/devices')
|
||||
devices.value = data
|
||||
}
|
||||
|
||||
const fetchDuplicateCount = async () => {
|
||||
try {
|
||||
const { data } = await request.get('/olt/duplicate-macs')
|
||||
duplicateCount.value = data.length
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const openAddDialog = () => {
|
||||
isEdit.value = false
|
||||
form.value = { ip_address: '', username: '', password: '', slot_command: 'display onu slot', location: '', description: '' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const openDetail = (row) => {
|
||||
selectedDevice.value = { ...row }
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const openEdit = () => {
|
||||
detailVisible.value = false
|
||||
isEdit.value = true
|
||||
form.value = {
|
||||
ip_address: selectedDevice.value.ip_address,
|
||||
username: selectedDevice.value.username,
|
||||
password: '',
|
||||
slot_command: selectedDevice.value.slot_command || 'display onu slot',
|
||||
location: selectedDevice.value.location || '',
|
||||
description: selectedDevice.value.description || ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const saveDevice = async () => {
|
||||
const payload = { ...form.value }
|
||||
if (!payload.password) {
|
||||
delete payload.password
|
||||
}
|
||||
if (isEdit.value) {
|
||||
await request.put(`/olt/devices/${form.value.ip_address}`, payload)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await request.post('/olt/devices', payload)
|
||||
ElMessage.success('保存成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchDevices()
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除此 OLT 设备吗?', '确认删除', { type: 'warning' })
|
||||
await request.delete(`/olt/devices/${selectedDevice.value.ip_address}`)
|
||||
ElMessage.success('删除成功')
|
||||
detailVisible.value = false
|
||||
fetchDevices()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.response?.data?.detail || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scanOlt = async () => {
|
||||
scanning.value = true
|
||||
try {
|
||||
const { data } = await request.post(`/check/scan/${selectedDevice.value.id}`)
|
||||
scanResult.value = data
|
||||
detailVisible.value = false
|
||||
scanVisible.value = true
|
||||
fetchDuplicateCount()
|
||||
fetchNewDeviceCount()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '扫描失败')
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const discoverOlt = async () => {
|
||||
discovering.value = true
|
||||
try {
|
||||
const { data } = await request.post(`/check/discover/${selectedDevice.value.id}`)
|
||||
const dupMsg = data.duplicate_macs > 0 ? `,重复 MAC ${data.duplicate_macs} 个` : ''
|
||||
ElMessage.success(`扫描完成:在线 ${data.online},离线 ${data.offline},新入库 ${data.new_discovered} 台${dupMsg}`)
|
||||
scanVisible.value = false
|
||||
detailVisible.value = false
|
||||
fetchDuplicateCount()
|
||||
fetchNewDeviceCount()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '入库失败')
|
||||
} finally {
|
||||
discovering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openDuplicateMacs = async () => {
|
||||
try {
|
||||
const { data } = await request.get('/olt/duplicate-macs')
|
||||
dupList.value = data
|
||||
duplicateCount.value = data.length
|
||||
dupVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error('获取重复 MAC 失败')
|
||||
}
|
||||
}
|
||||
|
||||
const clearPort = async (row, portId) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要清除端口 Onu${portId} 的配置吗?此操作将通过 SSH 执行 default 命令,不可撤销。`,
|
||||
'确认清除',
|
||||
{ type: 'warning', confirmButtonText: '确认清除', confirmButtonClass: 'el-button--danger' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
clearingPort.value = `${row.id}-${portId}`
|
||||
try {
|
||||
const { data } = await request.post(`/olt/duplicate-macs/${row.id}/clear-port`, { port_id: portId })
|
||||
ElMessage.success(data.message)
|
||||
if (data.remaining_ports.length === 0) {
|
||||
dupList.value = dupList.value.filter(r => r.id !== row.id)
|
||||
} else {
|
||||
row.ports = data.remaining_ports
|
||||
}
|
||||
duplicateCount.value = dupList.value.length
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '清除失败')
|
||||
} finally {
|
||||
clearingPort.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const deleteDupRecord = async (id) => {
|
||||
try {
|
||||
await request.delete(`/olt/duplicate-macs/${id}`)
|
||||
dupList.value = dupList.value.filter(r => r.id !== id)
|
||||
duplicateCount.value = dupList.value.length
|
||||
ElMessage.success('已清除')
|
||||
} catch (error) {
|
||||
ElMessage.error('清除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchNewDeviceCount = async () => {
|
||||
try {
|
||||
const { data } = await request.get('/olt/new-devices')
|
||||
newDeviceCount.value = data.length
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const openNewDevices = async () => {
|
||||
try {
|
||||
const { data } = await request.get('/olt/new-devices')
|
||||
newDevList.value = data.map(d => ({ ...d, region: d.region || '', school_name: d.school_name || '', building: d.building || '', room_number: d.room_number || '' }))
|
||||
newDeviceCount.value = data.length
|
||||
newDevVisible.value = true
|
||||
} catch {
|
||||
ElMessage.error('获取新增设备失败')
|
||||
}
|
||||
}
|
||||
|
||||
const saveNewDevice = async (row) => {
|
||||
if (!row.region || !row.school_name) {
|
||||
ElMessage.warning('区域和学校为必填项')
|
||||
return
|
||||
}
|
||||
savingDev.value = row.id
|
||||
try {
|
||||
await request.put(`/olt/new-devices/${row.id}`, {
|
||||
region: row.region,
|
||||
school_name: row.school_name,
|
||||
building: row.building,
|
||||
room_number: row.room_number,
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
newDevList.value = newDevList.value.filter(d => d.id !== row.id)
|
||||
newDeviceCount.value = newDevList.value.length
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
savingDev.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const dismissNewDevice = async (id) => {
|
||||
try {
|
||||
await request.delete(`/olt/new-devices/${id}`)
|
||||
newDevList.value = newDevList.value.filter(d => d.id !== id)
|
||||
newDeviceCount.value = newDevList.value.length
|
||||
ElMessage.success('已忽略')
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const runQuickScan = async () => {
|
||||
quickScanning.value = true
|
||||
try {
|
||||
const { data } = await request.post('/olt/quick-scan')
|
||||
quickScanResult.value = data
|
||||
quickScanVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '快速扫描失败')
|
||||
} finally {
|
||||
quickScanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const runLoopbackDetection = async () => {
|
||||
loopDetecting.value = true
|
||||
try {
|
||||
const { data } = await request.post('/olt/loopback-detection')
|
||||
loopResults.value = data
|
||||
// 更新列表中的环路标志
|
||||
const map = {}
|
||||
for (const item of data) {
|
||||
if (item.has_loop && item.loop_interfaces.length > 0) {
|
||||
map[item.olt_id] = item.loop_interfaces
|
||||
}
|
||||
}
|
||||
loopStatusMap.value = map
|
||||
loopVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '环路检测失败')
|
||||
} finally {
|
||||
loopDetecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showLoopDetail = (oltId) => {
|
||||
loopDetailInterfaces.value = loopStatusMap.value[oltId] || []
|
||||
loopDetailVisible.value = true
|
||||
}
|
||||
|
||||
const handleImportFile = async (uploadFile) => {
|
||||
importing.value = true
|
||||
const formData = new FormData()
|
||||
formData.append('file', uploadFile.raw)
|
||||
try {
|
||||
const { data } = await request.post('/olt/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
const failMsg = data.failed?.length ? `,${data.failed.length} 条失败` : ''
|
||||
ElMessage.success(`成功导入 ${data.success} 条${failMsg}`)
|
||||
fetchDevices()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '导入失败')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportSuccess = () => {
|
||||
ElMessage.success('导入成功')
|
||||
fetchDevices()
|
||||
}
|
||||
|
||||
const downloadTemplate = () => {
|
||||
window.open('/api/olt/template', '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDevices()
|
||||
fetchDuplicateCount()
|
||||
fetchNewDeviceCount()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.olt-manage {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user