5aadbc78c6
- 新增 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>
278 lines
7.6 KiB
Vue
278 lines
7.6 KiB
Vue
<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>
|