234 lines
6.8 KiB
Vue
234 lines
6.8 KiB
Vue
<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>
|