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:
@@ -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>
|
||||
Reference in New Issue
Block a user