Files
H3ConuMS-v2/frontend/src/views/ReplacementRecords.vue
T
v6ole 5aadbc78c6 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>
2026-05-12 10:36:06 +08:00

278 lines
7.6 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="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>