初始提交: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
+277
View File
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { NButton, NDataTable, NModal, NSelect, useMessage, type DataTableColumns } from 'naive-ui'
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
import { getAuditEventDetail, listAuditEvents } from '@/api/tenant'
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 { eventLabel } from '@/composables/useEventLabel'
import { fmtTime } from '@/composables/useFormat'
import type { AuditEvent } from '@/types/api'
const props = defineProps<{ cfgId: number }>()
const message = useMessage()
const hours = ref(24)
const hourOptions = [
{ label: '近 1 小时', value: 1 },
{ label: '近 6 小时', value: 6 },
{ label: '近 24 小时', value: 24 },
{ label: '近 72 小时', value: 72 },
]
/** 受控分页:字面量对象会在重渲染时被重建导致页大小切换失效 */
const pagination = reactive({
page: 1,
pageSize: 20,
showSizePicker: true,
pageSizes: [20, 50, 100],
onUpdatePage: (p: number) => {
pagination.page = p
},
onUpdatePageSize: (ps: number) => {
pagination.pageSize = ps
pagination.page = 1
},
})
const loading = ref(false)
const loadingMore = ref(false)
const error = ref('')
const rows = ref<AuditEvent[]>([])
const truncated = ref(false)
const nextPage = ref('')
// 本次查询的绝对时间窗:续查游标绑定查询参数,必须原样带回
const win = ref<{ start: string; end: string } | null>(null)
async function load() {
loading.value = true
error.value = ''
try {
const r = await listAuditEvents(props.cfgId, { hours: hours.value })
rows.value = r.items
truncated.value = r.truncated
nextPage.value = r.nextPage ?? ''
win.value = { start: r.start, end: r.end }
pagination.page = 1
} catch (e) {
error.value = e instanceof Error ? e.message : '查询失败'
} finally {
loading.value = false
}
}
/** 截断续查:同一绝对窗带游标断点续翻,追加去重后按时间重排 */
async function loadMore() {
if (!win.value || !nextPage.value || loadingMore.value) return
loadingMore.value = true
try {
const r = await listAuditEvents(props.cfgId, { ...win.value, page: nextPage.value })
const seen = new Set(rows.value.map((e) => e.eventId))
rows.value = [...rows.value, ...r.items.filter((e) => !seen.has(e.eventId))].sort((a, b) =>
(b.eventTime ?? '').localeCompare(a.eventTime ?? ''),
)
truncated.value = r.truncated
nextPage.value = r.nextPage ?? ''
} catch (e) {
// 追加失败不清空已有列表;游标过期时提示重新查询
message.error(e instanceof Error ? e.message : '继续加载失败,请刷新重新查询')
} finally {
loadingMore.value = false
}
}
onMounted(() => void load())
// 切换时间窗自动重查并回到第一页
watch(hours, () => void load())
// ---- 行点击详情:原始 JSON 懒取(命中服务端缓存秒开,过期走小窗重查) ----
const detail = ref<AuditEvent | null>(null)
const showDetail = ref(false)
const detailRaw = ref<unknown>(null)
const rawLoading = ref(false)
const rawGone = ref(false)
function rowProps(row: AuditEvent) {
return {
style: 'cursor: pointer',
onClick: () => {
detail.value = row
showDetail.value = true
void loadRaw(row)
},
}
}
async function loadRaw(row: AuditEvent) {
detailRaw.value = null
rawGone.value = false
if (!row.eventId || !row.eventTime) {
rawGone.value = true
return
}
rawLoading.value = true
try {
const { raw } = await getAuditEventDetail(props.cfgId, {
eventId: row.eventId,
eventTime: row.eventTime,
})
detailRaw.value = raw
} catch {
rawGone.value = true
} finally {
rawLoading.value = false
}
}
/** 摘要头 chips:操作者 / IP / 时间;完整长值仍在字段网格与原始 JSON 中 */
const heroChips = computed<HeroChip[]>(() => {
const d = detail.value
if (!d) return []
return [
{ label: '操作者', value: d.principalName || '—' },
{ label: 'IP', value: d.ipAddress || '—', mono: true },
{ label: '时间', value: fmtTime(d.eventTime) },
]
})
/** 字段网格:头部未覆盖的补充字段;空值以「—」占位 */
const detailRows = computed<DetailRow[]>(() => {
const d = detail.value
if (!d) return []
return [
{ label: '资源', value: d.resourceName, mono: true },
{ label: '区间', value: d.compartmentName },
{ label: '请求', value: `${d.requestAction} ${d.requestPath}`.trim(), mono: true },
{ label: '来源', value: d.source },
]
})
/** 主文本 + 次行小字的两行单元格;空值以「—」占位 */
function twoLine(main: string, sub: string) {
return h('div', { class: 'min-w-0' }, [
h('div', { class: 'truncate text-[13px]' }, main || '—'),
h('div', { class: 'mt-px truncate text-xs text-ink-3' }, sub || '—'),
])
}
const columns: DataTableColumns<AuditEvent> = [
{
title: '时间',
key: 'eventTime',
width: 145,
render: (row) =>
h('span', { class: 'text-[13px] text-ink-2 tabular-nums whitespace-nowrap' }, fmtTime(row.eventTime)),
},
{
title: '操作',
key: 'eventName',
minWidth: 200,
render: (row) => twoLine(row.eventName, row.source),
},
{
// 固定列宽让 truncate 生效:资源路径可长达数百字符,完整值点行查看
title: '资源',
key: 'resourceName',
width: 200,
render: (row) => twoLine(row.resourceName, row.compartmentName),
},
{
title: '操作者',
key: 'principalName',
width: 180,
render: (row) => twoLine(row.principalName, row.ipAddress),
},
{
title: '结果',
key: 'status',
width: 90,
render: (row) => {
if (!row.status) return h('span', { class: 'text-[13px] text-ink-3' }, '—')
const failed = Number(row.status) >= 400
return h(StatusBadge, { kind: failed ? 'term' : 'run', label: row.status, dot: false })
},
},
]
</script>
<template>
<div class="panel">
<div class="flex flex-wrap items-center gap-2.5 border-b border-line-soft px-4.5 py-3">
<div class="text-sm font-semibold">审计日志</div>
<template v-if="truncated">
<StatusBadge kind="warn" label="结果已截断" />
<NButton v-if="nextPage" size="tiny" :loading="loadingMore" @click="loadMore">
继续加载更早
</NButton>
</template>
<div class="flex-1" />
<div class="w-32 max-md:w-full">
<NSelect v-model:value="hours" size="small" :options="hourOptions" />
</div>
<NButton size="small" :loading="loading" @click="load()">刷新</NButton>
</div>
<div
v-if="error"
class="px-5 py-10 text-center text-[13px] text-ink-3"
>
{{ error }}
</div>
<NDataTable
v-else
:columns="columns"
:data="rows"
:loading="loading"
:pagination="pagination"
:scroll-x="820"
:max-height="480"
:row-props="rowProps"
/>
<FootNote>
OCI Audit 免费且默认开启事件保留 365 无需在云端做任何配置 ·
实时查询不入库事件通常延迟数分钟可见 ·
已默认过滤遥测噪声SummarizeMetricsData与内网地址发起的服务互调 ·
单次最多翻 10 截断时点继续加载更早断点续查 · 点击行查看完整详情
</FootNote>
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '720px', maxWidth: 'calc(100vw - 24px)' }">
<template #header>
<DetailHero
v-if="detail"
kind="审计事件 · OCI Audit"
:title="eventLabel(detail.eventName) || detail.eventName"
:sub="`${detail.eventName} · ${detail.source}`"
:chips="heroChips"
>
<StatusBadge
v-if="detail.status"
:kind="Number(detail.status) >= 400 ? 'term' : 'run'"
:label="`${Number(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
v-if="detailRaw"
:value="detailRaw"
title="原始 JSON"
meta="来自 Audit SDK"
max-height="36vh"
wide
/>
<div v-else-if="rawLoading" class="rounded-[10px] border border-line-soft px-3.5 py-3 text-xs text-ink-3">
正在取回原始事件
</div>
<div v-else-if="rawGone" class="rounded-[10px] border border-line-soft px-3.5 py-3 text-xs text-ink-3">
原始事件已不可取回超出缓存与重查窗口以上为列表精简字段
</div>
</div>
</NModal>
</div>
</template>