feat: 接入系统日志页面

This commit is contained in:
sunlei 2026-06-04 13:13:26 +08:00
parent d2af27dd9a
commit 3f101ac528
7 changed files with 569 additions and 0 deletions

View File

@ -59,6 +59,7 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'SystemKtTableDemoCreate', 'SystemKtTableDemoCreate',
'SystemKtTableDemoDelete', 'SystemKtTableDemoDelete',
'SystemKtTableDemoEdit', 'SystemKtTableDemoEdit',
'SystemLog',
'SystemMenu', 'SystemMenu',
'SystemMenuCreate', 'SystemMenuCreate',
'SystemMenuDelete', 'SystemMenuDelete',

View File

@ -1,5 +1,6 @@
export * from './dept'; export * from './dept';
export * from './dict'; export * from './dict';
export * from './log';
export * from './menu'; export * from './menu';
export * from './role'; export * from './role';
export * from './user'; export * from './user';

View File

@ -0,0 +1,71 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace SystemLogApi {
export type LogLevel = 'critical' | 'debug' | 'error' | 'info' | 'warning';
export interface LogItem {
context?: string;
durationMs?: number;
hostname?: string;
id: string;
level: LogLevel | string;
message: string;
method?: string;
path?: string;
raw: string;
requestId?: string;
statusCode?: number;
timestamp: string;
timestampNs: string;
}
export interface LogSummary {
count: number;
level: LogLevel;
}
export interface LogStatus {
app: string;
configured: boolean;
env: string;
host?: string;
selector: string;
}
export interface PageResult<T> {
items: T[];
total: number;
}
}
async function getSystemLogList(params: Recordable<any>) {
return requestClient.get<SystemLogApi.PageResult<SystemLogApi.LogItem>>(
'/system/logs',
{ params },
);
}
async function getSystemLogSummary(params: Recordable<any>) {
return requestClient.get<SystemLogApi.LogSummary[]>('/system/logs/summary', {
params,
});
}
async function getSystemLogLevels() {
return requestClient.get<
Array<{ label: string; value: SystemLogApi.LogLevel }>
>('/system/logs/levels');
}
async function getSystemLogStatus() {
return requestClient.get<SystemLogApi.LogStatus>('/system/logs/status');
}
export {
getSystemLogLevels,
getSystemLogList,
getSystemLogStatus,
getSystemLogSummary,
};

View File

