初始化

This commit is contained in:
2026-04-02 23:40:04 +08:00
commit 381ea7085d
107 changed files with 11858 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup>
</script>
+7
View File
@@ -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')
+3
View File
@@ -0,0 +1,3 @@
import request from '../utils/request'
export const triggerCheck = () => request.post('/check/status')
+17
View File
@@ -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`)
+13
View File
@@ -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'
})
}
+7
View File
@@ -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')
+57
View File
@@ -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>
+12
View File
@@ -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')
+67
View File
@@ -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
+21
View File
@@ -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 }
})
+16
View File
@@ -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
+73
View File
@@ -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>
+74
View File
@@ -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>
+313
View File
@@ -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>
+454
View File
@@ -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>
+120
View File
@@ -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>
+55
View File
@@ -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>
+644
View File
@@ -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>