Files
oci-portal-dash/src/components/logs/SystemLogPanel.vue
T
2026-07-22 16:51:45 +08:00

234 lines
6.8 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.
<script setup lang="ts">
import { NDataTable, NInput, NModal, type DataTableColumns } from 'naive-ui'
import { computed, h, reactive, ref } from 'vue'
import { listSystemLogs } from '@/api/settings'
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
import JsonBlock from '@/components/JsonBlock.vue'
import PanelHeader from '@/components/PanelHeader.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { actionLabel } from '@/composables/useActionLabel'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
import { fmtTime } from '@/composables/useFormat'
import { useIsMobile } from '@/composables/useIsMobile'
import { useMobileModal } from '@/composables/useMobileModal'
import type { SystemLog } from '@/types/api'
import { useToast } from '@/composables/useToast'
const isMobile = useIsMobile()
const { modalStyle, modalClass } = useMobileModal(640)
const message = useToast()
const rows = ref<SystemLog[]>([])
const loading = ref(false)
const keyword = ref('')
// 已生效的关键字:回车 / 清除时更新,翻页沿用
const applied = ref('')
/** 受控远程分页:字面量对象会在重渲染时被重建导致状态丢失 */
const pagination = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
showSizePicker: true,
pageSizes: [20, 50, 100],
// 移动端页码槽位放不下会被裁切,切 simple 形态(‹ n/总页 )
get simple() {
return isMobile.value
},
onUpdatePage: (p: number) => {
pagination.page = p
void load()
},
onUpdatePageSize: (ps: number) => {
pagination.pageSize = ps
pagination.page = 1
void load()
},
})
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
// 不得覆盖已切换的新页
let loadSeq = 0
async function load(silent = false) {
const seq = ++loadSeq
if (!silent) loading.value = true
try {
const data = await listSystemLogs({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: applied.value || undefined,
})
if (seq !== loadSeq) return
rows.value = data.items
pagination.itemCount = data.total
} catch (e) {
if (!silent) message.error(e instanceof Error ? e.message : '加载系统日志失败')
} finally {
if (!silent && seq === loadSeq) loading.value = false
}
}
function search() {
applied.value = keyword.value.trim()
pagination.page = 1
void load()
}
function onClear() {
keyword.value = ''
search()
}
// 行点击详情:与回传/审计日志一致的「字段 + 原始 JSON」风格
const detail = ref<SystemLog | null>(null)
const showDetail = ref(false)
function rowProps(row: SystemLog) {
return {
style: 'cursor: pointer',
onClick: () => {
detail.value = row
showDetail.value = true
},
}
}
/** 摘要头 chips:用户 / IP / 时间 */
const heroChips = computed<HeroChip[]>(() => {
const d = detail.value
if (!d) return []
return [
{ label: '用户', value: d.username || '—' },
{ label: 'IP', value: d.clientIp, mono: true },
{ label: '时间', value: fmtTime(d.createdAt) },
]
})
/** 字段网格:头部未覆盖的补充字段;失败原因红字置顶 */
const detailRows = computed<DetailRow[]>(() => {
const d = detail.value
if (!d) return []
return [
...(d.errMsg ? [{ label: '失败原因', value: d.errMsg, tone: 'err' as const, span: 2 as const }] : []),
{ label: 'User-Agent', value: d.userAgent, mono: true, span: 2 as const, ellipsis: true },
]
})
const columns: DataTableColumns<SystemLog> = [
{
title: '时间',
key: 'createdAt',
width: 150,
render: (row) =>
h('span', { class: 'text-[13px] whitespace-nowrap text-ink-2' }, fmtTime(row.createdAt)),
},
{
title: '用户',
key: 'username',
width: 110,
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'text-[13px]' }, row.username || '—'),
},
{
// 主行中文动作、副行原始接口,可读性与可检索性兼顾
title: '动作',
key: 'path',
minWidth: 280,
render: (row) =>
h('div', { class: 'min-w-0' }, [
h('div', { class: 'truncate text-[13px]' }, actionLabel(row.method, row.path) || '—'),
h('div', { class: 'mono mt-px truncate text-xs text-ink-3' }, `${row.method} ${row.path}`),
]),
},
{
title: '状态',
key: 'status',
width: 70,
render: (row) =>
h(
'span',
{ class: ['text-[13px] tabular-nums', row.status >= 400 ? 'text-err' : ''] },
String(row.status),
),
},
{
title: '耗时',
key: 'durationMs',
width: 90,
render: (row) =>
h('span', { class: 'text-[13px] tabular-nums text-ink-2' }, `${row.durationMs} ms`),
},
{
title: 'IP',
key: 'clientIp',
width: 140,
render: (row) => h('span', { class: 'mono text-[12.5px] text-ink-2' }, row.clientIp),
},
]
void load()
useAutoRefresh(() => load(true))
</script>
<template>
<div class="panel">
<PanelHeader title="系统日志" desc="面板写操作与登录成败留痕,用于审计与排障">
<NInput
v-if="!isMobile"
v-model:value="keyword"
size="small"
clearable
placeholder="搜索路径 / 用户名"
class="!w-64"
@keyup.enter="search"
@clear="onClear"
/>
</PanelHeader>
<!-- 移动端筛选独立成行全宽,不与标题挤同一行 -->
<div v-if="isMobile" class="border-b border-line-soft px-4.5 py-2.5">
<NInput
v-model:value="keyword"
size="small"
clearable
placeholder="搜索路径 / 用户名"
@keyup.enter="search"
@clear="onClear"
/>
</div>
<NDataTable
remote
:columns="columns"
:data="rows"
:loading="loading"
:pagination="pagination"
:scroll-x="880"
:row-key="(r: SystemLog) => r.id"
:row-props="rowProps"
/>
<NModal v-model:show="showDetail" preset="card" closable :style="modalStyle" :class="modalClass">
<template #header>
<DetailHero
v-if="detail"
kind="系统日志 · 面板操作"
:title="actionLabel(detail.method, detail.path) || `${detail.method} ${detail.path}`"
:sub="`${detail.method} ${detail.path} · ${detail.durationMs} ms`"
:chips="heroChips"
>
<StatusBadge
:kind="detail.status >= 400 ? 'term' : 'run'"
:label="`${detail.status >= 400 ? '失败' : '成功'} ${detail.status}`"
:dot="false"
/>
</DetailHero>
</template>
<div v-if="detail" class="flex flex-col gap-3.5">
<DetailFieldList :rows="detailRows" :cols="2" />
<JsonBlock :value="detail" title="原始记录" meta="仅元数据,不含请求体" max-height="32vh" wide />
</div>
</NModal>
</div>
</template>