b2c20ec43d
- 新增微信告警服务(wechat_service)和告警任务(alert_tasks) - 新增 WebSocket 实时推送端点 - 新增监控管理模块(monitor) - 增强统计仪表板:趋势图、区域分布、光功率历史 - 设备管理:添加坐标信息、标签系统、复合索引优化 - 前端:重构Dashboard/Charts页面,新增业务组件 - 新增5个数据库迁移(坐标、复合索引、标签、光功率历史、显示名) - 更新部署配置和脚本 - 新增测试框架基础结构
1910 lines
57 KiB
Vue
1910 lines
57 KiB
Vue
<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 v-if="!isMobile && can('device.delete')" type="danger" plain size="small" @click="clearStatus" :loading="clearing">
|
||
清空状态
|
||
</el-button>
|
||
<el-button v-if="can('device.check')" type="warning" size="small" @click="refreshAllStatus" :loading="refreshing" :disabled="refreshing || refreshCooldown > 0">
|
||
{{ refreshing ? '更新中…' : refreshCooldown > 0 ? `冷却 ${refreshCooldown}s` : '全部更新' }}
|
||
</el-button>
|
||
<el-button v-if="can('device.import')" type="success" size="small" @click="importDialogVisible = true">
|
||
数据导入
|
||
</el-button>
|
||
<a href="/api/devices/export/csv" class="csv-export-btn" title="导出CSV">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||
<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||
</svg>
|
||
导出CSV
|
||
</a>
|
||
</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-select v-model="filters.tag" placeholder="全部" clearable @change="search" size="small" style="width:130px">
|
||
<el-option v-for="t in availableTags" :key="t" :label="t" :value="t" />
|
||
</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
|
||
v-if="!isMobile"
|
||
: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">{{ formatDistance(row.distance_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 v-if="isMobile" class="device-cards">
|
||
<div
|
||
v-for="device in devices"
|
||
:key="device.id"
|
||
class="device-card"
|
||
:class="{ expanded: expandedId === device.id }"
|
||
@click="toggleCard(device)"
|
||
>
|
||
<!-- 卡片头部 -->
|
||
<div class="card-head">
|
||
<div class="card-head-left">
|
||
<span class="card-mac">{{ formatMac(device.mac_address) }}</span>
|
||
<span class="card-school">{{ device.school_name }}</span>
|
||
</div>
|
||
<div class="card-head-right">
|
||
<span class="status-badge" :class="device.status">
|
||
<span class="status-dot-sm"></span>
|
||
{{ device.status === 'online' ? '在线' : device.status === 'offline' ? '离线' : '未知' }}
|
||
</span>
|
||
<svg class="card-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="6 9 12 15 18 9"/>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 基础信息 -->
|
||
<div class="card-body">
|
||
<div class="card-row">
|
||
<span class="card-label">楼宇</span>
|
||
<span class="card-val">{{ device.building || '—' }}</span>
|
||
</div>
|
||
<div class="card-row">
|
||
<span class="card-label">场所</span>
|
||
<span class="card-val">{{ device.place_type || '—' }}</span>
|
||
</div>
|
||
<div class="card-row">
|
||
<span class="card-label">房间号</span>
|
||
<span class="card-val">{{ device.room_number || '—' }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 展开详情 -->
|
||
<div v-if="expandedId === device.id" class="card-expanded">
|
||
<div class="card-actions">
|
||
<button v-if="can('device.check')" class="card-action-btn" @click.stop="doRefreshDeviceCard(device)" :disabled="!device.olt_id">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||
</svg>
|
||
更新
|
||
</button>
|
||
<button v-if="can('device.view')" class="card-action-btn" @click.stop="openOpticalPowerFromCard(device)">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
|
||
</svg>
|
||
光功率
|
||
</button>
|
||
<button v-if="can('device.view')" class="card-action-btn" @click.stop="openONUEventsFromCard(device)" :disabled="!device.port_id">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||
</svg>
|
||
上下线记录
|
||
</button>
|
||
<button v-if="can('device.check')" class="card-action-btn card-action-btn--danger" @click.stop="confirmRebootFromCard(device)">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||
</svg>
|
||
重启
|
||
</button>
|
||
<button v-if="can('device.edit')" class="card-action-btn" @click.stop="openEditFromCard(device)">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||
</svg>
|
||
编辑
|
||
</button>
|
||
<button v-if="can('device.edit')" class="card-action-btn card-action-btn--warn" @click.stop="openReplaceFromCard(device)">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
|
||
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
|
||
</svg>
|
||
更换
|
||
</button>
|
||
<button
|
||
v-if="can('device.edit')"
|
||
class="card-action-btn card-action-btn--primary"
|
||
@click.stop="openProvisionFromCard(device)"
|
||
:disabled="device.status !== 'online' || (!device.port_id && !(device.slot_number && device.port_number))"
|
||
>
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||
</svg>
|
||
业务下发
|
||
</button>
|
||
</div>
|
||
<div class="card-detail-rows">
|
||
<div class="card-row">
|
||
<span class="card-label">距离</span>
|
||
<span class="card-val mono">{{ formatDistance(device.distance_m) }}</span>
|
||
</div>
|
||
<div class="card-row">
|
||
<span class="card-label">端口</span>
|
||
<span class="card-val mono">{{ formatPort(device) }}</span>
|
||
</div>
|
||
<div class="card-row">
|
||
<span class="card-label">型号</span>
|
||
<span class="card-val">{{ device.model || '—' }}</span>
|
||
</div>
|
||
<div class="card-row">
|
||
<span class="card-label">所属 OLT</span>
|
||
<span class="card-val">{{ device.olt_location || '—' }}</span>
|
||
</div>
|
||
<div v-if="device.notes" class="card-row card-row--notes">
|
||
<span class="card-label">备注</span>
|
||
<span class="card-val">{{ device.notes }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="devices.length === 0" class="cards-empty">暂无设备数据</div>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<div class="pagination-wrap">
|
||
<el-pagination
|
||
v-if="!isMobile"
|
||
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 v-else class="mobile-pagination">
|
||
<button class="mobile-page-btn" :disabled="page <= 1" @click="page--; loadDevices()">上一页</button>
|
||
<span class="mobile-page-info">第 {{ page }} 页 / 共 {{ Math.ceil(total / pageSize) }} 页</span>
|
||
<button class="mobile-page-btn" :disabled="page >= Math.ceil(total / pageSize)" @click="page++; loadDevices()">下一页</button>
|
||
</div>
|
||
</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">{{ formatDistance(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>
|
||
<div class="detail-footer">
|
||
<div class="detail-footer-left">
|
||
<el-button v-if="can('device.check')" type="info" size="small" :loading="deviceRefreshing" @click="doRefreshDevice" :disabled="!selectedDevice.olt_id">更新状态</el-button>
|
||
<el-button v-if="can('device.view')" size="small" :loading="opticalLoading" @click="openOpticalPower">光功率</el-button>
|
||
<el-button v-if="can('device.view')" size="small" :loading="eventsLoading" @click="openONUEvents" :disabled="!selectedDevice.port_id">上下线记录</el-button>
|
||
<el-button v-if="can('device.check')" type="danger" plain size="small" :loading="rebooting" @click="confirmReboot">重启</el-button>
|
||
</div>
|
||
<div class="detail-footer-right">
|
||
<el-button v-if="can('device.edit')" size="small" @click="openReplace">更换</el-button>
|
||
<el-button v-if="can('device.edit')" type="primary" size="small" @click="openEdit">编辑</el-button>
|
||
<el-button v-if="can('device.edit')" type="success" size="small" @click="openProvision" :disabled="selectedDevice.status !== 'online' || (!selectedDevice.port_id && !(selectedDevice.slot_number && selectedDevice.port_number))">业务下发</el-button>
|
||
<el-button size="small" @click="detailVisible = false">关闭</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 更换设备对话框 -->
|
||
<el-dialog v-model="replaceVisible" title="更换设备" width="440px" destroy-on-close>
|
||
<div style="margin-bottom: 12px; font-size: 13px; color: var(--text-muted)">
|
||
当前 MAC:<span class="mono-val accent">{{ formatMac(selectedDevice.mac_address) }}</span>
|
||
</div>
|
||
<el-form :model="replaceForm" label-width="80px">
|
||
<el-form-item label="新 MAC 地址" required>
|
||
<el-input v-model="replaceForm.new_mac" placeholder="例:AABB-CCDD-EEFF" clearable @input="formatMacInput" />
|
||
</el-form-item>
|
||
<el-form-item label="更换原因">
|
||
<el-input v-model="replaceForm.reason" type="textarea" :rows="2" placeholder="可选,填写更换原因" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<div v-if="replacements.length" style="margin-top: 16px">
|
||
<div style="font-size: 12px; font-weight: 600; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em">更换历史</div>
|
||
<div v-for="r in replacements" :key="r.id" class="replace-history-row">
|
||
<span class="mono-val small">{{ r.old_mac }}</span>
|
||
<span style="margin: 0 6px; color: var(--text-muted)">→</span>
|
||
<span class="mono-val small">{{ r.new_mac }}</span>
|
||
<span style="margin-left: auto; font-size: 11px; color: var(--text-muted)">{{ fmtReplaceTime(r.replaced_at) }}</span>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="replaceVisible = false">取消</el-button>
|
||
<el-button type="warning" :loading="replaceSaving" @click="doReplace">确认更换</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-select
|
||
v-model="editForm.region"
|
||
filterable
|
||
allow-create
|
||
placeholder="选择或输入区域"
|
||
style="width: 100%"
|
||
>
|
||
<el-option v-for="r in regionOptions" :key="r" :label="r" :value="r" />
|
||
</el-select>
|
||
</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.place_type" placeholder="如:宿舍、教室、办公室…" />
|
||
</el-form-item>
|
||
<el-form-item label="备注"><el-input v-model="editForm.notes" type="textarea" :rows="2" /></el-form-item>
|
||
<el-form-item label="标签"><el-input v-model="editForm.tags" placeholder="多个标签用逗号分隔,如:重点设备,考试用" /></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>
|
||
<!-- 上下线记录弹窗 -->
|
||
<el-dialog v-model="eventsVisible" title="上下线事件记录" width="min(92vw, 620px)" destroy-on-close>
|
||
<div v-if="eventsLoading" class="dialog-loading">
|
||
<div class="loading-spinner"></div>
|
||
<span>正在从 OLT 获取记录,请稍候…</span>
|
||
</div>
|
||
<template v-else-if="eventsData">
|
||
<div class="events-meta">
|
||
<span class="events-intf">接口:<em>{{ eventsData.interface }}</em></span>
|
||
<span class="events-olt" v-if="eventsData.olt_location">OLT:{{ eventsData.olt_location }}</span>
|
||
<el-tooltip content="时间来自 OLT 设备时钟,可能与实际时间有偏差" placement="top">
|
||
<span class="events-hint">⚠ OLT 时钟</span>
|
||
</el-tooltip>
|
||
</div>
|
||
<div v-if="!eventsData.events.length" class="events-empty">暂无事件记录</div>
|
||
<div v-else class="events-list">
|
||
<div
|
||
v-for="(ev, idx) in eventsData.events"
|
||
:key="idx"
|
||
class="event-row"
|
||
:class="ev.status.toLowerCase() === 'up' ? 'ev-up' : 'ev-down'"
|
||
>
|
||
<div class="ev-indicator"></div>
|
||
<div class="ev-body">
|
||
<div class="ev-top">
|
||
<span class="ev-event">{{ fmtONUEvent(ev.event) }}</span>
|
||
<span class="ev-status" :class="ev.status.toLowerCase() === 'up' ? 'status-up' : 'status-down'">
|
||
{{ fmtONUStatus(ev.status) }}
|
||
</span>
|
||
</div>
|
||
<div class="ev-time">{{ ev.datetime_str }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template #footer>
|
||
<el-button :loading="eventsLoading" @click="doFetchEvents">刷新</el-button>
|
||
<el-button @click="eventsVisible = false">关闭</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 光功率查询对话框 -->
|
||
<el-dialog v-model="opticalVisible" title="光功率查询" width="min(90vw, 440px)" destroy-on-close>
|
||
<div v-if="opticalLoading" class="dialog-loading">
|
||
<div class="loading-spinner"></div>
|
||
<span>正在查询光功率,请稍候…</span>
|
||
</div>
|
||
<template v-else-if="opticalResult">
|
||
<div class="optical-grid">
|
||
<div class="optical-card rx">
|
||
<span class="optical-label">接收光功率</span>
|
||
<span class="optical-val">{{ fmtPower(opticalResult.power_in) }}</span>
|
||
<span class="optical-unit">dBm</span>
|
||
</div>
|
||
<div class="optical-card tx">
|
||
<span class="optical-label">发送光功率</span>
|
||
<span class="optical-val">{{ fmtPower(opticalResult.power_out) }}</span>
|
||
<span class="optical-unit">dBm</span>
|
||
</div>
|
||
</div>
|
||
<div class="optical-meta">
|
||
<div class="optical-meta-row" v-if="opticalResult.epon_dev_name">
|
||
<span class="optical-meta-label">OLT 设备</span>
|
||
<span class="optical-meta-val mono">{{ opticalResult.epon_dev_name }}</span>
|
||
</div>
|
||
<div class="optical-meta-row" v-if="opticalResult.olt_if_name">
|
||
<span class="optical-meta-label">端口</span>
|
||
<span class="optical-meta-val mono">{{ opticalResult.olt_if_name }}</span>
|
||
</div>
|
||
<div class="optical-meta-row" v-if="opticalResult.onu_if_desc">
|
||
<span class="optical-meta-label">ONU 描述</span>
|
||
<span class="optical-meta-val">{{ opticalResult.onu_if_desc }}</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template #footer>
|
||
<el-button :loading="opticalLoading" @click="doFetchOpticalPower">刷新</el-button>
|
||
<el-button @click="opticalVisible = false">关闭</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 数据导入对话框 -->
|
||
<el-dialog v-model="importDialogVisible" title="数据导入" width="min(90vw, 560px)" destroy-on-close>
|
||
<div class="import-hint">
|
||
请先下载模板,按格式填写后上传。导入只更新设备信息,不会删除已有设备。
|
||
</div>
|
||
<div class="import-actions">
|
||
<el-button size="small" @click="downloadImportTemplate">下载导入模板</el-button>
|
||
</div>
|
||
<el-upload
|
||
ref="uploadRef"
|
||
:auto-upload="false"
|
||
:show-file-list="true"
|
||
:limit="1"
|
||
accept=".xlsx,.xls"
|
||
:on-change="onImportFileChange"
|
||
:on-remove="() => importFile = null"
|
||
drag
|
||
style="margin-top: 16px"
|
||
>
|
||
<div class="upload-area">
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--text-muted); margin-bottom: 8px">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||
</svg>
|
||
<div style="font-size: 13px; color: var(--text-secondary)">拖拽文件到此处,或 <em style="color: var(--accent)">点击选择</em></div>
|
||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px">支持 .xlsx / .xls 格式</div>
|
||
</div>
|
||
</el-upload>
|
||
<div v-if="importResult" style="margin-top: 16px">
|
||
<el-alert
|
||
:type="importResult.failed?.length ? 'warning' : 'success'"
|
||
:title="`导入完成:成功 ${importResult.success} 条${importResult.failed?.length ? ',失败 ' + importResult.failed.length + ' 条' : ''}`"
|
||
:closable="false"
|
||
/>
|
||
<div v-if="importResult.failed?.length" style="margin-top: 10px; max-height: 160px; overflow-y: auto">
|
||
<div v-for="f in importResult.failed" :key="f.row" style="font-size: 12px; color: var(--danger); padding: 2px 0">
|
||
第 {{ f.row }} 行<template v-if="f.mac">({{ f.mac }})</template>:{{ f.reason }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="importDialogVisible = false; importResult = null; importFile = null">关闭</el-button>
|
||
<el-button type="primary" :loading="importing" :disabled="!importFile" @click="doImport">开始导入</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { ElMessage } from '../utils/message'
|
||
import { ElMessageBox } from 'element-plus'
|
||
import * as deviceApi from '../api/device'
|
||
import request from '../utils/request'
|
||
import { useMobile } from '../composables/useMobile'
|
||
import { usePermission } from '../composables/usePermission'
|
||
import { fmtTime as fmtReplaceTime } from '../utils/datetime'
|
||
|
||
const { isMobile } = useMobile()
|
||
const { can } = usePermission()
|
||
|
||
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: '', tag: '' })
|
||
const availableTags = ref([])
|
||
|
||
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 refreshCooldown = ref(0)
|
||
let cooldownTimer = null
|
||
const clearing = ref(false)
|
||
|
||
const editVisible = ref(false)
|
||
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', place_type: '', notes: '', tags: '' })
|
||
const regionOptions = ref([])
|
||
const editSaving = ref(false)
|
||
|
||
const replaceVisible = ref(false)
|
||
const replaceForm = ref({ new_mac: '', reason: '' })
|
||
const replaceSaving = ref(false)
|
||
const replacements = ref([])
|
||
|
||
const checkResultVisible = ref(false)
|
||
const checkResult = ref(null)
|
||
const checkInProgress = ref(false)
|
||
|
||
// 移动端卡片展开状态
|
||
const expandedId = ref(null)
|
||
|
||
// 数据导入
|
||
const importDialogVisible = ref(false)
|
||
const importFile = ref(null)
|
||
const importing = ref(false)
|
||
const importResult = ref(null)
|
||
const uploadRef = ref(null)
|
||
|
||
// 光功率
|
||
const opticalVisible = ref(false)
|
||
const opticalLoading = ref(false)
|
||
const opticalResult = ref(null)
|
||
|
||
// 重启
|
||
const rebooting = ref(false)
|
||
|
||
// 上下线记录
|
||
const eventsVisible = ref(false)
|
||
const eventsLoading = ref(false)
|
||
const eventsData = ref(null)
|
||
|
||
const ONU_EVENT_MAP = {
|
||
'Registration': '注册上线',
|
||
'Deregistration': '注销离线',
|
||
'Power Failure': '断电',
|
||
'Power Recovery': '供电恢复',
|
||
'Link Down': '链路断开',
|
||
'Link Up': '链路恢复',
|
||
'TFTP Start': '固件升级开始',
|
||
'TFTP Success': '固件升级成功',
|
||
'TFTP Failure': '固件升级失败',
|
||
'Reset': '重置',
|
||
'Dying Gasp': '掉电告警',
|
||
}
|
||
const ONU_STATUS_MAP = { 'Up': '上线', 'Offline': '离线', 'Down': '离线' }
|
||
|
||
const fmtONUEvent = (e) => ONU_EVENT_MAP[e] || e
|
||
const fmtONUStatus = (s) => ONU_STATUS_MAP[s] || s
|
||
|
||
const fmtPower = (v) => {
|
||
if (!v || v.trim() === '--' || v.trim() === '') return '—'
|
||
return v
|
||
}
|
||
|
||
const openOpticalPower = () => {
|
||
opticalResult.value = null
|
||
opticalVisible.value = true
|
||
doFetchOpticalPower()
|
||
}
|
||
|
||
const doFetchOpticalPower = async () => {
|
||
opticalLoading.value = true
|
||
try {
|
||
const { data } = await request.get(`/devices/${selectedDevice.value.id}/optical-power`)
|
||
opticalResult.value = data
|
||
} catch (error) {
|
||
ElMessage.error(error.response?.data?.detail || '获取光功率失败')
|
||
opticalVisible.value = false
|
||
} finally {
|
||
opticalLoading.value = false
|
||
}
|
||
}
|
||
|
||
const confirmReboot = () => {
|
||
ElMessageBox.confirm(
|
||
`确认重启设备 ${formatMac(selectedDevice.value.mac_address)}?重启过程约需 30-60 秒,期间设备将短暂离线。`,
|
||
'重启确认',
|
||
{ confirmButtonText: '确认重启', cancelButtonText: '取消', type: 'warning' }
|
||
).then(doReboot).catch(() => {})
|
||
}
|
||
|
||
const doReboot = async () => {
|
||
rebooting.value = true
|
||
try {
|
||
const { data } = await request.post(`/devices/${selectedDevice.value.id}/reboot`)
|
||
if (data.success) {
|
||
ElMessage.success(data.message)
|
||
} else {
|
||
ElMessage.error(data.message)
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error(error.response?.data?.detail || '重启失败')
|
||
} finally {
|
||
rebooting.value = false
|
||
}
|
||
}
|
||
|
||
const onImportFileChange = (file) => {
|
||
importFile.value = file.raw
|
||
}
|
||
|
||
const downloadImportTemplate = () => {
|
||
window.open('/api/import/template', '_blank')
|
||
}
|
||
|
||
const doImport = async () => {
|
||
if (!importFile.value) return
|
||
importing.value = true
|
||
importResult.value = null
|
||
const formData = new FormData()
|
||
formData.append('file', importFile.value)
|
||
try {
|
||
const { data } = await request.post('/import/upload', formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' }
|
||
})
|
||
importResult.value = data
|
||
if (!data.failed?.length) {
|
||
ElMessage.success(`成功导入 ${data.success} 条记录`)
|
||
loadDevices()
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error(error.response?.data?.detail || '导入失败')
|
||
} finally {
|
||
importing.value = false
|
||
}
|
||
}
|
||
|
||
|
||
const toggleCard = (device) => {
|
||
expandedId.value = expandedId.value === device.id ? null : device.id
|
||
}
|
||
|
||
const doRefreshDeviceCard = async (device) => {
|
||
try {
|
||
const { data } = await deviceApi.refreshDevice(device.id)
|
||
device.status = data.status
|
||
device.distance_m = data.distance_m
|
||
if (data.model) device.model = data.model
|
||
ElMessage.success(`更新完成:${data.status === 'online' ? '在线' : '离线'}`)
|
||
} catch (error) {
|
||
ElMessage.error(error.response?.data?.detail || '更新失败')
|
||
}
|
||
}
|
||
|
||
const openEditFromCard = (device) => {
|
||
selectedDevice.value = { ...device }
|
||
editForm.value = {
|
||
region: device.region || '',
|
||
school_name: device.school_name || '',
|
||
building: device.building || '',
|
||
room_number: device.room_number || '',
|
||
place_type: device.place_type || '',
|
||
notes: device.notes || '',
|
||
}
|
||
loadRegionOptions()
|
||
editVisible.value = true
|
||
}
|
||
|
||
const openReplaceFromCard = async (device) => {
|
||
selectedDevice.value = { ...device }
|
||
replaceForm.value = { new_mac: '', reason: '' }
|
||
replacements.value = []
|
||
try {
|
||
const { data } = await request.get(`/devices/${device.id}/replacements`)
|
||
replacements.value = data
|
||
} catch {}
|
||
replaceVisible.value = true
|
||
}
|
||
|
||
const openOpticalPowerFromCard = (device) => {
|
||
selectedDevice.value = { ...device }
|
||
openOpticalPower()
|
||
}
|
||
|
||
const openONUEvents = () => {
|
||
eventsData.value = null
|
||
eventsVisible.value = true
|
||
doFetchEvents()
|
||
}
|
||
|
||
const doFetchEvents = async () => {
|
||
eventsLoading.value = true
|
||
try {
|
||
const { data } = await request.get(`/devices/${selectedDevice.value.id}/onu-events`)
|
||
eventsData.value = data
|
||
} catch (error) {
|
||
ElMessage.error(error.response?.data?.detail || '获取事件记录失败')
|
||
eventsVisible.value = false
|
||
} finally {
|
||
eventsLoading.value = false
|
||
}
|
||
}
|
||
|
||
const openONUEventsFromCard = (device) => {
|
||
selectedDevice.value = { ...device }
|
||
openONUEvents()
|
||
}
|
||
|
||
const confirmRebootFromCard = (device) => {
|
||
selectedDevice.value = { ...device }
|
||
confirmReboot()
|
||
}
|
||
|
||
const openProvisionFromCard = (device) => { selectedDevice.value = { ...device }
|
||
provisionResult.value = null
|
||
provisionVisible.value = true
|
||
}
|
||
|
||
const clearStatus = async () => {
|
||
clearing.value = true
|
||
try {
|
||
await deviceApi.clearAllStatus()
|
||
ElMessage.success('已清空所有设备状态')
|
||
loadDevices()
|
||
} catch {
|
||
ElMessage.error('清空失败')
|
||
} finally {
|
||
clearing.value = false
|
||
}
|
||
}
|
||
|
||
const refreshAllStatus = async () => {
|
||
if (refreshCooldown.value > 0) return
|
||
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
|
||
clearInterval(cooldownTimer)
|
||
refreshCooldown.value = 60
|
||
cooldownTimer = setInterval(() => {
|
||
refreshCooldown.value--
|
||
if (refreshCooldown.value <= 0) {
|
||
clearInterval(cooldownTimer)
|
||
refreshCooldown.value = 0
|
||
}
|
||
}, 1000)
|
||
}
|
||
}
|
||
|
||
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 formatDistance = (d) => {
|
||
if (!d && d !== 0) return '—'
|
||
if (d === 1000) return '1000 米内'
|
||
return d + 'm'
|
||
}
|
||
|
||
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
|
||
if (data.port_id !== undefined) selectedDevice.value.port_id = data.port_id
|
||
if (data.slot_number !== undefined) selectedDevice.value.slot_number = data.slot_number
|
||
if (data.port_number !== undefined) selectedDevice.value.port_number = data.port_number
|
||
if (data.olt_location !== undefined) selectedDevice.value.olt_location = data.olt_location
|
||
ElMessage.success(`更新完成:${data.status === 'online' ? '在线' : '离线'}${data.distance_m ? ',距离 ' + formatDistance(data.distance_m) : ''}`)
|
||
loadDevices()
|
||
} catch (error) {
|
||
ElMessage.error(error.response?.data?.detail || '更新失败')
|
||
} finally {
|
||
deviceRefreshing.value = false
|
||
}
|
||
}
|
||
|
||
const openReplace = async () => {
|
||
replaceForm.value = { new_mac: '', reason: '' }
|
||
replacements.value = []
|
||
try {
|
||
const { data } = await request.get(`/devices/${selectedDevice.value.id}/replacements`)
|
||
replacements.value = data
|
||
} catch {}
|
||
detailVisible.value = false
|
||
replaceVisible.value = true
|
||
}
|
||
|
||
// 输入时自动格式化为 xxxx-xxxx-xxxx(小写)
|
||
const formatMacInput = () => {
|
||
const raw = replaceForm.value.new_mac.toLowerCase().replace(/[^0-9a-f]/g, '')
|
||
if (raw.length <= 4) {
|
||
replaceForm.value.new_mac = raw
|
||
} else if (raw.length <= 8) {
|
||
replaceForm.value.new_mac = raw.slice(0, 4) + '-' + raw.slice(4)
|
||
} else {
|
||
replaceForm.value.new_mac = raw.slice(0, 4) + '-' + raw.slice(4, 8) + '-' + raw.slice(8, 12)
|
||
}
|
||
}
|
||
|
||
const doReplace = async () => {
|
||
if (!replaceForm.value.new_mac.trim()) {
|
||
ElMessage.warning('请输入新 MAC 地址')
|
||
return
|
||
}
|
||
replaceSaving.value = true
|
||
try {
|
||
await request.post(`/devices/${selectedDevice.value.id}/replace`, replaceForm.value)
|
||
ElMessage.success('更换成功')
|
||
replaceVisible.value = false
|
||
loadDevices()
|
||
} catch (error) {
|
||
ElMessage.error(error.response?.data?.detail || '更换失败')
|
||
} finally {
|
||
replaceSaving.value = false
|
||
}
|
||
}
|
||
|
||
// fmtReplaceTime 从 utils/datetime 导入,已在文件顶部
|
||
|
||
const openEdit = () => {
|
||
editForm.value = {
|
||
region: selectedDevice.value.region || '',
|
||
school_name: selectedDevice.value.school_name || '',
|
||
building: selectedDevice.value.building || '',
|
||
room_number: selectedDevice.value.room_number || '',
|
||
place_type: selectedDevice.value.place_type || '',
|
||
notes: selectedDevice.value.notes || '',
|
||
tags: selectedDevice.value.tags || '',
|
||
}
|
||
loadRegionOptions()
|
||
detailVisible.value = false
|
||
editVisible.value = true
|
||
}
|
||
|
||
const loadRegionOptions = async () => {
|
||
try {
|
||
const { data } = await deviceApi.getRegions()
|
||
regionOptions.value = data
|
||
} catch {}
|
||
}
|
||
|
||
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,
|
||
tag: filters.value.tag || 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()
|
||
}
|
||
|
||
const fetchTags = async () => {
|
||
try {
|
||
const { data } = await request.get('/devices/tags')
|
||
availableTags.value = data || []
|
||
} catch {}
|
||
}
|
||
|
||
onMounted(() => {
|
||
if (route.query.school_name) {
|
||
filters.value.keyword = route.query.school_name
|
||
}
|
||
loadRegions()
|
||
loadDevices()
|
||
fetchTags()
|
||
})
|
||
</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;
|
||
align-items: center;
|
||
}
|
||
|
||
.csv-export-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 5px 12px;
|
||
background: var(--bg-elevated);
|
||
border: 1px solid var(--border-default);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text-secondary);
|
||
font-size: 12px;
|
||
font-family: var(--font-sans);
|
||
text-decoration: none;
|
||
transition: all 0.15s;
|
||
}
|
||
.csv-export-btn:hover {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* 筛选栏 */
|
||
.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; }
|
||
|
||
.replace-history-row {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 5px 0;
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
font-size: 12px;
|
||
}
|
||
.replace-history-row:last-child { border-bottom: none; }
|
||
|
||
/* 分页 */
|
||
.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;
|
||
}
|
||
|
||
/* ── 移动端卡片 ── */
|
||
.device-cards {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
}
|
||
|
||
.device-card {
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
user-select: none;
|
||
}
|
||
|
||
.device-card:active {
|
||
background: var(--bg-hover);
|
||
}
|
||
|
||
.card-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px 8px;
|
||
gap: 12px;
|
||
}
|
||
|
||
.card-head-left {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.card-mac {
|
||
font-family: var(--font-mono);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--accent);
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.card-school {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.card-head-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.card-chevron {
|
||
color: var(--text-muted);
|
||
transition: transform 0.2s ease;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.device-card.expanded .card-chevron {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.card-body {
|
||
padding: 0 16px 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.card-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
min-height: 22px;
|
||
}
|
||
|
||
.card-label {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
min-width: 52px;
|
||
flex-shrink: 0;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.card-val {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
flex: 1;
|
||
min-width: 0;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.card-val.mono {
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.card-row--notes .card-val {
|
||
color: var(--text-muted);
|
||
font-size: 11px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.card-expanded {
|
||
border-top: 1px solid var(--border-subtle);
|
||
background: var(--bg-elevated);
|
||
}
|
||
|
||
.card-actions {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 7px;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
|
||
.card-action-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 5px;
|
||
padding: 9px 4px;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-default);
|
||
border-radius: var(--radius-md);
|
||
color: var(--text-secondary);
|
||
font-size: 11px;
|
||
font-family: var(--font-sans);
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
min-height: 44px;
|
||
width: 100%;
|
||
}
|
||
|
||
.card-action-btn:active {
|
||
background: var(--bg-hover);
|
||
}
|
||
|
||
.card-action-btn:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.card-action-btn--primary {
|
||
background: var(--accent-dim);
|
||
border-color: var(--border-accent);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.card-action-btn--primary:active {
|
||
background: rgba(0,210,180,0.25);
|
||
}
|
||
|
||
.card-action-btn--danger {
|
||
background: var(--danger-dim);
|
||
border-color: rgba(239,68,68,0.3);
|
||
color: var(--danger);
|
||
}
|
||
|
||
.card-action-btn--danger:active {
|
||
background: rgba(239,68,68,0.2);
|
||
}
|
||
|
||
.card-action-btn--warn {
|
||
background: rgba(245,158,11,0.1);
|
||
border-color: rgba(245,158,11,0.3);
|
||
color: #d97706;
|
||
}
|
||
|
||
.card-action-btn--warn:active {
|
||
background: rgba(245,158,11,0.2);
|
||
}
|
||
|
||
.card-detail-rows {
|
||
padding: 10px 16px 14px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.cards-empty {
|
||
text-align: center;
|
||
padding: 48px 0;
|
||
font-size: 13px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* 移动端筛选栏 */
|
||
@media (max-width: 767px) {
|
||
.device-list {
|
||
padding: 12px;
|
||
}
|
||
|
||
.page-header {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.filter-bar {
|
||
padding: 12px;
|
||
gap: 10px;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.filter-group {
|
||
width: 100%;
|
||
}
|
||
|
||
.filter-group .el-select,
|
||
.filter-group .el-input {
|
||
width: 100% !important;
|
||
}
|
||
|
||
.filter-bar .el-button {
|
||
width: 100%;
|
||
height: 44px;
|
||
}
|
||
|
||
.data-stats {
|
||
padding: 10px 12px;
|
||
}
|
||
|
||
.pagination-wrap {
|
||
padding: 12px;
|
||
justify-content: center;
|
||
}
|
||
|
||
.pagination-wrap .el-pagination {
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
}
|
||
}
|
||
|
||
.mobile-pagination {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
width: 100%;
|
||
gap: 12px;
|
||
}
|
||
|
||
.mobile-page-btn {
|
||
padding: 9px 20px;
|
||
background: var(--bg-elevated);
|
||
border: 1px solid var(--border-default);
|
||
border-radius: var(--radius-md);
|
||
color: var(--text-secondary);
|
||
font-size: 13px;
|
||
font-family: var(--font-sans);
|
||
cursor: pointer;
|
||
min-height: 44px;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.mobile-page-btn:active {
|
||
background: var(--bg-hover);
|
||
}
|
||
|
||
.mobile-page-btn:disabled {
|
||
opacity: 0.35;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.mobile-page-info {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
font-family: var(--font-mono);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* 设备详情弹窗 footer 两端布局 */
|
||
.detail-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
width: 100%;
|
||
}
|
||
|
||
.detail-footer-left,
|
||
.detail-footer-right {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.import-hint {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
line-height: 1.6;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.import-actions {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
/* 上下线事件记录弹窗 */
|
||
.events-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 0 0 14px;
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
margin-bottom: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.events-intf em {
|
||
font-style: normal;
|
||
font-family: var(--font-mono);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.events-olt { color: var(--text-secondary); }
|
||
|
||
.events-hint {
|
||
margin-left: auto;
|
||
font-size: 11px;
|
||
color: var(--warning, #f59e0b);
|
||
cursor: default;
|
||
}
|
||
|
||
.events-empty {
|
||
text-align: center;
|
||
padding: 32px 0;
|
||
font-size: 13px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.events-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.event-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: stretch;
|
||
padding: 10px 0;
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
|
||
.event-row:last-child { border-bottom: none; }
|
||
|
||
.ev-indicator {
|
||
width: 3px;
|
||
border-radius: 2px;
|
||
flex-shrink: 0;
|
||
align-self: stretch;
|
||
min-height: 36px;
|
||
}
|
||
|
||
.ev-up .ev-indicator { background: var(--success); }
|
||
.ev-down .ev-indicator { background: var(--danger); }
|
||
|
||
.ev-body {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
|
||
.ev-top {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
|
||
.ev-event {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.ev-status {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
padding: 2px 8px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.status-up {
|
||
background: var(--success-dim);
|
||
color: var(--success);
|
||
border: 1px solid rgba(34,197,94,0.25);
|
||
}
|
||
|
||
.status-down {
|
||
background: var(--danger-dim);
|
||
color: var(--danger);
|
||
border: 1px solid rgba(239,68,68,0.25);
|
||
}
|
||
|
||
.ev-time {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* 光功率弹窗 */
|
||
.optical-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.optical-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 20px 12px;
|
||
border-radius: var(--radius-md);
|
||
border: 1px solid;
|
||
}
|
||
|
||
.optical-card.rx {
|
||
background: var(--success-dim);
|
||
border-color: rgba(34,197,94,0.25);
|
||
}
|
||
|
||
.optical-card.tx {
|
||
background: var(--info-dim);
|
||
border-color: rgba(59,130,246,0.25);
|
||
}
|
||
|
||
.optical-label {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.optical-val {
|
||
font-family: var(--font-mono);
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.optical-card.rx .optical-val { color: var(--success); }
|
||
.optical-card.tx .optical-val { color: var(--info); }
|
||
|
||
.optical-unit {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.optical-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.optical-meta-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
font-size: 12px;
|
||
padding: 5px 0;
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
|
||
.optical-meta-row:last-child { border-bottom: none; }
|
||
|
||
.optical-meta-label {
|
||
width: 64px;
|
||
flex-shrink: 0;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.optical-meta-val {
|
||
flex: 1;
|
||
color: var(--text-secondary);
|
||
word-break: break-all;
|
||
}
|
||
|
||
.optical-meta-val.mono { font-family: var(--font-mono); }
|
||
|
||
.upload-area {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 24px 0;
|
||
}
|
||
</style>
|