Files
H3ConuMS-v2/frontend/src/views/DeviceList.vue
T
v6ole b2c20ec43d feat(alerts): 添加微信告警、WebSocket实时推送和地图功能
- 新增微信告警服务(wechat_service)和告警任务(alert_tasks)
- 新增 WebSocket 实时推送端点
- 新增监控管理模块(monitor)
- 增强统计仪表板:趋势图、区域分布、光功率历史
- 设备管理:添加坐标信息、标签系统、复合索引优化
- 前端:重构Dashboard/Charts页面,新增业务组件
- 新增5个数据库迁移(坐标、复合索引、标签、光功率历史、显示名)
- 更新部署配置和脚本
- 新增测试框架基础结构
2026-06-11 15:16:29 +08:00

1910 lines
57 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 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>