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
+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>