初始提交:OCI 面板前端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:29 +08:00
commit db45a669e3
135 changed files with 25220 additions and 0 deletions
+219
View File
@@ -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>