初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import { NDataTable, NInput, NModal, useMessage, 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 FootNote from '@/components/FootNote.vue'
|
||||
import JsonBlock from '@/components/JsonBlock.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { actionLabel } from '@/composables/useActionLabel'
|
||||
import { useAutoRefresh } from '@/composables/useAutoRefresh'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import type { SystemLog } from '@/types/api'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
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],
|
||||
onUpdatePage: (p: number) => {
|
||||
pagination.page = p
|
||||
void load()
|
||||
},
|
||||
onUpdatePageSize: (ps: number) => {
|
||||
pagination.pageSize = ps
|
||||
pagination.page = 1
|
||||
void load()
|
||||
},
|
||||
})
|
||||
|
||||
async function load(silent = false) {
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
const data = await listSystemLogs({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: applied.value || undefined,
|
||||
})
|
||||
rows.value = data.items
|
||||
pagination.itemCount = data.total
|
||||
} catch (e) {
|
||||
if (!silent) message.error(e instanceof Error ? e.message : '加载系统日志失败')
|
||||
} finally {
|
||||
if (!silent) 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">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">系统日志</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">面板写操作与登录成败留痕,用于审计与排障</div>
|
||||
</div>
|
||||
<NInput
|
||||
v-model:value="keyword"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="搜索路径 / 用户名,回车确认"
|
||||
class="max-w-60"
|
||||
@keyup.enter="search"
|
||||
@clear="onClear"
|
||||
/>
|
||||
</div>
|
||||
<NDataTable
|
||||
remote
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:scroll-x="880"
|
||||
:max-height="480"
|
||||
:row-key="(r: SystemLog) => r.id"
|
||||
:row-props="rowProps"
|
||||
/>
|
||||
<FootNote>
|
||||
自动记录 secured 接口的全部写请求(POST / PUT /
|
||||
DELETE)与登录成败,只存方法与路径等元数据、绝不记录请求体;失败请求另存响应错误文案与
|
||||
User-Agent;日志保留 90 天,总量超 5 万条时自动删除最旧记录 · 点击行查看详情
|
||||
</FootNote>
|
||||
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '640px', maxWidth: 'calc(100vw - 24px)' }">
|
||||
<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>
|
||||
Reference in New Issue
Block a user