Files
H3ConuMS-v2/frontend/src/views/OltManage.vue
T
v6ole f511e3e808 fix: security remediation follow-up and OLT button layout improvements
- fix Casdoor JWT verification with correct PythonProject public key
- fix iMC TLS cert validation with custom SSL adapter (skip hostname check for non-DNS CN)
- add iMC CA cert and Casdoor public key to build context
- improve OLT manage page: unify button styles, fix mobile grid spacing, replace el-upload with native input for consistent alignment
- swap NTP sync button for duplicate MAC on mobile

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 15:05:52 +08:00

1584 lines
52 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="olt-manage">
<!-- 页头 -->
<div class="page-header">
<div class="page-title-group">
<h1 class="page-title">OLT 管理</h1>
<span class="page-subtitle">光线路终端设备配置与监控</span>
</div>
<div class="header-actions">
<el-button v-if="!isMobile && can('olt.manage')" type="primary" size="small" @click="openAddDialog">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="btn-icon">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
添加 OLT
</el-button>
<el-button v-if="!isMobile && can('olt.manage')" type="success" plain size="small" :loading="importing" @click="triggerImport">
批量导入
</el-button>
<el-button v-if="!isMobile && can('olt.manage')" plain size="small" @click="downloadTemplate">下载模板</el-button>
<el-button v-if="can('olt.manage')" type="danger" plain size="small" @click="openDuplicateMacs">
重复 MAC<span v-if="duplicateCount > 0" class="btn-count danger">{{ duplicateCount }}</span>
</el-button>
<el-button v-if="can('olt.manage')" type="warning" plain size="small" @click="openNewDevices">
新增设备<span v-if="newDeviceCount > 0" class="btn-count warning">{{ newDeviceCount }}</span>
</el-button>
<el-button v-if="!isMobile && can('olt.manage')" type="info" plain size="small" @click="openNtpSync" :loading="ntpSyncing">
同步NTP
</el-button>
<el-button v-if="can('olt.loopback')" type="danger" plain size="small" @click="runLoopbackDetection" :loading="loopDetecting">
环路检测
</el-button>
<el-button v-if="can('olt.discover')" type="primary" plain size="small" @click="runQuickScan" :loading="quickScanning">
快速扫描
</el-button>
<!-- 隐藏的上传组件批量导入触发 -->
<input
v-if="!isMobile && can('olt.manage')"
ref="importInput"
type="file"
accept=".xlsx,.xls"
style="display:none"
@change="handleImportFile"
/>
</div>
</div>
<!-- OLT 列表 -->
<div class="data-panel">
<!-- 筛选栏 -->
<div class="filter-bar" v-if="!isMobile">
<el-select v-model="filterRegion" placeholder="全部区域" clearable size="small" style="width: 160px" @change="fetchDevices">
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
</el-select>
</div>
<!-- 桌面端表格 -->
<el-table v-if="!isMobile" :data="filteredDevices" size="small" style="width: 100%">
<el-table-column type="index" label="#" width="52" align="center">
<template #default="{ $index }">
<span class="row-index">{{ $index + 1 }}</span>
</template>
</el-table-column>
<el-table-column prop="ip_address" label="IP 地址" width="155">
<template #default="{ row }">
<span class="ip-link" @click="openDetail(row)">{{ row.ip_address }}</span>
</template>
</el-table-column>
<el-table-column prop="region" label="区域" width="90">
<template #default="{ row }">
<span class="region-tag">{{ row.region || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="username" label="用户名" width="110">
<template #default="{ row }">
<span class="mono-val">{{ row.username }}</span>
</template>
</el-table-column>
<el-table-column prop="location" label="安装位置" width="160" show-overflow-tooltip />
<el-table-column prop="slot_command" label="槽位命令" width="180">
<template #default="{ row }">
<span class="mono-val cmd">{{ row.slot_command }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span style="color: var(--text-muted); font-size: 12px">{{ row.description || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="环路状态" width="110" align="center">
<template #default="{ row }">
<span
v-if="loopStatusMap[row.id]"
class="loop-badge"
@click="showLoopDetail(row.id)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
环路 {{ loopStatusMap[row.id].length }}
</span>
</template>
</el-table-column>
</el-table>
<!-- 移动端 OLT 卡片 -->
<div v-if="isMobile" class="olt-cards">
<div v-for="device in devices" :key="device.id" class="olt-card">
<div class="olt-card-head">
<div class="olt-card-head-left">
<span class="olt-ip">{{ device.ip_address }}</span>
<span class="olt-location">{{ device.location || '—' }}</span>
</div>
<div class="olt-card-head-right">
<span
v-if="loopStatusMap[device.id]"
class="loop-badge"
@click="showLoopDetail(device.id)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
环路 {{ loopStatusMap[device.id].length }}
</span>
<span v-else class="olt-status-ok">
<span class="status-dot-sm online-dot"></span>正常
</span>
</div>
</div>
<div class="olt-card-actions">
<button class="olt-action-btn" @click="openDetail(device)">
<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>
快速扫描
</button>
<button v-if="can('olt.port_manage')" class="olt-action-btn" @click="openPortManagerFromCard(device)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<circle cx="7" cy="9" r="1.2" fill="currentColor"/>
</svg>
端口管理
</button>
</div>
</div>
<div v-if="devices.length === 0" class="olt-cards-empty">暂无 OLT 设备</div>
</div>
</div>
<!-- 快速扫描结果 -->
<el-dialog v-model="quickScanVisible" title="快速扫描结果" width="680px" destroy-on-close>
<div class="result-stats">
<div class="result-stat">
<span class="rs-val online">{{ quickScanResult.total_online || 0 }}</span>
<span class="rs-label">在线设备</span>
</div>
<div class="result-stat">
<span class="rs-val offline">{{ quickScanResult.total_offline || 0 }}</span>
<span class="rs-label">离线设备</span>
</div>
<div class="result-stat">
<span class="rs-val warn">{{ quickScanResult.total_new || 0 }}</span>
<span class="rs-label">新入库</span>
</div>
<div class="result-stat">
<span class="rs-val muted">{{ (quickScanResult.errors || []).length }}</span>
<span class="rs-label">失败 OLT</span>
</div>
</div>
<el-table :data="quickScanResult.results || []" border size="small" max-height="320">
<el-table-column prop="olt_location" label="OLT" min-width="140" show-overflow-tooltip />
<el-table-column label="在线" width="65" align="center">
<template #default="{ row }">
<span class="mono-val" style="color: var(--success)">{{ row.online }}</span>
</template>
</el-table-column>
<el-table-column label="离线" width="65" align="center">
<template #default="{ row }">
<span class="mono-val" style="color: var(--danger)">{{ row.offline }}</span>
</template>
</el-table-column>
<el-table-column label="新入库" width="65" align="center">
<template #default="{ row }">
<span v-if="row.new_discovered > 0" class="mono-val" style="color: var(--warning)">{{ row.new_discovered }}</span>
<span v-else class="mono-val" style="color: var(--text-muted)"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="72" align="center">
<template #default="{ row }">
<el-tag :type="row.success ? 'success' : 'danger'" size="small">{{ row.success ? '成功' : '失败' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="error" label="错误信息" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
<span style="color: var(--danger); font-size: 12px">{{ row.error || '—' }}</span>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="quickScanVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 环路检测结果 -->
<el-dialog v-model="loopVisible" title="环路检测结果" width="820px" destroy-on-close>
<div v-for="item in loopResults" :key="item.olt_id" class="loop-olt-block">
<div class="loop-olt-header">
<span class="loop-olt-name">{{ item.olt_location }}</span>
<el-tag v-if="item.error" type="warning" size="small">连接失败</el-tag>
<el-tag v-else-if="item.has_loop" type="danger" size="small">环路 {{ item.loop_interfaces.length }} </el-tag>
<el-tag v-else type="success" size="small">正常</el-tag>
<span v-if="item.error" style="color: var(--warning); font-size: 12px">{{ item.error }}</span>
</div>
<el-table v-if="item.has_loop" :data="item.loop_interfaces" border size="small" style="margin-top: 8px">
<el-table-column prop="interface" label="端口" width="130" />
<el-table-column prop="mac_address" label="MAC" width="150">
<template #default="{ row }"><span class="mono-val">{{ row.mac_address || '—' }}</span></template>
</el-table-column>
<el-table-column prop="region" label="区域" width="90">
<template #default="{ row }">{{ row.region || '—' }}</template>
</el-table-column>
<el-table-column prop="school_name" label="学校" min-width="130">
<template #default="{ row }">{{ row.school_name || '—' }}</template>
</el-table-column>
<el-table-column prop="building" label="楼宇" width="90">
<template #default="{ row }">{{ row.building || '—' }}</template>
</el-table-column>
<el-table-column prop="room_number" label="房间" width="80">
<template #default="{ row }">{{ row.room_number || '—' }}</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="loopVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 单台环路详情 -->
<el-dialog v-model="loopDetailVisible" title="环路详情" width="680px" destroy-on-close>
<el-table :data="loopDetailInterfaces" border size="small">
<el-table-column prop="interface" label="端口" width="130" />
<el-table-column prop="mac_address" label="MAC" width="150">
<template #default="{ row }"><span class="mono-val">{{ row.mac_address || '—' }}</span></template>
</el-table-column>
<el-table-column prop="region" label="区域" width="90">
<template #default="{ row }">{{ row.region || '—' }}</template>
</el-table-column>
<el-table-column prop="school_name" label="学校" min-width="130">
<template #default="{ row }">{{ row.school_name || '—' }}</template>
</el-table-column>
<el-table-column prop="building" label="楼宇" width="90">
<template #default="{ row }">{{ row.building || '—' }}</template>
</el-table-column>
<el-table-column prop="room_number" label="房间" width="80">
<template #default="{ row }">{{ row.room_number || '—' }}</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="loopDetailVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- NTP 时间同步 -->
<el-dialog v-model="ntpSyncVisible" title="NTP 时间服务器同步" width="560px" destroy-on-close>
<template v-if="ntpPhase === 'config'">
<p style="margin: 0 0 16px; color: var(--text-muted); font-size: 13px">
批量对所有 OLT 执行 NTP 时间服务器配置并强制保存旧服务器若不存在会自动忽略
</p>
<el-form label-width="110px">
<el-form-item label="旧 NTP 服务器">
<el-input v-model="ntpForm.old_server" class="mono-val" placeholder="172.16.0.254" />
</el-form-item>
<el-form-item label="新 NTP 服务器">
<el-input v-model="ntpForm.new_server" class="mono-val" placeholder="172.16.1.252" />
</el-form-item>
</el-form>
<div style="background: var(--bg-subtle); border: 1px solid var(--border-subtle); border-radius: 6px; padding: 10px 14px; font-size: 12px; color: var(--text-muted); font-family: monospace; line-height: 1.7">
<div>system-view</div>
<div>undo ntp-service unicast-server {{ ntpForm.old_server }}</div>
<div>ntp-service unicast-server {{ ntpForm.new_server }}</div>
<div>clock timezone Beijing add 08:00:00</div>
<div>quit</div>
<div>save force</div>
</div>
</template>
<template v-else>
<div class="result-stats" style="margin-bottom: 16px">
<div class="result-stat">
<span class="rs-val" style="color: var(--success)">{{ ntpSyncResult.success || 0 }}</span>
<span class="rs-label">成功</span>
</div>
<div class="result-stat">
<span class="rs-val offline">{{ ntpSyncResult.failed || 0 }}</span>
<span class="rs-label">失败</span>
</div>
<div class="result-stat">
<span class="rs-val muted">{{ ntpSyncResult.total || 0 }}</span>
<span class="rs-label">共计</span>
</div>
</div>
<el-table :data="ntpSyncResult.results || []" border size="small" max-height="320">
<el-table-column prop="olt_location" label="OLT" min-width="150" show-overflow-tooltip />
<el-table-column prop="olt_ip" label="IP" width="140">
<template #default="{ row }"><span class="mono-val">{{ row.olt_ip }}</span></template>
</el-table-column>
<el-table-column label="状态" width="72" align="center">
<template #default="{ row }">
<el-tag :type="row.success ? 'success' : 'danger'" size="small">{{ row.success ? '成功' : '失败' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="error" label="错误信息" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
<span style="color: var(--danger); font-size: 12px">{{ row.error || '—' }}</span>
</template>
</el-table-column>
</el-table>
</template>
<template #footer>
<el-button @click="ntpSyncVisible = false">关闭</el-button>
<el-button v-if="ntpPhase === 'config'" type="primary" @click="runNtpSync" :loading="ntpSyncing">开始同步</el-button>
</template>
</el-dialog>
<!-- 添加/编辑 OLT -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑 OLT 设备' : '添加 OLT 设备'" width="480px" destroy-on-close>
<el-form :model="form" label-width="90px">
<el-form-item label="IP 地址"><el-input v-model="form.ip_address" :disabled="isEdit" /></el-form-item>
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
<el-form-item label="密码"><el-input v-model="form.password" type="password" placeholder="不修改请留空" /></el-form-item>
<el-form-item label="区域">
<el-select v-model="form.region" style="width: 100%">
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
</el-select>
</el-form-item>
<el-form-item label="安装位置"><el-input v-model="form.location" /></el-form-item>
<el-form-item label="槽位命令"><el-input v-model="form.slot_command" /></el-form-item>
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveDevice">保存</el-button>
</template>
</el-dialog>
<!-- OLT 详情 -->
<el-dialog v-model="detailVisible" title="OLT 设备详情" width="480px" destroy-on-close>
<el-descriptions :column="2" border>
<el-descriptions-item label="IP 地址" :span="2">
<span class="mono-val accent">{{ selectedDevice.ip_address }}</span>
</el-descriptions-item>
<el-descriptions-item label="用户名">
<span class="mono-val">{{ selectedDevice.username }}</span>
</el-descriptions-item>
<el-descriptions-item label="区域">{{ selectedDevice.region || '—' }}</el-descriptions-item>
<el-descriptions-item label="安装位置">{{ selectedDevice.location || '—' }}</el-descriptions-item>
<el-descriptions-item label="槽位命令" :span="2">
<span class="mono-val cmd">{{ selectedDevice.slot_command || '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ selectedDevice.description || '—' }}</el-descriptions-item>
</el-descriptions>
<!-- 操作按钮区分两行语义分组 -->
<div class="detail-actions">
<!-- 第一行设备操作 -->
<div class="action-row">
<el-button v-if="can('olt.port_manage')" type="info" :loading="portsLoading" @click="openPortManager">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:4px;vertical-align:-2px">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<circle cx="7" cy="9" r="1.2" fill="currentColor"/><circle cx="11" cy="9" r="1.2" fill="currentColor"/>
<circle cx="7" cy="15" r="1.2" fill="currentColor"/><circle cx="11" cy="15" r="1.2" fill="currentColor"/>
</svg>
端口管理
</el-button>
<el-button type="success" :loading="scanning" @click="scanOlt">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:4px;vertical-align:-2px">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
扫描发现
</el-button>
<el-button v-if="can('olt.discover')" type="warning" :loading="discovering" @click="discoverOlt">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:4px;vertical-align:-2px">
<path d="M12 5v14M5 12l7 7 7-7"/>
</svg>
扫描入库
</el-button>
</div>
<!-- 第二行管理操作 + 关闭 -->
<div class="action-row action-row--secondary">
<div class="action-left">
<el-button v-if="can('olt.manage')" type="primary" @click="openEdit">编辑</el-button>
<el-button v-if="can('olt.manage')" type="danger" @click="confirmDelete">删除</el-button>
</div>
<el-button @click="detailVisible = false">关闭</el-button>
</div>
</div>
</el-dialog>
<!-- 端口管理 -->
<el-dialog v-model="portManagerVisible" title="OLT 端口管理" width="580px" destroy-on-close>
<div v-if="portsLoading" class="dialog-loading">
<div class="loading-spinner"></div>
<span>正在获取端口状态</span>
</div>
<div v-else>
<div class="port-legend">
<span class="legend-badge up">UP</span> 在线 &nbsp;
<span class="legend-badge down">DOWN</span> 离线 &nbsp;
<span class="legend-badge adm">ADM</span> 已关闭
</div>
<div class="port-info-bar">
{{ selectedDevice.location || selectedDevice.ip_address }} {{ oltPorts.length }} 个端口
</div>
<!-- 端口面板 -->
<div class="switch-panel">
<div
v-for="port in oltPorts"
:key="port.name"
class="port-slot"
:class="portClass(port)"
@click="togglePort(port)"
:title="port.name"
>
<div class="port-led" :class="portClass(port)"></div>
<div class="port-label">{{ port.name.replace('Olt', '') }}</div>
<div class="port-status-text">{{ portStatusText(port) }}</div>
</div>
</div>
<div class="port-hint" v-if="can('olt.port_manage')">点击端口可切换开启/关闭状态</div>
<div class="port-hint" v-else>仅查看端口状态无管理权限</div>
</div>
<template #footer>
<el-button :loading="portsLoading" @click="loadPorts">刷新</el-button>
<el-button @click="portManagerVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 扫描结果 -->
<el-dialog v-model="scanVisible" title="扫描结果" width="780px" destroy-on-close>
<div class="scan-summary">
<el-tag>共发现 {{ scanResult.total }} </el-tag>
<el-tag type="warning" style="margin-left: 8px">新设备 {{ scanResult.new }} </el-tag>
<el-tag v-if="scanResult.duplicates?.length" type="danger" style="margin-left: 8px">
重复 MAC {{ scanResult.duplicates.length }}
</el-tag>
</div>
<el-table :data="scanResult.devices" max-height="380" size="small">
<el-table-column prop="mac_address" label="MAC 地址" width="155">
<template #default="{ row }"><span class="mono-val">{{ row.mac_address }}</span></template>
</el-table-column>
<el-table-column label="状态" width="72">
<template #default="{ row }">
<span class="status-badge" :class="row.status">
<span class="status-dot-sm"></span>
{{ row.status === 'online' ? '在线' : '离线' }}
</span>
</template>
</el-table-column>
<el-table-column prop="distance_m" label="距离" width="80">
<template #default="{ row }"><span class="mono-val">{{ row.distance_m || '—' }}</span></template>
</el-table-column>
<el-table-column label="端口" width="110">
<template #default="{ row }"><span class="mono-val">{{ row.port_id || '—' }}</span></template>
</el-table-column>
<el-table-column prop="model" label="型号" />
<el-table-column label="新设备" width="80">
<template #default="{ row }">
<el-tag v-if="row.is_new" type="warning" size="small">新发现</el-tag>
<span v-else style="color: var(--text-muted)"></span>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button v-if="can('olt.discover')" type="warning" :loading="discovering" @click="discoverOlt">确认入库新设备</el-button>
<el-button @click="scanVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 重复 MAC -->
<el-dialog v-model="dupVisible" title="重复 MAC 地址" width="780px" destroy-on-close>
<div class="hint-text">
以下 MAC 地址在 OLT 上出现于多个端口可能是设备更换端口后旧记录未清除对离线的旧端口执行清除可恢复默认配置
</div>
<el-table :data="dupList" max-height="420" size="small" empty-text="暂无重复 MAC 记录">
<el-table-column prop="mac_address" label="MAC 地址" width="155">
<template #default="{ row }"><span class="mono-val accent">{{ row.mac_address }}</span></template>
</el-table-column>
<el-table-column label="出现的端口" min-width="260">
<template #default="{ row }">
<div style="display: flex; flex-wrap: wrap; gap: 6px">
<div v-for="p in row.ports" :key="p.port_id" style="display: inline-flex; align-items: center; gap: 4px">
<el-tag :type="p.status === 'online' ? 'success' : 'info'" size="small">
{{ p.port_id }} ({{ p.status === 'online' ? '在线' : '离线' }})
</el-tag>
<el-button
v-if="p.status !== 'online'"
type="danger"
size="small"
text
:loading="clearingPort === `${row.id}-${p.port_id}`"
style="padding: 0 4px; min-height: 20px"
@click="clearPort(row, p.port_id)"
>清除</el-button>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="最后发现" width="155">
<template #default="{ row }">
<span style="font-size: 12px; color: var(--text-muted)">
{{ fmtTime(row.last_seen_at) }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button type="danger" size="small" text @click="deleteDupRecord(row.id)">忽略</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button v-if="can('olt.manage') && dupList.length > 0" type="danger" :loading="batchClearing" @click="batchClearAll">批量清除并忽略</el-button>
<el-button @click="dupVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 新增设备 -->
<el-dialog v-model="newDevVisible" title="新增设备" width="880px" destroy-on-close>
<div class="hint-text">以下设备是 OLT 扫描新发现的请补全区域和学校信息后确认入库</div>
<el-table :data="newDevList" max-height="420" size="small" empty-text="暂无新增设备">
<el-table-column prop="mac_address" label="MAC 地址" width="155">
<template #default="{ row }"><span class="mono-val">{{ row.mac_address }}</span></template>
</el-table-column>
<el-table-column prop="port_id" label="端口" width="100">
<template #default="{ row }"><span class="mono-val">{{ row.port_id }}</span></template>
</el-table-column>
<el-table-column prop="model" label="型号" width="140" />
<el-table-column label="区域 *" width="120">
<template #default="{ row }">
<el-input v-model="row.region" size="small" placeholder="必填" />
</template>
</el-table-column>
<el-table-column label="学校 *" width="140">
<template #default="{ row }">
<el-input v-model="row.school_name" size="small" placeholder="必填" />
</template>
</el-table-column>
<el-table-column label="楼宇" width="100">
<template #default="{ row }">
<el-input v-model="row.building" size="small" />
</template>
</el-table-column>
<el-table-column label="房间号" width="90">
<template #default="{ row }">
<el-input v-model="row.room_number" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button type="primary" size="small" text :loading="savingDev === row.id" @click="saveNewDevice(row)">保存</el-button>
<el-button type="info" size="small" text @click="dismissNewDevice(row.id)">忽略</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="newDevVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from '../utils/message'
import { ElMessageBox } from 'element-plus'
import { fmtTime } from '../utils/datetime'
import request from '../utils/request'
import { useMobile } from '../composables/useMobile'
import { usePermission } from '../composables/usePermission'
const { isMobile } = useMobile()
const { can } = usePermission()
const devices = ref([])
const filterRegion = ref('')
const regions = ref([])
const filteredDevices = computed(() =>
filterRegion.value ? devices.value.filter(d => d.region === filterRegion.value) : devices.value
)
const dialogVisible = ref(false)
const detailVisible = ref(false)
const scanVisible = ref(false)
const dupVisible = ref(false)
const isEdit = ref(false)
const selectedDevice = ref({})
const scanning = ref(false)
const discovering = ref(false)
const importing = ref(false)
const scanResult = ref({ total: 0, new: 0, devices: [], duplicates: [] })
const dupList = ref([])
const duplicateCount = ref(0)
const clearingPort = ref('')
const batchClearing = ref(false)
const newDevList = ref([])
const newDeviceCount = ref(0)
const newDevVisible = ref(false)
const savingDev = ref(null)
const importInput = ref(null)
const loopDetecting = ref(false)
const loopVisible = ref(false)
const loopResults = ref([])
const loopStatusMap = ref({})
const loopDetailVisible = ref(false)
const loopDetailInterfaces = ref([])
const quickScanning = ref(false)
const quickScanVisible = ref(false)
const quickScanResult = ref({ total_online: 0, total_offline: 0, total_new: 0, results: [], errors: [] })
const form = ref({ ip_address: '', username: '', password: '', slot_command: 'display onu slot', region: '城区', location: '', description: '' })
const portManagerVisible = ref(false)
const portsLoading = ref(false)
const oltPorts = ref([])
const togglingPort = ref('')
const portClass = (port) => {
if (port.status === 'up') return 'port-up'
if (port.status === 'adm-down') return 'port-adm'
return 'port-down'
}
const portStatusText = (port) => {
if (port.status === 'up') return 'UP'
if (port.status === 'adm-down') return 'ADM'
return 'DOWN'
}
const loadPorts = async () => {
portsLoading.value = true
try {
const { data } = await request.get(`/olt/devices/${selectedDevice.value.id}/ports`)
oltPorts.value = data.ports
} catch (error) {
ElMessage.error(error.response?.data?.detail || '获取端口失败')
} finally {
portsLoading.value = false
}
}
const openPortManager = async () => {
portManagerVisible.value = true
await loadPorts()
}
const togglePort = async (port) => {
if (!can('olt.port_manage')) return
const isUp = port.status === 'up'
const action = isUp ? 'shutdown' : 'undo shutdown'
const actionText = isUp ? '关闭' : '开启'
try {
await ElMessageBox.confirm(
`确定要${actionText}端口 ${port.name} 吗?`,
`${actionText}端口`,
{ type: isUp ? 'warning' : 'info', confirmButtonText: `确认${actionText}` }
)
} catch {
return
}
togglingPort.value = port.name
try {
const { data } = await request.post(
`/olt/devices/${selectedDevice.value.id}/ports/toggle?port_name=${encodeURIComponent(port.name)}`,
{ action }
)
ElMessage.success(data.message)
port.status = isUp ? 'adm-down' : 'up'
} catch (error) {
ElMessage.error(error.response?.data?.detail || '操作失败')
} finally {
togglingPort.value = ''
}
}
const fetchRegions = async () => {
try {
const { data } = await request.get('/olt/regions')
regions.value = data
} catch {}
}
const fetchDevices = async () => {
const { data } = await request.get('/olt/devices')
devices.value = data
}
const fetchDuplicateCount = async () => {
try {
const { data } = await request.get('/olt/duplicate-macs')
duplicateCount.value = data.length
} catch {}
}
const openAddDialog = () => {
isEdit.value = false
form.value = { ip_address: '', username: '', password: '', slot_command: 'display onu slot', region: regions.value[0] || '', location: '', description: '' }
dialogVisible.value = true
}
const openDetail = (row) => {
selectedDevice.value = { ...row }
detailVisible.value = true
}
const openEdit = () => {
detailVisible.value = false
isEdit.value = true
form.value = {
ip_address: selectedDevice.value.ip_address,
username: selectedDevice.value.username,
password: '',
slot_command: selectedDevice.value.slot_command || 'display onu slot',
region: selectedDevice.value.region || '城区',
location: selectedDevice.value.location || '',
description: selectedDevice.value.description || ''
}
dialogVisible.value = true
}
const saveDevice = async () => {
const payload = { ...form.value }
if (!payload.password) delete payload.password
if (isEdit.value) {
await request.put(`/olt/devices/${form.value.ip_address}`, payload)
ElMessage.success('更新成功')
} else {
await request.post('/olt/devices', payload)
ElMessage.success('保存成功')
}
dialogVisible.value = false
fetchDevices()
}
const confirmDelete = async () => {
try {
await ElMessageBox.confirm('确定要删除此 OLT 设备吗?', '确认删除', { type: 'warning' })
await request.delete(`/olt/devices/${selectedDevice.value.ip_address}`)
ElMessage.success('删除成功')
detailVisible.value = false
fetchDevices()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.response?.data?.detail || '删除失败')
}
}
}
const scanOlt = async () => {
scanning.value = true
try {
const { data } = await request.post(`/check/scan/${selectedDevice.value.id}`)
scanResult.value = data
detailVisible.value = false
scanVisible.value = true
fetchDuplicateCount()
fetchNewDeviceCount()
} catch (error) {
ElMessage.error(error.response?.data?.detail || '扫描失败')
} finally {
scanning.value = false
}
}
const discoverOlt = async () => {
discovering.value = true
try {
const { data } = await request.post(`/check/discover/${selectedDevice.value.id}`)
const dupMsg = data.duplicate_macs > 0 ? `,重复 MAC ${data.duplicate_macs} 个` : ''
ElMessage.success(`扫描完成:在线 ${data.online},离线 ${data.offline},新入库 ${data.new_discovered}${dupMsg}`)
scanVisible.value = false
detailVisible.value = false
fetchDuplicateCount()
fetchNewDeviceCount()
} catch (error) {
ElMessage.error(error.response?.data?.detail || '入库失败')
} finally {
discovering.value = false
}
}
const openDuplicateMacs = async () => {
try {
const { data } = await request.get('/olt/duplicate-macs')
dupList.value = data
duplicateCount.value = data.length
dupVisible.value = true
} catch {
ElMessage.error('获取重复 MAC 失败')
}
}
const clearPort = async (row, portId) => {
try {
await ElMessageBox.confirm(
`确定要清除端口 Onu${portId} 的配置吗?此操作将通过 SSH 执行 default 命令,不可撤销。`,
'确认清除',
{ type: 'warning', confirmButtonText: '确认清除', confirmButtonClass: 'el-button--danger' }
)
} catch {
return
}
clearingPort.value = `${row.id}-${portId}`
try {
const { data } = await request.post(`/olt/duplicate-macs/${row.id}/clear-port`, { port_id: portId })
ElMessage.success(data.message)
if (data.remaining_ports.length === 0) {
dupList.value = dupList.value.filter(r => r.id !== row.id)
} else {
row.ports = data.remaining_ports
}
duplicateCount.value = dupList.value.length
} catch (error) {
ElMessage.error(error.response?.data?.detail || '清除失败')
} finally {
clearingPort.value = ''
}
}
const deleteDupRecord = async (id) => {
try {
await request.delete(`/olt/duplicate-macs/${id}`)
dupList.value = dupList.value.filter(r => r.id !== id)
duplicateCount.value = dupList.value.length
ElMessage.success('已清除')
} catch {
ElMessage.error('清除失败')
}
}
const batchClearAll = async () => {
const total = dupList.value.length
if (total === 0) return
try {
await ElMessageBox.confirm(
`将对 ${total} 条重复 MAC 记录的所有离线端口执行 SSH 清除,然后自动忽略全部记录。此操作不可撤销,确认继续?`,
'批量清除确认',
{ type: 'warning', confirmButtonText: '确认清除', confirmButtonClass: 'el-button--danger' }
)
} catch {
return
}
batchClearing.value = true
// 按 olt_id 分组
const groups = {}
for (const row of dupList.value) {
const key = row.olt_id ?? '__unknown__'
if (!groups[key]) groups[key] = []
groups[key].push(row)
}
let cleared = 0, failed = 0
// 每个 OLT 一个并发任务,组内串行处理完所有重复 MAC 再结束
await Promise.all(
Object.values(groups).map(async (rows) => {
for (const row of rows) {
const offlinePorts = (row.ports || []).filter(p => p.status !== 'online')
for (const p of offlinePorts) {
try {
await request.post(`/olt/duplicate-macs/${row.id}/clear-port`, { port_id: p.port_id })
cleared++
} catch {
failed++
}
}
try {
await request.delete(`/olt/duplicate-macs/${row.id}`)
} catch {}
}
})
)
dupList.value = []
duplicateCount.value = 0
batchClearing.value = false
ElMessage.success(`批量清除完成:清除 ${cleared} 个端口${failed ? `${failed} 个失败` : ''},已忽略全部 ${total} 条记录`)
}
const fetchNewDeviceCount = async () => {
try {
const { data } = await request.get('/olt/new-devices')
newDeviceCount.value = data.length
} catch {}
}
const openNewDevices = async () => {
try {
const { data } = await request.get('/olt/new-devices')
newDevList.value = data.map(d => ({ ...d, region: d.region || '', school_name: d.school_name || '', building: d.building || '', room_number: d.room_number || '' }))
newDeviceCount.value = data.length
newDevVisible.value = true
} catch {
ElMessage.error('获取新增设备失败')
}
}
const saveNewDevice = async (row) => {
if (!row.region || !row.school_name) {
ElMessage.warning('区域和学校为必填项')
return
}
savingDev.value = row.id
try {
await request.put(`/olt/new-devices/${row.id}`, {
region: row.region,
school_name: row.school_name,
building: row.building,
room_number: row.room_number,
})
ElMessage.success('已保存')
newDevList.value = newDevList.value.filter(d => d.id !== row.id)
newDeviceCount.value = newDevList.value.length
} catch (error) {
ElMessage.error(error.response?.data?.detail || '保存失败')
} finally {
savingDev.value = null
}
}
const dismissNewDevice = async (id) => {
try {
await request.delete(`/olt/new-devices/${id}`)
newDevList.value = newDevList.value.filter(d => d.id !== id)
newDeviceCount.value = newDevList.value.length
ElMessage.success('已忽略')
} catch {
ElMessage.error('操作失败')
}
}
const runQuickScan = async () => {
quickScanning.value = true
try {
const { data } = await request.post('/olt/quick-scan', null, { timeout: 180000 })
quickScanResult.value = data
quickScanVisible.value = true
} catch (error) {
ElMessage.error(error.response?.data?.detail || '快速扫描失败')
} finally {
quickScanning.value = false
}
}
const runLoopbackDetection = async () => {
loopDetecting.value = true
try {
const { data } = await request.post('/olt/loopback-detection', null, { timeout: 120000 })
loopResults.value = data
const map = {}
for (const item of data) {
if (item.has_loop && item.loop_interfaces.length > 0) {
map[item.olt_id] = item.loop_interfaces
}
}
loopStatusMap.value = map
loopVisible.value = true
} catch (error) {
ElMessage.error(error.response?.data?.detail || '环路检测失败')
} finally {
loopDetecting.value = false
}
}
const showLoopDetail = (oltId) => {
loopDetailInterfaces.value = loopStatusMap.value[oltId] || []
loopDetailVisible.value = true
}
const triggerImport = () => {
importInput.value?.click()
}
const handleImportFile = async (event) => {
const file = event.target?.files?.[0] || (event.raw || event)
if (!file) return
importing.value = true
const formData = new FormData()
formData.append('file', file)
try {
const { data } = await request.post('/olt/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
const failMsg = data.failed?.length ? `${data.failed.length} 条失败` : ''
ElMessage.success(`成功导入 ${data.success}${failMsg}`)
fetchDevices()
} catch (error) {
ElMessage.error(error.response?.data?.detail || '导入失败')
} finally {
importing.value = false
}
}
const downloadTemplate = () => {
window.open('/api/olt/template', '_blank')
}
// 移动端卡片操作
const openEditFromCard = (device) => {
selectedDevice.value = { ...device }
openEdit()
}
const openPortManagerFromCard = async (device) => {
selectedDevice.value = { ...device }
await openPortManager()
}
const ntpSyncing = ref(false)
const ntpSyncVisible = ref(false)
const ntpPhase = ref('config') // 'config' | 'results'
const ntpForm = ref({ old_server: '172.16.0.254', new_server: '172.16.1.252' })
const ntpSyncResult = ref({ total: 0, success: 0, failed: 0, results: [] })
const openNtpSync = () => {
ntpPhase.value = 'config'
ntpSyncResult.value = { total: 0, success: 0, failed: 0, results: [] }
ntpSyncVisible.value = true
}
const runNtpSync = async () => {
ntpSyncing.value = true
try {
const { data } = await request.post('/olt/sync-ntp', ntpForm.value)
ntpSyncResult.value = data
ntpPhase.value = 'results'
} catch (error) {
ElMessage.error(error.response?.data?.detail || 'NTP 同步失败')
} finally {
ntpSyncing.value = false
}
}
onMounted(() => {
fetchRegions()
fetchDevices()
fetchDuplicateCount()
fetchNewDeviceCount()
})
</script>
<style scoped>
.olt-manage {
padding: 24px;
}
/* 页头 */
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.page-title-group {
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
}
.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;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.btn-icon {
margin-right: 5px;
vertical-align: -2px;
}
.btn-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
margin-left: 4px;
border-radius: 9px;
font-size: 11px;
font-weight: 600;
line-height: 1;
flex-shrink: 0;
}
.btn-count.danger { background: var(--danger); color: #fff; }
.btn-count.warning { background: var(--warning); color: #fff; }
.header-actions .el-button {
white-space: nowrap;
}
/* 数据面板 */
.data-panel {
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
overflow: hidden;
}
.filter-bar {
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
display: flex;
gap: 12px;
align-items: center;
}
.region-tag {
display: inline-block;
padding: 1px 8px;
background: var(--accent-dim);
border: 1px solid var(--border-accent);
border-radius: 10px;
font-size: 11px;
color: var(--accent);
font-weight: 500;
}
/* 表格内元素 */
.row-index {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
}
.ip-link {
font-family: var(--font-mono);
font-size: 13px;
color: var(--accent);
cursor: pointer;
transition: color 0.15s;
}
.ip-link:hover {
color: #00f0cc;
text-decoration: underline;
}
.mono-val {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
}
.mono-val.accent { color: var(--accent); font-size: 13px; }
.mono-val.cmd { color: #a3e6d8; font-size: 11px; }
.loop-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: var(--danger-dim);
border: 1px solid rgba(239,68,68,0.3);
border-radius: 10px;
color: var(--danger);
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.loop-badge:hover {
background: rgba(239,68,68,0.25);
}
/* 对话框公共 */
.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-val.muted { color: var(--text-muted); }
.rs-label {
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.hint-text {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 14px;
line-height: 1.6;
}
.scan-summary {
margin-bottom: 14px;
}
.loop-olt-block {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-subtle);
}
.loop-olt-block:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.loop-olt-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.loop-olt-name {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
/* 状态 badge(从DeviceList复用) */
.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-dot-sm {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.online .status-dot-sm { box-shadow: 0 0 4px currentColor; }
/* 端口面板 */
.port-legend {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
font-size: 12px;
color: var(--text-muted);
}
.legend-badge {
display: inline-block;
padding: 1px 7px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
font-family: var(--font-mono);
}
.legend-badge.up { background: var(--success-dim); color: var(--success); border: 1px solid rgba(34,197,94,0.3); }
.legend-badge.down { background: var(--danger-dim); color: var(--danger); border: 1px solid rgba(239,68,68,0.3); }
.legend-badge.adm { background: var(--warning-dim); color: var(--warning); border: 1px solid rgba(245,158,11,0.3); }
.port-info-bar {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 14px;
font-family: var(--font-mono);
}
.switch-panel {
display: flex;
flex-wrap: wrap;
gap: 8px;
background: #06090f;
border-radius: var(--radius-md);
padding: 18px;
border: 1px solid var(--border-default);
}
.port-slot {
width: 68px;
height: 68px;
border-radius: var(--radius-sm);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
border: 1px solid #2a3050;
transition: all 0.18s;
background: #0e1428;
gap: 5px;
}
.port-slot:hover {
transform: scale(1.07);
border-color: var(--border-strong);
}
.port-slot.port-up { border-color: rgba(34,197,94,0.5); background: #0b1d10; }
.port-slot.port-down { border-color: rgba(239,68,68,0.5); background: #1d0b0b; }
.port-slot.port-adm { border-color: rgba(245,158,11,0.5); background: #1d150b; }
.port-led {
width: 10px;
height: 10px;
border-radius: 50%;
background: #2a3050;
}
.port-led.port-up { background: var(--success); box-shadow: 0 0 8px var(--success); }
.port-led.port-down { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
.port-led.port-adm { background: var(--warning); box-shadow: 0 0 8px var(--warning); }
.port-label {
font-size: 10px;
color: var(--text-muted);
font-family: var(--font-mono);
text-align: center;
line-height: 1.2;
}
.port-status-text {
font-size: 9px;
font-weight: 700;
font-family: var(--font-mono);
letter-spacing: 0.05em;
}
.port-slot.port-up .port-status-text { color: var(--success); }
.port-slot.port-down .port-status-text { color: var(--danger); }
.port-slot.port-adm .port-status-text { color: var(--warning); }
.port-hint {
margin-top: 10px;
font-size: 11px;
color: var(--text-muted);
}
/* 详情弹窗操作区 */
.detail-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border-subtle);
}
.action-row {
display: flex;
gap: 8px;
align-items: center;
}
.action-row--secondary {
justify-content: space-between;
}
.action-left {
display: flex;
gap: 8px;
}
/* ── 移动端 OLT 卡片 ── */
.olt-cards {
display: flex;
flex-direction: column;
gap: 0;
}
.olt-card {
border-bottom: 1px solid var(--border-subtle);
padding: 14px 16px;
}
.olt-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.olt-card-head-left {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.olt-ip {
font-family: var(--font-mono);
font-size: 15px;
font-weight: 600;
color: var(--accent);
cursor: pointer;
letter-spacing: 0.02em;
}
.olt-location {
font-size: 12px;
color: var(--text-secondary);
}
.olt-card-head-right {
flex-shrink: 0;
display: flex;
align-items: center;
}
.olt-status-ok {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--success);
font-weight: 500;
}
.online-dot {
background: var(--success);
box-shadow: 0 0 5px var(--success);
}
.olt-card-actions {
display: flex;
gap: 8px;
}
.olt-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 9px 6px;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-sans);
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
min-height: 44px;
}
.olt-action-btn:active {
background: var(--bg-hover);
}
.olt-action-btn--edit {
flex: 0 0 auto;
padding: 9px 14px;
}
.olt-cards-empty {
text-align: center;
padding: 48px 0;
font-size: 13px;
color: var(--text-muted);
}
/* 移动端页头 */
@media (max-width: 767px) {
.olt-manage {
padding: 12px;
}
.page-header {
flex-direction: column;
gap: 12px;
margin-bottom: 14px;
}
.page-title {
font-size: 18px;
}
.header-actions {
width: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
justify-items: center;
}
.header-actions .el-button {
width: 95%;
min-height: 44px;
padding-left: 14px;
padding-right: 14px;
justify-content: center;
}
/* 端口管理对话框:限制高度支持滚动 */
.switch-panel {
max-height: 45vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}
</style>