abeab9d3e4
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.
196 lines
6.2 KiB
Vue
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>
|