初始化

This commit is contained in:
2026-04-02 23:40:04 +08:00
commit 381ea7085d
107 changed files with 11858 additions and 0 deletions
+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>