feat: v0.9.0 新增记录日志、设备更换记录及IMC服务集成

- 新增 RecordsLog.vue 操作记录日志页面
- 新增 ReplacementRecords.vue 设备更换记录页面
- 新增 imc_service.py IMC网管系统集成服务
- 新增 datetime.js 前端日期时间工具函数
- 新增 OLT时间同步.md 文档
- 扩展 devices.py API:设备更换记录、批量操作等
- 扩展 ssh_service.py:OLT时间同步功能
- 扩展 olt.py:新增时间同步相关接口
- 更新 DeviceList.vue:增强设备列表功能
- 更新路由和导航菜单
- 将 .claude/ 和 .mcp.json 加入 .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 10:36:06 +08:00
parent eaabebbceb
commit 5aadbc78c6
24 changed files with 2830 additions and 45 deletions
+4 -3
View File
@@ -292,7 +292,7 @@ const allNavItems = [
},
{
path: '/audit',
label: '审计日志',
label: '记录查询',
adminOnly: true,
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
@@ -322,7 +322,7 @@ const mobileNavItems = computed(() => navItems.value.slice(0, 3))
const extraNavItems = computed(() =>
navItems.value.slice(3).map((item, i) => ({
...item,
desc: ['7天趋势与区域分布', '物料出入库台账', '管理系统用户', '配置角色权限', '定时检查等系统配置', '查看操作审计记录', '系统信息与说明'][i] || ''
desc: ['7天趋势与区域分布', '物料出入库台账', '管理系统用户', '配置角色权限', '定时检查等系统配置', '更换台账与审计日志', '系统信息与说明'][i] || ''
}))
)
@@ -337,7 +337,8 @@ const pageNameMap = {
'/users': '用户管理',
'/roles': '角色权限',
'/settings': '系统设置',
'/audit': '审计日志',
'/replacements': '记录查询',
'/audit': '记录查询',
}
const currentPageName = computed(() => pageNameMap[route.path] || '页面')
+2 -2
View File
@@ -64,8 +64,8 @@ const routes = [
},
{
path: '/audit',
name: 'AuditLog',
component: () => import('../views/AuditLog.vue'),
name: 'RecordsLog',
component: () => import('../views/RecordsLog.vue'),
meta: { requiresRole: 'admin' }
},
{
+14
View File
@@ -0,0 +1,14 @@
/**
* 格式化后端返回的 UTC 时间戳为北京时间(UTC+8)
*
* 后端用 datetime.utcnow() 存储,JSON 序列化无时区后缀,
* JavaScript 把无后缀字符串当本地时间处理,导致少 8 小时。
* 补上 'Z' 告知 JS 这是 UTC,浏览器会自动转成本地时间(北京 = UTC+8)。
*/
export function fmtTime(t, { slice } = {}) {
if (!t) return '—'
const s = String(t)
const iso = /[Z+]/.test(s) ? s : s + 'Z'
const result = new Date(iso).toLocaleString('zh-CN', { hour12: false })
return slice ? result.slice(0, slice) : result
}
+2 -1
View File
@@ -113,6 +113,7 @@
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from '../utils/message'
import { getAuditLogs, getAuditLogDetail, exportAuditLogs } from '../api/audit'
import { fmtTime } from '../utils/datetime'
const logs = ref([])
const total = ref(0)
@@ -137,7 +138,7 @@ const actionTypes = [
const typeLabel = (t) => actionTypes.find(x => x.value === t)?.label || t
const statusLabel = (s) => ({ success: '成功', failed: '失败', error: '错误' }[s] || s)
const fmtTime = (t) => t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—'
// fmtTime 从 utils/datetime 导入,已在文件顶部
const buildParams = () => {
const p = { page: page.value, page_size: pageSize.value }
+452 -15
View File
@@ -185,6 +185,24 @@
</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"/>
@@ -344,13 +362,20 @@
<el-descriptions-item label="备注" :span="2">{{ selectedDevice.notes || '—' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button v-if="can('device.check')" type="info" :loading="deviceRefreshing" @click="doRefreshDevice" :disabled="!selectedDevice.olt_id">更新</el-button>
<el-button v-if="can('device.edit')" type="warning" @click="openReplace">更换</el-button>
<el-button v-if="can('device.edit')" type="primary" @click="openEdit">编辑</el-button>
<el-button v-if="can('device.edit')" type="success" @click="openProvision" :disabled="selectedDevice.status !== 'online' || (!selectedDevice.port_id && !(selectedDevice.slot_number && selectedDevice.port_number))">
业务下发
</el-button>
<el-button @click="detailVisible = false">关闭</el-button>
<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>
@@ -457,8 +482,89 @@ save force</pre>
</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="480px" destroy-on-close>
<el-dialog v-model="importDialogVisible" title="数据导入" width="min(90vw, 560px)" destroy-on-close>
<div class="import-hint">
请先下载模板按格式填写后上传导入只更新设备信息不会删除已有设备
</div>
@@ -509,10 +615,12 @@ save force</pre>
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()
@@ -562,6 +670,85 @@ 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
}
@@ -634,8 +821,41 @@ const openReplaceFromCard = async (device) => {
replaceVisible.value = true
}
const openProvisionFromCard = (device) => {
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
}
@@ -773,7 +993,7 @@ const doReplace = async () => {
}
}
const fmtReplaceTime = (t) => t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—'
// fmtReplaceTime 从 utils/datetime 导入,已在文件顶部
const openEdit = () => {
editForm.value = {
@@ -1250,29 +1470,30 @@ onMounted(() => {
}
.card-actions {
display: flex;
gap: 8px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 7px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.card-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 9px 8px;
padding: 9px 4px;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 12px;
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 {
@@ -1294,6 +1515,16 @@ onMounted(() => {
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);
@@ -1407,6 +1638,23 @@ onMounted(() => {
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);
@@ -1418,6 +1666,195 @@ onMounted(() => {
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;
+12 -2
View File
@@ -88,8 +88,12 @@
<div class="panel-body">
<div class="result-summary">
<div class="rs-card success">
<span class="rs-num">{{ result.success }}</span>
<span class="rs-label">成功导入</span>
<span class="rs-num">{{ result.created || 0 }}</span>
<span class="rs-label">新增设备</span>
</div>
<div class="rs-card updated">
<span class="rs-num">{{ result.updated || 0 }}</span>
<span class="rs-label">更新信息</span>
</div>
<div class="rs-card" :class="result.failed?.length ? 'failed' : 'empty'">
<span class="rs-num">{{ result.failed?.length || 0 }}</span>
@@ -350,6 +354,11 @@ const handleDownloadTemplate = async () => {
border-color: rgba(34,197,94,0.25);
}
.rs-card.updated {
background: var(--info-dim);
border-color: rgba(59,130,246,0.25);
}
.rs-card.failed {
background: var(--danger-dim);
border-color: rgba(239,68,68,0.25);
@@ -369,6 +378,7 @@ const handleDownloadTemplate = async () => {
}
.rs-card.success .rs-num { color: var(--success); }
.rs-card.updated .rs-num { color: var(--info); }
.rs-card.failed .rs-num { color: var(--danger); }
.rs-card.empty .rs-num { color: var(--text-muted); }
+2 -1
View File
@@ -617,6 +617,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { inventoryApi } from '../api/inventory'
import { ElMessage } from '../utils/message'
import { ElMessageBox } from 'element-plus'
import { fmtTime as fmtTimeRaw } from '../utils/datetime'
const activeTab = ref('materials')
const tabs = [
@@ -1016,7 +1017,7 @@ const txnTypeLabel = (t) => ({ purchase_in: '采购入库', allocate_out: '领
const txnTypeClass = (t) => ({ purchase_in: 'badge-success', allocate_out: 'badge-info', return_in: 'badge-warn', scrap_out: 'badge-danger', adjust: 'badge-default' }[t] || '')
const statusLabel = (s) => ({ in_stock: '库存中', allocated: '已分配', installed: '已安装', in_use: '使用中', returned: '已退库', repairing: '维修中', scrapped: '已报废' }[s] || s)
const statusClass = (s) => ({ in_stock: 'badge-success', allocated: 'badge-info', installed: 'badge-info', in_use: 'badge-info', returned: 'badge-default', repairing: 'badge-warn', scrapped: 'badge-danger' }[s] || '')
const formatTime = (t) => t ? new Date(t).toLocaleString('zh-CN', { hour12: false }).slice(0, 16) : '-'
const formatTime = (t) => fmtTimeRaw(t, { slice: 16 })
</script>
<style scoped>
+91 -1
View File
@@ -32,6 +32,9 @@
新增设备
<el-badge v-if="newDeviceCount > 0" :value="newDeviceCount" style="margin-left: 4px" />
</el-button>
<el-button v-if="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>
@@ -257,6 +260,67 @@
</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">
@@ -443,7 +507,7 @@
<el-table-column label="最后发现" width="155">
<template #default="{ row }">
<span style="font-size: 12px; color: var(--text-muted)">
{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString() : '—' }}
{{ fmtTime(row.last_seen_at) }}
</span>
</template>
</el-table-column>
@@ -508,6 +572,7 @@
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'
@@ -941,6 +1006,31 @@ const openPortManagerFromCard = async (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()
+536
View File
@@ -0,0 +1,536 @@
<template>
<div class="records-page">
<!-- 页头 -->
<div class="page-header">
<div class="page-title-group">
<h1 class="page-title">记录查询</h1>
<span class="page-subtitle">{{ activeTab === 'replacements' ? '设备 MAC 更换台账,支持筛选与导出' : '全量用户操作审计,支持追溯与问责' }}</span>
</div>
<el-button type="success" size="small" :loading="activeTab === 'replacements' ? repExporting : auditExporting" @click="handleExport">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="margin-right:5px">
<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
</el-button>
</div>
<!-- Tab 切换 -->
<div class="tab-bar">
<button
:class="['tab-btn', activeTab === 'replacements' && 'active']"
@click="switchTab('replacements')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="12" y2="17"/>
</svg>
更换台账
<span v-if="repTotal > 0" class="tab-count">{{ repTotal }}</span>
</button>
<button
:class="['tab-btn', activeTab === 'audit' && 'active']"
@click="switchTab('audit')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
审计日志
<span v-if="auditTotal > 0" class="tab-count">{{ auditTotal }}</span>
</button>
</div>
<!-- 更换台账 -->
<template v-if="activeTab === 'replacements'">
<div class="filter-bar">
<el-select v-model="repFilters.region" placeholder="全部区域" clearable size="small" style="width:140px" @change="repLoad">
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
</el-select>
<el-date-picker
v-model="repDateRange"
type="daterange"
size="small"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width:240px"
@change="repLoad"
/>
<el-input
v-model="repFilters.keyword"
placeholder="学校 / MAC / 操作人"
clearable size="small" style="width:200px"
@input="repDebounce" @clear="repLoad"
/>
<button class="reset-btn" @click="repReset">重置</button>
<span class="total-hint"> {{ repTotal }} </span>
</div>
<div class="table-wrap">
<el-table :data="repItems" v-loading="repLoading" size="small" style="width:100%" border>
<el-table-column type="index" label="#" width="52" align="center">
<template #default="{ $index }">
<span class="row-index">{{ (repPage - 1) * repPageSize + $index + 1 }}</span>
</template>
</el-table-column>
<el-table-column label="更换时间" width="165">
<template #default="{ row }">
<span class="mono-val small">{{ fmtTime(row.replaced_at) }}</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="school_name" label="学校" min-width="140" show-overflow-tooltip />
<el-table-column label="楼宇/房间" width="130" show-overflow-tooltip>
<template #default="{ row }">
<span class="muted">{{ [row.building, row.room_number].filter(Boolean).join(' / ') || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="旧 MAC" width="145">
<template #default="{ row }">
<span class="mono-val mac old-mac">{{ row.old_mac }}</span>
</template>
</el-table-column>
<el-table-column label="新 MAC" width="145">
<template #default="{ row }">
<span class="mono-val mac new-mac">{{ row.new_mac }}</span>
</template>
</el-table-column>
<el-table-column label="更换原因" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.reason" style="font-size:12px">{{ row.reason }}</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column prop="operator_name" label="操作人" width="110">
<template #default="{ row }">
<span style="font-size:12px">{{ row.operator_name || '—' }}</span>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="repPage"
v-model:page-size="repPageSize"
:total="repTotal"
:page-sizes="[50, 100, 200]"
layout="total, sizes, prev, pager, next"
small
@size-change="repLoad"
@current-change="repLoad"
/>
</div>
</div>
</template>
<!-- 审计日志 -->
<template v-if="activeTab === 'audit'">
<div class="filter-bar">
<el-date-picker
v-model="auditDateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
size="small"
style="width:340px"
@change="auditFilter"
/>
<el-input v-model="auditFilters.username" placeholder="用户名" size="small" style="width:130px" clearable @change="auditFilter" />
<el-select v-model="auditFilters.action_type" placeholder="操作类型" size="small" style="width:120px" clearable @change="auditFilter">
<el-option v-for="t in actionTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<el-select v-model="auditFilters.status" placeholder="状态" size="small" style="width:100px" clearable @change="auditFilter">
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
<el-option label="错误" value="error" />
</el-select>
<button class="reset-btn" @click="auditReset">重置</button>
<span class="total-hint"> {{ auditTotal }} </span>
</div>
<div class="table-wrap">
<el-table :data="auditLogs" size="small" style="width:100%" v-loading="auditLoading" @row-click="openDetail">
<el-table-column label="时间" width="160">
<template #default="{ row }">
<span class="mono-val">{{ fmtTime(row.action_time) }}</span>
</template>
</el-table-column>
<el-table-column label="用户" width="110">
<template #default="{ row }">
<span>{{ row.username }}</span>
<span class="role-tag">{{ row.user_role }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<span :class="['type-tag', `type-${row.action_type}`]">{{ typeLabel(row.action_type) }}</span>
</template>
</el-table-column>
<el-table-column label="子类型" width="80">
<template #default="{ row }"><span class="muted">{{ row.action_subtype || '—' }}</span></template>
</el-table-column>
<el-table-column label="描述" min-width="180" show-overflow-tooltip prop="description" />
<el-table-column label="路径" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="mono-val small">{{ row.request_method }} {{ row.request_path }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="72" align="center">
<template #default="{ row }">
<span :class="['status-dot', `status-${row.status}`]">{{ statusLabel(row.status) }}</span>
</template>
</el-table-column>
<el-table-column label="IP" width="130">
<template #default="{ row }">
<span class="mono-val small">{{ row.ip_address || '—' }}</span>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="auditPage"
v-model:page-size="auditPageSize"
:total="auditTotal"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
small
@change="fetchAudit"
/>
</div>
</div>
</template>
<!-- 审计详情抽屉 -->
<el-drawer v-model="drawerVisible" title="日志详情" size="480px" direction="rtl">
<div v-if="auditDetail" class="detail-body">
<div class="detail-row" v-for="(v, k) in detailFields" :key="k">
<span class="detail-label">{{ v.label }}</span>
<span class="detail-value" :class="{ mono: v.mono }">{{ v.val }}</span>
</div>
<template v-if="auditDetail.request_params">
<div class="detail-section">请求参数</div>
<pre class="json-block">{{ JSON.stringify(auditDetail.request_params, null, 2) }}</pre>
</template>
<template v-if="auditDetail.error_message">
<div class="detail-section">错误信息</div>
<pre class="json-block error">{{ auditDetail.error_message }}</pre>
</template>
</div>
</el-drawer>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from '../utils/message'
import { getAuditLogs, getAuditLogDetail, exportAuditLogs } from '../api/audit'
import request from '../utils/request'
import { fmtTime } from '../utils/datetime'
// ── 当前 Tab ──────────────────────────────────────
const activeTab = ref('replacements')
const switchTab = (tab) => {
activeTab.value = tab
if (tab === 'replacements' && repItems.value.length === 0) repLoad()
if (tab === 'audit' && auditLogs.value.length === 0) fetchAudit()
}
// ── 公共 ──────────────────────────────────────────
const regions = ref([])
// ── 更换台账 ──────────────────────────────────────
const repItems = ref([])
const repTotal = ref(0)
const repPage = ref(1)
const repPageSize = ref(100)
const repLoading = ref(false)
const repExporting = ref(false)
const repDateRange = ref(null)
const repFilters = ref({ region: '', keyword: '' })
let repTimer = null
const repDebounce = () => { clearTimeout(repTimer); repTimer = setTimeout(repLoad, 400) }
const repReset = () => { repFilters.value = { region: '', keyword: '' }; repDateRange.value = null; repPage.value = 1; repLoad() }
const repParams = () => {
const p = { skip: (repPage.value - 1) * repPageSize.value, limit: repPageSize.value }
if (repFilters.value.region) p.region = repFilters.value.region
if (repFilters.value.keyword) p.keyword = repFilters.value.keyword
if (repDateRange.value?.[0]) p.start_date = repDateRange.value[0]
if (repDateRange.value?.[1]) p.end_date = repDateRange.value[1]
return p
}
const repLoad = async () => {
repLoading.value = true
try {
const { data } = await request.get('/devices/replacements', { params: repParams() })
repItems.value = data.items
repTotal.value = data.total
} catch { ElMessage.error('更换台账加载失败') }
finally { repLoading.value = false }
}
const repExport = async () => {
repExporting.value = true
try {
const params = {}
if (repFilters.value.region) params.region = repFilters.value.region
if (repFilters.value.keyword) params.keyword = repFilters.value.keyword
if (repDateRange.value?.[0]) params.start_date = repDateRange.value[0]
if (repDateRange.value?.[1]) params.end_date = repDateRange.value[1]
const { data } = await request.get('/devices/replacements/export', { params, responseType: 'blob' })
const url = URL.createObjectURL(new Blob([data]))
const a = document.createElement('a')
a.href = url
a.download = `replacement_records_${Date.now()}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { ElMessage.error('导出失败') }
finally { repExporting.value = false }
}
// ── 审计日志 ──────────────────────────────────────
const auditLogs = ref([])
const auditTotal = ref(0)
const auditPage = ref(1)
const auditPageSize = ref(50)
const auditLoading = ref(false)
const auditExporting = ref(false)
const drawerVisible = ref(false)
const auditDetail = ref(null)
const auditDateRange = ref(null)
const auditFilters = ref({ username: '', action_type: '', status: '' })
const actionTypes = [
{ label: '认证', value: 'auth' },
{ label: '设备', value: 'device' },
{ label: 'OLT', value: 'olt' },
{ label: '用户', value: 'user' },
{ label: '系统', value: 'system' },
{ label: '库存', value: 'inventory' },
]
const typeLabel = (t) => actionTypes.find(x => x.value === t)?.label || t
const statusLabel = (s) => ({ success: '成功', failed: '失败', error: '错误' }[s] || s)
const auditBuildParams = () => {
const p = { page: auditPage.value, page_size: auditPageSize.value }
if (auditDateRange.value?.[0]) p.start_time = auditDateRange.value[0].toISOString()
if (auditDateRange.value?.[1]) p.end_time = auditDateRange.value[1].toISOString()
if (auditFilters.value.username) p.username = auditFilters.value.username
if (auditFilters.value.action_type) p.action_type = auditFilters.value.action_type
if (auditFilters.value.status) p.status = auditFilters.value.status
return p
}
const fetchAudit = async () => {
auditLoading.value = true
try {
const { data } = await getAuditLogs(auditBuildParams())
auditLogs.value = data.items
auditTotal.value = data.total
} catch { ElMessage.error('审计日志加载失败') }
finally { auditLoading.value = false }
}
const auditFilter = () => { auditPage.value = 1; fetchAudit() }
const auditReset = () => {
auditFilters.value = { username: '', action_type: '', status: '' }
auditDateRange.value = null
auditPage.value = 1
fetchAudit()
}
const openDetail = async (row) => {
try {
const { data } = await getAuditLogDetail(row.id)
auditDetail.value = data
drawerVisible.value = true
} catch { ElMessage.error('加载详情失败') }
}
const detailFields = computed(() => {
if (!auditDetail.value) return {}
const d = auditDetail.value
return {
time: { label: '时间', val: fmtTime(d.action_time), mono: true },
user: { label: '用户', val: `${d.username} (${d.user_role})` },
type: { label: '操作', val: `${typeLabel(d.action_type)} / ${d.action_subtype || '—'}` },
path: { label: '路径', val: `${d.request_method} ${d.request_path}`, mono: true },
status: { label: '状态', val: `${statusLabel(d.status)} (${d.status_code})` },
ip: { label: 'IP', val: d.ip_address || '—', mono: true },
desc: { label: '描述', val: d.description },
ua: { label: 'UA', val: d.user_agent || '—' },
}
})
const auditExport = async () => {
auditExporting.value = true
try {
const { data } = await exportAuditLogs(auditBuildParams())
const url = URL.createObjectURL(new Blob([data]))
const a = document.createElement('a')
a.href = url; a.download = `audit_${Date.now()}.csv`; a.click()
URL.revokeObjectURL(url)
} catch { ElMessage.error('导出失败') }
finally { auditExporting.value = false }
}
// ── 导出分发 ──────────────────────────────────────
const handleExport = () => activeTab.value === 'replacements' ? repExport() : auditExport()
// ── 初始化 ────────────────────────────────────────
onMounted(async () => {
try {
const { data } = await request.get('/devices/regions')
regions.value = data
} catch {}
repLoad()
})
</script>
<style scoped>
.records-page { padding: 24px; }
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.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); }
/* Tab 条 */
.tab-bar {
display: flex;
gap: 4px;
margin-bottom: 16px;
border-bottom: 2px solid var(--border-default);
padding-bottom: 0;
}
.tab-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
transition: color .15s, border-color .15s;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.tab-btn:hover { color: var(--text-primary); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-count {
font-size: 10px;
background: var(--accent-dim);
color: var(--accent);
border-radius: 8px;
padding: 1px 6px;
font-weight: 600;
}
/* 筛选栏 */
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.total-hint { font-size: 12px; color: var(--text-muted); margin-left: 4px; }
.reset-btn {
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
}
.reset-btn:hover { color: var(--text-primary); }
/* 表格容器 */
.table-wrap {
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
overflow: hidden;
}
.pagination {
padding: 12px 16px;
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--border-subtle);
}
/* 通用样式 */
.row-index { color: var(--text-muted); font-size: 12px; }
.mono-val { font-family: var(--font-mono); font-size: 12px; }
.small { font-size: 11px; }
.muted { color: var(--text-muted); font-size: 12px; }
.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;
}
.mac.old-mac { color: var(--danger); }
.mac.new-mac { color: var(--success); }
.role-tag {
margin-left: 4px;
font-size: 10px;
color: var(--text-muted);
background: var(--bg-elevated);
padding: 1px 5px;
border-radius: 3px;
}
.type-tag { font-size: 11px; padding: 2px 7px; border-radius: 3px; font-weight: 500; }
.type-auth { background: #dbeafe; color: #1d4ed8; }
.type-device { background: #dcfce7; color: #15803d; }
.type-olt { background: #fef9c3; color: #854d0e; }
.type-user { background: #fce7f3; color: #9d174d; }
.type-system { background: #f3e8ff; color: #6b21a8; }
.type-inventory{ background: #ffedd5; color: #9a3412; }
.status-dot { font-size: 11px; padding: 2px 7px; border-radius: 3px; }
.status-success { background: #dcfce7; color: #15803d; }
.status-failed { background: #fef9c3; color: #854d0e; }
.status-error { background: #fee2e2; color: #991b1b; }
/* 详情抽屉 */
.detail-body { padding: 4px 0; }
.detail-row { display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); font-size: 13px; }
.detail-label { width: 60px; flex-shrink: 0; color: var(--text-muted); }
.detail-value { flex: 1; color: var(--text-primary); word-break: break-all; }
.detail-value.mono { font-family: var(--font-mono); font-size: 12px; }
.detail-section { margin: 16px 0 8px; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.json-block { background: var(--bg-elevated); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 10px 12px; font-size: 12px; font-family: var(--font-mono); white-space: pre-wrap; word-break: break-all; color: var(--text-secondary); margin: 0; }
.json-block.error { color: #dc2626; }
</style>
+277
View File
@@ -0,0 +1,277 @@
<template>
<div class="replacement-page">
<div class="page-header">
<div class="page-title-group">
<h1 class="page-title">更换记录台账</h1>
<span class="page-subtitle">设备 MAC 地址更换历史支持筛选与导出</span>
</div>
<el-button type="success" size="small" :loading="exporting" @click="exportCsv">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="margin-right:5px">
<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
</el-button>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<el-select v-model="filters.region" placeholder="全部区域" clearable size="small" style="width:140px" @change="load">
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
size="small"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width:240px"
@change="load"
/>
<el-input
v-model="filters.keyword"
placeholder="学校 / MAC / 操作人"
clearable
size="small"
style="width:200px"
@input="onKeyword"
@clear="load"
/>
<span class="total-hint"> {{ total }} </span>
</div>
<!-- 表格 -->
<div class="data-panel">
<el-table :data="items" v-loading="loading" size="small" style="width:100%" border>
<el-table-column type="index" label="#" width="52" align="center">
<template #default="{ $index }"><span class="row-index">{{ filters.skip + $index + 1 }}</span></template>
</el-table-column>
<el-table-column label="更换时间" width="165">
<template #default="{ row }">
<span class="mono-val small">{{ fmtTime(row.replaced_at) }}</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="school_name" label="学校" min-width="140" show-overflow-tooltip />
<el-table-column label="楼宇/房间" width="130" show-overflow-tooltip>
<template #default="{ row }">
<span style="font-size:12px;color:var(--text-muted)">
{{ [row.building, row.room_number].filter(Boolean).join(' / ') || '—' }}
</span>
</template>
</el-table-column>
<el-table-column label="旧 MAC" width="145">
<template #default="{ row }">
<span class="mono-val mac old-mac">{{ row.old_mac }}</span>
</template>
</el-table-column>
<el-table-column label="新 MAC" width="145">
<template #default="{ row }">
<span class="mono-val mac new-mac">{{ row.new_mac }}</span>
</template>
</el-table-column>
<el-table-column label="更换原因" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.reason" style="font-size:12px">{{ row.reason }}</span>
<span v-else style="color:var(--text-muted);font-size:12px"></span>
</template>
</el-table-column>
<el-table-column prop="operator_name" label="操作人" width="110">
<template #default="{ row }">
<span style="font-size:12px">{{ row.operator_name || '—' }}</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-bar">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[50, 100, 200]"
layout="total, sizes, prev, pager, next"
size="small"
@size-change="load"
@current-change="load"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage } from '../utils/message'
import request from '../utils/request'
import { fmtTime } from '../utils/datetime'
const regions = ref([])
const items = ref([])
const total = ref(0)
const loading = ref(false)
const exporting = ref(false)
const currentPage = ref(1)
const pageSize = ref(100)
const dateRange = ref(null)
const filters = ref({ region: '', keyword: '' })
let keywordTimer = null
const onKeyword = () => {
clearTimeout(keywordTimer)
keywordTimer = setTimeout(load, 400)
}
const buildParams = () => {
const p = {
skip: (currentPage.value - 1) * pageSize.value,
limit: pageSize.value,
}
if (filters.value.region) p.region = filters.value.region
if (filters.value.keyword) p.keyword = filters.value.keyword
if (dateRange.value?.[0]) p.start_date = dateRange.value[0]
if (dateRange.value?.[1]) p.end_date = dateRange.value[1]
return p
}
const load = async () => {
loading.value = true
try {
const { data } = await request.get('/devices/replacements', { params: buildParams() })
items.value = data.items
total.value = data.total
} catch {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
const exportCsv = async () => {
exporting.value = true
try {
const params = new URLSearchParams()
if (filters.value.region) params.set('region', filters.value.region)
if (filters.value.keyword) params.set('keyword', filters.value.keyword)
if (dateRange.value?.[0]) params.set('start_date', dateRange.value[0])
if (dateRange.value?.[1]) params.set('end_date', dateRange.value[1])
const url = `/api/devices/replacements/export?${params}`
const a = document.createElement('a')
a.href = url
a.download = ''
a.click()
} catch {
ElMessage.error('导出失败')
} finally {
exporting.value = false
}
}
onMounted(async () => {
try {
const { data } = await request.get('/devices/regions')
regions.value = data
} catch {}
load()
})
</script>
<style scoped>
.replacement-page {
padding: 24px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.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);
}
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.total-hint {
font-size: 12px;
color: var(--text-muted);
margin-left: 4px;
}
.data-panel {
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
overflow: hidden;
}
.pagination-bar {
padding: 12px 16px;
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--border-subtle);
}
.row-index {
color: var(--text-muted);
font-size: 12px;
}
.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;
}
.mono-val {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
}
.mono-val.small {
font-size: 12px;
}
.mac.old-mac {
color: var(--danger);
}
.mac.new-mac {
color: var(--success);
}
</style>
+2 -4
View File
@@ -160,6 +160,7 @@
import { ref, computed, onMounted } from 'vue'
import { getUsers, updateUserRole, toggleUser } from '../api/user'
import request from '../utils/request'
import { fmtTime as formatTime } from '../utils/datetime'
const users = ref([])
const total = ref(0)
@@ -226,10 +227,7 @@ const roleOptions = [
const roleLabel = (role) => roleOptions.find(r => r.value === role)?.label || role
const formatTime = (t) => {
if (!t) return '—'
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
// formatTime 从 utils/datetime 导入,已在文件顶部
const loadRegions = async () => {
try {