@ -85,6 +85,32 @@
"ktTableDemo": { "ktTableDemo": {
"title": "Table Demo" "title": "Table Demo"
}, },
"log": {
"app": "App",
"configured": "Loki Connected",
"context": "Context",
"durationMs": "Duration",
"emptyStatus": "Loki query host is not configured",
"env": "Env",
"detail": "Detail",
"host": "Loki Host",
"keyword": "Keyword",
"level": "Level",
"message": "Message",
"method": "Method",
"path": "Path",
"raw": "Raw Log",
"rangeMinutes": "Last N Minutes",
"requestId": "Request ID",
"selector": "Selector",
"statusCode": "Status",
"summary": "Level Summary",
"time": "Time",
"timeRange": "Time Range",
"title": "System Logs",
"total": "Total",
"unconfigured": "Loki Not Configured"
},
"role": { "role": {
"title": "Role Management", "title": "Role Management",
"list": "Role List", "list": "Role List",

View File

@ -87,6 +87,32 @@
"ktTableDemo": { "ktTableDemo": {
"title": "表格演示" "title": "表格演示"
}, },
"log": {
"app": "应用",
"configured": "Loki 已接入",
"context": "上下文",
"durationMs": "耗时",
"emptyStatus": "未配置 Loki 查询地址",
"env": "环境",
"detail": "详情",
"host": "Loki 地址",
"keyword": "关键字",
"level": "级别",
"message": "消息",
"method": "方法",
"path": "路径",
"raw": "原始日志",
"rangeMinutes": "近 N 分钟",
"requestId": "请求 ID",
"selector": "选择器",
"statusCode": "状态码",
"summary": "级别统计",
"time": "时间",
"timeRange": "时间范围",
"title": "系统日志",
"total": "总量",
"unconfigured": "Loki 未配置"
},
"role": { "role": {
"title": "角色管理", "title": "角色管理",
"list": "角色列表", "list": "角色列表",

View File

@ -57,6 +57,15 @@ const routes: RouteRecordRaw[] = [
}, },
component: () => import('#/views/system/dept/list.vue'), component: () => import('#/views/system/dept/list.vue'),
}, },
{
path: '/system/logs',
name: 'SystemLog',
meta: {
icon: 'lucide:scroll-text',
title: $t('system.log.title'),
},
component: () => import('#/views/system/log/list.vue'),
},
{ {
path: '/system/ktTableDemo', path: '/system/ktTableDemo',
name: 'SystemKtTableDemo', name: 'SystemKtTableDemo',

View File

@ -0,0 +1,435 @@
<script lang="ts" setup>
import type { TableColumnType } from 'antdv-next';
import type { SystemLogApi } from '#/api/system/log';
import type {
KtTableApi,
KtTableContext,
KtTablePageResult,
KtTableRowAction,
} from '#/components/ktTable';
import { computed, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Drawer, Tag } from 'antdv-next';
import {
getSystemLogLevels,
getSystemLogList,
getSystemLogStatus,
getSystemLogSummary,
} from '#/api/system/log';
import { KtTable, useKtTable } from '#/components/ktTable';
import { $t } from '#/locales';
const levelColorMap: Record<string, string> = {
critical: 'magenta',
debug: 'default',
error: 'error',
info: 'processing',
warning: 'warning',
};
const fallbackLevelOptions: Array<{
label: string;
value: SystemLogApi.LogLevel;
}> = [
{ label: 'debug', value: 'debug' },
{ label: 'info', value: 'info' },
{ label: 'warning', value: 'warning' },
{ label: 'error', value: 'error' },
{ label: 'critical', value: 'critical' },
];
const levelOptions = ref(fallbackLevelOptions);
const summary = ref<SystemLogApi.LogSummary[]>([]);
const status = ref<SystemLogApi.LogStatus>();
const detailOpen = ref(false);
const detailRecord = ref<SystemLogApi.LogItem>();
const summaryTotal = computed(() =>
summary.value.reduce((total, item) => total + Number(item.count || 0), 0),
);
const columns: Array<TableColumnType<SystemLogApi.LogItem>> = [
{
dataIndex: 'timestamp',
fixed: 'left',
key: 'timestamp',
title: $t('system.log.time'),
width: 190,
},
{
align: 'center',
dataIndex: 'level',
key: 'level',
title: $t('system.log.level'),
width: 110,
},
{
dataIndex: 'message',
key: 'message',
title: $t('system.log.message'),
width: 420,
},
{
dataIndex: 'context',
key: 'context',
title: $t('system.log.context'),
width: 180,
},
{
align: 'center',
dataIndex: 'method',
key: 'method',
title: $t('system.log.method'),
width: 100,
},
{
dataIndex: 'path',
key: 'path',
title: $t('system.log.path'),
width: 260,
},
{
align: 'center',
dataIndex: 'statusCode',
key: 'statusCode',
title: $t('system.log.statusCode'),
width: 110,
},
{
align: 'right',
dataIndex: 'durationMs',
key: 'durationMs',
title: $t('system.log.durationMs'),
width: 110,
},
{
dataIndex: 'requestId',
key: 'requestId',
title: $t('system.log.requestId'),
width: 220,
},
];
const api: KtTableApi<SystemLogApi.LogItem> = {
list: async (params) => await getSystemLogList(params),
};
const rowActions: Array<KtTableRowAction<SystemLogApi.LogItem>> = [
{
key: 'detail',
label: $t('system.log.detail'),
onClick: onDetail,
permissionCodes: ['System:Log:List'],
},
];
const [registerTable] = useKtTable<SystemLogApi.LogItem>({
afterFetch: onAfterFetch,
api,
columns,
formOptions: {
fieldMappingTime: [
['logTime', ['startTime', 'endTime'], 'YYYY-MM-DD HH:mm:ss'],
],
schema: [
{
component: 'Select',
componentProps: () => ({
allowClear: true,
options: levelOptions.value,
}),
fieldName: 'level',
label: $t('system.log.level'),
},
{
component: 'Input',
componentProps: {
allowClear: true,
},
fieldName: 'keyword',
label: $t('system.log.keyword'),
},
{
component: 'Input',
componentProps: {
allowClear: true,
},
fieldName: 'context',
label: $t('system.log.context'),
},
{
component: 'Input',
componentProps: {
allowClear: true,
},
fieldName: 'path',
label: $t('system.log.path'),
},
{
component: 'Input',
componentProps: {
allowClear: true,
},
fieldName: 'requestId',
label: $t('system.log.requestId'),
},
{
component: 'RangePicker',
fieldName: 'logTime',
label: $t('system.log.timeRange'),
},
{
component: 'InputNumber',
componentProps: {
class: 'w-full',
min: 1,
precision: 0,
},
defaultValue: 60,
fieldName: 'rangeMinutes',
label: $t('system.log.rangeMinutes'),
},
],
},
pageSize: 20,
rowActions,
rowKey: 'id',
showSelection: false,
tableTitle: $t('system.log.title'),
});
onMounted(async () => {
await Promise.all([loadStatus(), loadLevels(), refreshSummary()]);
});
function getLevelColor(level: string) {
return levelColorMap[level] || 'default';
}
function getStatusColor(statusCode?: number) {
if (!statusCode) return 'default';
if (statusCode >= 500) return 'error';
if (statusCode >= 400) return 'warning';
if (statusCode >= 300) return 'processing';
return 'success';
}
function getSummaryCount(level: string) {
return summary.value.find((item) => item.level === level)?.count || 0;
}
async function loadStatus() {
status.value = await getSystemLogStatus();
}
async function loadLevels() {
const options = await getSystemLogLevels();
levelOptions.value = options.length > 0 ? options : fallbackLevelOptions;
}
async function refreshSummary(params: Record<string, any> = {}) {
summary.value = await getSystemLogSummary(params);
}
async function onAfterFetch(
result: KtTablePageResult<SystemLogApi.LogItem> | SystemLogApi.LogItem[],
context: KtTableContext<SystemLogApi.LogItem>,
) {
await refreshSummary(await context.getSearchValues());
return result;
}
function onDetail(row: SystemLogApi.LogItem) {
detailRecord.value = row;
detailOpen.value = true;
}
</script>
<template>
<Page auto-content-height>
<div class="system-log-page">
<section class="system-log-page__status">
<div class="system-log-page__status-main">
<Tag :color="status?.configured ? 'success' : 'warning'">
{{
status?.configured
? $t('system.log.configured')
: $t('system.log.unconfigured')
}}
</Tag>
<span>{{ status?.app || '-' }}</span>
<span>{{ status?.env || '-' }}</span>
<span class="system-log-page__muted">
{{ status?.selector || $t('system.log.emptyStatus') }}
</span>
</div>
<div class="system-log-page__host">
{{ status?.host || '-' }}
</div>
</section>
<section class="system-log-page__summary">
<div class="system-log-page__summary-item">
<span>{{ $t('system.log.total') }}</span>
<strong>{{ summaryTotal }}</strong>
</div>
<div
v-for="item in levelOptions"
:key="item.value"
class="system-log-page__summary-item"
>
<Tag :color="getLevelColor(item.value)">
{{ item.label }}
</Tag>
<strong>{{ getSummaryCount(item.value) }}</strong>
</div>
</section>
<KtTable @register="registerTable">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'level'">
<Tag :color="getLevelColor(record.level)">
{{ record.level }}
</Tag>
</template>
<template v-else-if="column.key === 'statusCode'">
<Tag :color="getStatusColor(record.statusCode)">
{{ record.statusCode || '-' }}
</Tag>
</template>
<template v-else-if="column.key === 'durationMs'">
{{
record.durationMs === undefined ? '-' : `${record.durationMs} ms`
}}
</template>
<template v-else-if="column.key === 'message'">
<span class="system-log-page__message" :title="record.message">
{{ record.message }}
</span>
</template>
</template>
</KtTable>
</div>
<Drawer v-model:open="detailOpen" :size="720" :title="$t('system.log.raw')">
<dl v-if="detailRecord" class="system-log-page__detail">
<dt>{{ $t('system.log.time') }}</dt>
<dd>{{ detailRecord.timestamp }}</dd>
<dt>{{ $t('system.log.level') }}</dt>
<dd>
<Tag :color="getLevelColor(detailRecord.level)">
{{ detailRecord.level }}
</Tag>
</dd>
<dt>{{ $t('system.log.context') }}</dt>
<dd>{{ detailRecord.context || '-' }}</dd>
<dt>{{ $t('system.log.requestId') }}</dt>
<dd>{{ detailRecord.requestId || '-' }}</dd>
<dt>{{ $t('system.log.path') }}</dt>
<dd>{{ detailRecord.path || '-' }}</dd>
</dl>
<pre class="system-log-page__raw">{{ detailRecord?.raw || '' }}</pre>
</Drawer>
</Page>
</template>
<style scoped>
.system-log-page {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
min-height: 0;
}
.system-log-page__status,
.system-log-page__summary {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
padding: 10px 12px;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.system-log-page__status {
justify-content: space-between;
}
.system-log-page__status-main {
display: flex;
flex: 1;
gap: 10px;
align-items: center;
min-width: 0;
}
.system-log-page__host,
.system-log-page__muted {
min-width: 0;
overflow: hidden;
color: hsl(var(--muted-foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
.system-log-page__summary-item {
display: inline-flex;
gap: 8px;
align-items: center;
min-height: 30px;
padding: 0 10px;
border-right: 1px solid hsl(var(--border));
}
.system-log-page__summary-item:last-child {
border-right: 0;
}
.system-log-page__summary-item strong {
font-variant-numeric: tabular-nums;
}
.system-log-page__message {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.system-log-page__detail {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
gap: 10px 12px;
margin: 0 0 12px;
}
.system-log-page__detail dt {
color: hsl(var(--muted-foreground));
}
.system-log-page__detail dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.system-log-page__raw {
min-height: 280px;
padding: 12px;
overflow: auto;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
background: hsl(var(--muted));
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
</style>