Files
H3ConuMS-v2/frontend/src/views/DeviceList.vue
T
v6ole dfa8fa62a8 ```
feat(api): 添加OLT端口管理和设备发现功能

- 在check.py中更新discover_olt方法,使用scan_and_discover替代check_olt_devices
- 在olt.py中添加get_olt_ports和toggle_olt_port接口,支持获取和控制OLT端口状态
- 添加TogglePortRequest模型用于端口操作请求验证
- 更新SSHService类,新增get_olt_ports和toggle_olt_port方法
- 优化CheckService中的MAC地址索引逻辑,支持模型信息更新
```
2026-04-04 22:13:22 +08:00

756 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="device-list">
<!-- 页头 -->
<div class="page-header">
<div class="page-title-group">
<h1 class="page-title">设备列表</h1>
<span class="page-subtitle">ONU 终端设备管理</span>
</div>
<div class="header-actions">
<el-button type="danger" plain size="small" @click="clearStatus" :loading="clearing">
清空状态
</el-button>
<el-button type="warning" size="small" @click="refreshAllStatus" :loading="refreshing">
{{ refreshing ? '更新中…' : '全部更新' }}
</el-button>
</div>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<div class="filter-group">
<label class="filter-label">区域</label>
<el-select
v-model="filters.region"
placeholder="全部区域"
clearable
@change="search"
size="small"
style="width: 160px"
>
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
</el-select>
</div>
<div class="filter-group">
<label class="filter-label">状态</label>
<el-select
v-model="filters.status"
placeholder="全部"
clearable
@change="search"
size="small"
style="width: 110px"
>
<el-option label="在线" value="online" />
<el-option label="离线" value="offline" />
<el-option label="未知" value="unknown" />
</el-select>
</div>
<div class="filter-group">
<label class="filter-label">搜索</label>
<el-input
v-model="filters.keyword"
placeholder="MAC / 区域 / 学校 / 楼宇 / 房间"
clearable
size="small"
@clear="search"
@keyup.enter="search"
style="width: 280px"
>
<template #prefix>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
</template>
</el-input>
</div>
<el-button type="primary" size="small" @click="search">查询</el-button>
</div>
<!-- 数据面板 -->
<div class="data-panel">
<!-- 统计行 -->
<div class="data-stats">
<span class="data-total"> <strong>{{ total }}</strong> 条记录</span>
<div class="status-legend">
<span class="legend-item">
<span class="led online"></span>在线
</span>
<span class="legend-item">
<span class="led offline"></span>离线
</span>
<span class="legend-item">
<span class="led unknown"></span>未知
</span>
</div>
</div>
<!-- 表格 -->
<el-table
:data="devices"
border
stripe
size="small"
style="width: 100%"
>
<el-table-column type="index" label="#" width="52" align="center">
<template #default="{ $index }">
<span class="row-index">{{ (page - 1) * pageSize + $index + 1 }}</span>
</template>
</el-table-column>
<el-table-column prop="region" label="区域" width="90" />
<el-table-column prop="school_name" label="学校名称" width="170" show-overflow-tooltip />
<el-table-column prop="building" label="楼宇" width="120" show-overflow-tooltip />
<el-table-column prop="place_type" label="场所类型" width="110" show-overflow-tooltip />
<el-table-column prop="room_number" label="房间号" width="90" />
<el-table-column prop="mac_address" label="MAC 地址" width="160">
<template #default="{ row }">
<span class="mac-link" @click="openDetail(row)">
{{ formatMac(row.mac_address) }}
</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="76" align="center">
<template #default="{ row }">
<span class="status-badge" :class="row.status">
<span class="status-dot-sm"></span>
{{ row.status === 'online' ? '在线' : row.status === 'offline' ? '离线' : '未知' }}
</span>
</template>
</el-table-column>
<el-table-column prop="distance_m" label="距离" width="80" align="right">
<template #default="{ row }">
<span class="mono-val">{{ row.distance_m ? row.distance_m + 'm' : '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="notes" label="备注" min-width="100" show-overflow-tooltip>
<template #default="{ row }">
<span style="color: var(--text-muted); font-size: 12px">{{ row.notes || '—' }}</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
@current-change="loadDevices"
@size-change="handleSizeChange"
layout="total, sizes, prev, pager, next"
/>
</div>
</div>
<!-- 检查结果对话框 -->
<el-dialog v-model="checkResultVisible" title="全部更新结果" width="680px" destroy-on-close>
<div v-if="checkInProgress" class="dialog-loading">
<div class="loading-spinner"></div>
<span>正在扫描所有 OLT请稍候</span>
</div>
<div v-else-if="checkResult">
<el-alert
:type="checkResult.success ? 'success' : 'error'"
:title="checkResult.success ? '更新完成' : '更新失败'"
:description="checkResult.error || ''"
:closable="false"
style="margin-bottom: 16px"
/>
<template v-if="checkResult.success">
<div class="result-stats">
<div class="result-stat">
<span class="rs-val online">{{ checkResult.total_online || 0 }}</span>
<span class="rs-label">在线设备</span>
</div>
<div class="result-stat">
<span class="rs-val offline">{{ checkResult.total_offline || 0 }}</span>
<span class="rs-label">离线设备</span>
</div>
<div class="result-stat">
<span class="rs-val warn">{{ (checkResult.errors || []).length }}</span>
<span class="rs-label">失败 OLT</span>
</div>
</div>
<el-table :data="checkResult.results || []" border size="small" max-height="280">
<el-table-column prop="olt_location" label="OLT 名称" min-width="150" show-overflow-tooltip />
<el-table-column label="在线" width="70" align="center">
<template #default="{ row }">
<span class="mono-val" style="color: var(--success)">{{ row.online || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="离线" width="70" align="center">
<template #default="{ row }">
<span class="mono-val" style="color: var(--danger)">{{ row.offline || 0 }}</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>
</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">
<span class="mono-val accent">{{ formatMac(selectedDevice.mac_address) }}</span>
</el-descriptions-item>
<el-descriptions-item label="区域">{{ selectedDevice.region }}</el-descriptions-item>
<el-descriptions-item label="学校">{{ selectedDevice.school_name }}</el-descriptions-item>
<el-descriptions-item label="楼宇">{{ selectedDevice.building || '—' }}</el-descriptions-item>
<el-descriptions-item label="房间号">{{ selectedDevice.room_number || '—' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<span class="status-badge" :class="selectedDevice.status">
<span class="status-dot-sm"></span>
{{ selectedDevice.status === 'online' ? '在线' : selectedDevice.status === 'offline' ? '离线' : '未知' }}
</span>
</el-descriptions-item>
<el-descriptions-item label="距离">
<span class="mono-val">{{ selectedDevice.distance_m ? selectedDevice.distance_m + ' 米' : '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="端口" :span="2">
<span class="mono-val">{{ formatPort(selectedDevice) }}</span>
</el-descriptions-item>
<el-descriptions-item label="型号" :span="2">{{ selectedDevice.model || '—' }}</el-descriptions-item>
<el-descriptions-item label="所属 OLT" :span="2">{{ selectedDevice.olt_location || '—' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ selectedDevice.notes || '—' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button type="info" :loading="deviceRefreshing" @click="doRefreshDevice" :disabled="!selectedDevice.olt_id">更新</el-button>
<el-button type="primary" @click="openEdit">编辑</el-button>
<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="460px" destroy-on-close>
<el-form :model="editForm" label-width="70px">
<el-form-item label="区域"><el-input v-model="editForm.region" /></el-form-item>
<el-form-item label="学校"><el-input v-model="editForm.school_name" /></el-form-item>
<el-form-item label="楼宇"><el-input v-model="editForm.building" /></el-form-item>
<el-form-item label="房间号"><el-input v-model="editForm.room_number" /></el-form-item>
<el-form-item label="备注"><el-input v-model="editForm.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
<!-- 业务下发对话框 -->
<el-dialog v-model="provisionVisible" title="业务下发" width="540px" 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 地址">
<span class="mono-val accent">{{ formatMac(selectedDevice.mac_address) }}</span>
</el-descriptions-item>
<el-descriptions-item label="端口">
<span class="mono-val">{{ formatPort(selectedDevice) }}</span>
</el-descriptions-item>
<el-descriptions-item label="学校">{{ selectedDevice.school_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<span class="status-badge" :class="selectedDevice.status">
<span class="status-dot-sm"></span>
{{ selectedDevice.status === 'online' ? '在线' : selectedDevice.status === 'offline' ? '离线' : '未知' }}
</span>
</el-descriptions-item>
</el-descriptions>
<el-divider>将执行的配置</el-divider>
<div class="cli-block">
<pre>system-view
interface {{ formatPort(selectedDevice) }}
uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010
port link-type trunk
undo port trunk permit vlan 1
port trunk permit vlan 2000 3000 to 3010 4094
save force</pre>
</div>
<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
}
}
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
if (data.model) selectedDevice.value.model = data.model
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: 24px;
}
/* 页头 */
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.02em;
}
.page-subtitle {
font-size: 12px;
color: var(--text-muted);
}
.header-actions {
display: flex;
gap: 8px;
}
/* 筛选栏 */
.filter-bar {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 18px;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-label {
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.04em;
white-space: nowrap;
}
/* 数据面板 */
.data-panel {
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
overflow: hidden;
}
.data-stats {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.data-total {
font-size: 12px;
color: var(--text-muted);
}
.data-total strong {
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.status-legend {
display: flex;
gap: 16px;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-muted);
}
.led {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.led.online { background: var(--success); box-shadow: 0 0 5px var(--success); }
.led.offline { background: var(--danger); }
.led.unknown { background: var(--text-muted); }
/* 表格中的元素 */
.row-index {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
}
.mac-link {
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent);
cursor: pointer;
letter-spacing: 0.02em;
transition: color 0.15s;
}
.mac-link:hover {
color: #00f0cc;
text-decoration: underline;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
padding: 2px 7px;
border-radius: 10px;
white-space: nowrap;
}
.status-badge.online {
background: var(--success-dim);
color: var(--success);
border: 1px solid rgba(34,197,94,0.25);
}
.status-badge.offline {
background: var(--danger-dim);
color: var(--danger);
border: 1px solid rgba(239,68,68,0.25);
}
.status-badge.unknown {
background: rgba(255,255,255,0.04);
color: var(--text-muted);
border: 1px solid var(--border-subtle);
}
.status-dot-sm {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.online .status-dot-sm { box-shadow: 0 0 4px currentColor; }
.mono-val {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
}
.mono-val.accent { color: var(--accent); font-size: 13px; }
/* 分页 */
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding: 14px 16px;
border-top: 1px solid var(--border-subtle);
}
/* 对话框内元素 */
.dialog-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 48px 0;
color: var(--text-muted);
font-size: 13px;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 2px solid var(--border-default);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.result-stats {
display: flex;
gap: 32px;
padding: 16px 0;
margin-bottom: 16px;
border-bottom: 1px solid var(--border-subtle);
}
.result-stat {
display: flex;
flex-direction: column;
gap: 4px;
}
.rs-val {
font-family: var(--font-mono);
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.rs-val.online { color: var(--success); }
.rs-val.offline { color: var(--danger); }
.rs-val.warn { color: var(--warning); }
.rs-label {
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
/* CLI 预览块 */
.cli-block {
background: #0a0e1a;
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
padding: 14px 16px;
overflow-x: auto;
}
.cli-block pre {
margin: 0;
font-family: var(--font-mono);
font-size: 12px;
color: #a3e6d8;
line-height: 1.7;
white-space: pre;
}
</style>