Files
Nexus/frontend/src/pages/AuditPage.vue
T
Nexus Agent abeab9d3e4 Fix recovery Telegram dedup and unify Beijing time display.
Store alert metrics without per-value Redis members to stop duplicate recovery pushes; format all operator-facing timestamps in Asia/Shanghai across Web UI and Telegram.
2026-06-08 23:18:56 +08:00

196 lines
6.2 KiB
Vue

<template>
<v-container fluid class="pa-6">
<v-card elevation="0" border rounded="lg">
<v-card-title class="d-flex align-center">
审计日志
<v-spacer />
<v-select
v-model="actionFilter"
:items="actionOptions"
item-title="label"
item-value="value"
label="操作类型"
variant="outlined"
density="compact"
hide-details
style="max-width: 200px"
clearable
@update:model-value="page = 1; loadAudit()"
/>
<v-text-field
v-model="userFilter"
prepend-inner-icon="mdi-account"
label="用户"
variant="outlined"
density="compact"
hide-details
style="max-width: 140px"
class="ml-3"
clearable
@update:model-value="page = 1; loadAudit()"
/>
<v-text-field
v-model="dateFilter"
prepend-inner-icon="mdi-calendar"
label="日期"
variant="outlined"
density="compact"
hide-details
style="max-width: 160px"
class="ml-3"
type="date"
clearable
@update:model-value="page = 1; loadAudit()"
/>
</v-card-title>
<v-data-table-server
:items="logs"
:headers="headers"
:items-length="total"
:loading="loading"
:page="page"
:items-per-page="50"
hover
density="compact"
@update:page="page = $event; loadAudit()"
>
<template #item.action="{ item }">
<v-chip :color="actionColor(item.action)" size="small" variant="tonal" label>
{{ auditActionLabel(item.action) }}
</v-chip>
</template>
<template #item.admin_username="{ item }">
<span class="font-weight-medium">{{ item.admin_username }}</span>
</template>
<template #item.target="{ item }">
<span class="text-body-2">
{{ auditTargetLabel(item.resource_type) }}{{ item.resource_id ? ` #${item.resource_id}` : '' }}
</span>
</template>
<template #item.detail="{ item }">
<div class="d-flex align-center ga-2 flex-wrap">
<span
class="text-body-2 text-medium-emphasis"
style="max-width: 360px"
>
{{ auditDetailText(item) }}
</span>
<v-btn
v-if="auditBatchMeta(item)"
size="x-small"
variant="tonal"
color="primary"
@click.stop="openBatchResult(auditBatchMeta(item)!)"
>
查看结果
</v-btn>
</div>
</template>
<template #item.ip_address="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.ip_address || '—' }}</span>
</template>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.created_at) }}</span>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
</template>
</v-data-table-server>
</v-card>
</v-container>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import type { PaginatedResponse, AuditItem } from '@/types/api'
import { auditActionLabel, auditTargetLabel, auditActionFilterOptions } from '@/utils/auditLabels'
import {
formatAuditBatchDetailText,
isBatchJobAuditAction,
parseAuditBatchDetail,
type AuditBatchJobDetail,
} from '@/utils/auditBatchDetail'
import { openServerBatchJobResult } from '@/composables/useServerBatchJobViewer'
import { normalizeAuditList } from '@/utils/auditNormalize'
import { headersWithoutSort } from '@/constants/dataTable'
import { formatDateTimeBeijing } from '@/utils/datetime'
defineOptions({ name: 'AuditPage' })
const snackbar = useSnackbar()
// ── State ──
const loading = ref(false)
const logs = ref<AuditItem[]>([])
const total = ref(0)
const page = ref(1)
const actionFilter = ref<string | null>(null)
const userFilter = ref('')
const dateFilter = ref('')
const actionOptions = auditActionFilterOptions
const headers = headersWithoutSort([
{ title: '操作', key: 'action', width: 100 },
{ title: '用户', key: 'admin_username', width: 100 },
{ title: '目标', key: 'target', width: 140 },
{ title: '详情', key: 'detail' },
{ title: 'IP', key: 'ip_address', width: 120 },
{ title: '时间', key: 'created_at', width: 160 },
])
// ── Data loading ──
async function loadAudit(silent = false) {
if (!silent) loading.value = true
try {
const limit = 50
const offset = (page.value - 1) * limit
const res = await http.get<PaginatedResponse<unknown>>('/audit/', {
limit,
offset,
action: actionFilter.value || undefined,
admin_username: userFilter.value || undefined,
date_from: dateFilter.value || undefined,
})
logs.value = normalizeAuditList(res.items || [])
total.value = res.total || 0
} catch { logs.value = []; if (!silent) snackbar('加载审计日志失败', 'error') }
finally { if (!silent) loading.value = false }
}
// ── Helpers ──
function actionColor(a: string) {
if (a.startsWith('create') || a === 'login') return 'success'
if (a.startsWith('update')) return 'info'
if (a.startsWith('delete') || a === 'logout') return 'error'
if (a.includes('execute') || a.includes('push') || a.includes('retry')) return 'warning'
return 'primary'
}
function auditBatchMeta(item: AuditItem): AuditBatchJobDetail | null {
if (!isBatchJobAuditAction(item.action)) return null
return parseAuditBatchDetail(item.detail)
}
function auditDetailText(item: AuditItem): string {
const parsed = auditBatchMeta(item)
if (parsed) return formatAuditBatchDetailText(parsed)
return (item.detail || '').trim() || '—'
}
function openBatchResult(meta: AuditBatchJobDetail) {
void openServerBatchJobResult(meta.job_id, meta.summary)
}
usePageActivateRefresh((silent) => loadAudit(silent))
</script>