feat: 新增系统日志记录、查看与自动清理,VO 转换改用 MapStruct
- 新增访问/登录日志表与拦截器,登录成功/失败均记录,接口访问同时输出到应用日志 - 新增 /api/log/* 查询与 SSE 实时推送,运行日志默认最近 1 小时并支持日期/级别/关键字筛选 - 新增日志定时清理,保留天数可配置(默认 30 天,0 为永久保留) - 前端新增系统日志页(独立 URL 子路径、标签偏好记忆),入口位于顶部用户下拉菜单 - 实体到视图对象的属性拷贝改用 MapStruct 编译期生成
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
// 系统日志相关 API 接口:访问日志、登录日志与后端运行日志
|
||||
import request from './request'
|
||||
|
||||
/** HTTP 访问日志 */
|
||||
export interface HttpLog {
|
||||
id: number
|
||||
userId: number | null
|
||||
method: string | null
|
||||
path: string | null
|
||||
queryString: string | null
|
||||
status: number | null
|
||||
durationMs: number | null
|
||||
ip: string | null
|
||||
userAgent: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 登录日志 */
|
||||
export interface LoginLog {
|
||||
id: number
|
||||
userId: number | null
|
||||
username: string | null
|
||||
success: boolean
|
||||
message: string | null
|
||||
ip: string | null
|
||||
userAgent: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 分页结果 */
|
||||
export interface LogPageResult<T> {
|
||||
records: T[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
/** 应用运行日志 */
|
||||
export interface AppLog {
|
||||
file: string
|
||||
total: number
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
/** 分页查询 HTTP 访问日志 */
|
||||
export function pageHttpLogs(params: {
|
||||
page: number
|
||||
size: number
|
||||
keyword?: string
|
||||
method?: string
|
||||
status?: number
|
||||
}) {
|
||||
return request.get('/log/http', { params })
|
||||
}
|
||||
|
||||
/** 分页查询登录日志 */
|
||||
export function pageLoginLogs(params: {
|
||||
page: number
|
||||
size: number
|
||||
keyword?: string
|
||||
success?: boolean
|
||||
}) {
|
||||
return request.get('/log/login', { params })
|
||||
}
|
||||
|
||||
/** 读取后端运行日志 */
|
||||
export function getAppLog(params: {
|
||||
date?: string
|
||||
level?: string
|
||||
keyword?: string
|
||||
lines: number
|
||||
since?: number
|
||||
}) {
|
||||
return request.get('/log/app', { params })
|
||||
}
|
||||
|
||||
/** 获取日志保留天数(0 表示永久保留) */
|
||||
export function getLogRetention() {
|
||||
return request.get('/app/log-retention')
|
||||
}
|
||||
|
||||
/** 更新日志保留天数(0 表示永久保留) */
|
||||
export function updateLogRetention(days: number) {
|
||||
return request.put('/app/log-retention', { days })
|
||||
}
|
||||
@@ -82,6 +82,7 @@ export default {
|
||||
transactions: { zh: '收支记录', en: 'Transactions', ja: '収支記録' },
|
||||
categories: { zh: '分类管理', en: 'Categories', ja: 'カテゴリ管理' },
|
||||
tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' },
|
||||
logs: { zh: '系统日志', en: 'Logs', ja: 'システムログ' },
|
||||
settings: { zh: '系统设置', en: 'Settings', ja: 'システム設定' },
|
||||
user: { zh: '用户', en: 'User', ja: 'ユーザー' },
|
||||
logout: { zh: '退出', en: 'Sign Out', ja: 'ログアウト' },
|
||||
@@ -216,6 +217,54 @@ export default {
|
||||
back: { zh: '返回设置', en: 'Back to Settings', ja: '設定に戻る' },
|
||||
},
|
||||
|
||||
// 系统日志
|
||||
log: {
|
||||
title: { zh: '系统日志', en: 'System Logs', ja: 'システムログ' },
|
||||
httpTab: { zh: '访问日志', en: 'Access Logs', ja: 'アクセスログ' },
|
||||
loginTab: { zh: '登录日志', en: 'Login Logs', ja: 'ログインログ' },
|
||||
appTab: { zh: '运行日志', en: 'Application Logs', ja: 'アプリログ' },
|
||||
keyword: { zh: '关键字', en: 'Keyword', ja: 'キーワード' },
|
||||
keywordPlaceholder: { zh: '路径 / 参数 / IP', en: 'Path / Query / IP', ja: 'パス / クエリ / IP' },
|
||||
method: { zh: '请求方法', en: 'Method', ja: 'メソッド' },
|
||||
status: { zh: '状态码', en: 'Status', ja: 'ステータス' },
|
||||
result: { zh: '结果', en: 'Result', ja: '結果' },
|
||||
level: { zh: '级别', en: 'Level', ja: 'レベル' },
|
||||
date: { zh: '日期', en: 'Date', ja: '日付' },
|
||||
datePlaceholder: { zh: 'yyyy-MM-dd,留空为当前', en: 'yyyy-MM-dd, blank for current', ja: 'yyyy-MM-dd、空欄で現在' },
|
||||
range1h: { zh: '最近1小时', en: 'Last 1h', ja: '直近1時間' },
|
||||
range6h: { zh: '最近6小时', en: 'Last 6h', ja: '直近6時間' },
|
||||
range24h: { zh: '最近24小时', en: 'Last 24h', ja: '直近24時間' },
|
||||
rangeAll: { zh: '全部', en: 'All', ja: 'すべて' },
|
||||
lines: { zh: '行数', en: 'Lines', ja: '行数' },
|
||||
file: { zh: '文件', en: 'File', ja: 'ファイル' },
|
||||
totalLines: { zh: '命中 {count} 行', en: '{count} line(s) matched', ja: '{count} 行が一致' },
|
||||
search: { zh: '查询', en: 'Search', ja: '検索' },
|
||||
refresh: { zh: '刷新', en: 'Refresh', ja: '更新' },
|
||||
realtime: { zh: '实时', en: 'Live', ja: 'リアルタイム' },
|
||||
time: { zh: '时间', en: 'Time', ja: '時刻' },
|
||||
path: { zh: '路径', en: 'Path', ja: 'パス' },
|
||||
queryString: { zh: '参数', en: 'Query', ja: 'クエリ' },
|
||||
duration: { zh: '耗时', en: 'Duration', ja: '所要時間' },
|
||||
ip: { zh: 'IP', en: 'IP', ja: 'IP' },
|
||||
user: { zh: '用户', en: 'User', ja: 'ユーザー' },
|
||||
username: { zh: '用户名', en: 'Username', ja: 'ユーザー名' },
|
||||
userAgent: { zh: 'User-Agent', en: 'User-Agent', ja: 'User-Agent' },
|
||||
message: { zh: '说明', en: 'Message', ja: '説明' },
|
||||
success: { zh: '成功', en: 'Success', ja: '成功' },
|
||||
failed: { zh: '失败', en: 'Failed', ja: '失敗' },
|
||||
anonymous: { zh: '未登录', en: 'Anonymous', ja: '未ログイン' },
|
||||
noData: { zh: '暂无日志', en: 'No logs', ja: 'ログがありません' },
|
||||
appEmpty: { zh: '暂无运行日志', en: 'No application logs', ja: 'アプリログがありません' },
|
||||
retention: { zh: '日志保留', en: 'Log retention', ja: 'ログ保持' },
|
||||
retention30: { zh: '30 天', en: '30 days', ja: '30日' },
|
||||
retention90: { zh: '90 天', en: '90 days', ja: '90日' },
|
||||
retention180: { zh: '180 天', en: '180 days', ja: '180日' },
|
||||
retentionForever: { zh: '永久保留', en: 'Forever', ja: '永久保持' },
|
||||
retentionSaved: { zh: '日志保留策略已保存', en: 'Log retention saved', ja: 'ログ保持を保存しました' },
|
||||
retentionFailed: { zh: '保存日志保留策略失败', en: 'Failed to save log retention', ja: 'ログ保持の保存に失敗しました' },
|
||||
loadFailed: { zh: '加载日志失败', en: 'Failed to load logs', ja: 'ログの読み込みに失敗しました' },
|
||||
},
|
||||
|
||||
// 统计面板
|
||||
dashboard: {
|
||||
title: { zh: '统计面板', en: 'Dashboard', ja: 'ダッシュボード' },
|
||||
|
||||
@@ -63,6 +63,20 @@ const router = createRouter({
|
||||
name: 'AiUsage',
|
||||
component: () => import('../views/AiUsage.vue'), // 模型使用情况
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
// 跳转到上次访问的日志标签页,无记录时默认访问日志
|
||||
redirect: () => {
|
||||
const tabs = ['http', 'login', 'app']
|
||||
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('logs.tab') : null
|
||||
return `/logs/${saved && tabs.includes(saved) ? saved : 'http'}`
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'logs/:tab(http|login|app)',
|
||||
name: 'Logs',
|
||||
component: () => import('../views/Logs.vue'), // 系统日志(访问日志/登录日志/运行日志)
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui'
|
||||
import type { MenuOption } from 'naive-ui'
|
||||
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, SettingsOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5'
|
||||
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5'
|
||||
import { useUserStore } from '../store/user'
|
||||
import LocaleSwitch from '../components/LocaleSwitch.vue'
|
||||
|
||||
@@ -45,13 +45,16 @@ function handleMenuUpdate(key: string) {
|
||||
|
||||
// 顶部用户下拉菜单配置项(随语言切换响应式更新)
|
||||
const userMenuOptions = computed(() => [
|
||||
{ label: t('layout.logs'), key: 'logs', icon: renderIcon(DocumentTextOutline) },
|
||||
{ label: t('layout.settings'), key: 'settings', icon: renderIcon(SettingsOutline) },
|
||||
{ label: t('layout.logout'), key: 'logout', icon: renderIcon(LogOutOutline) },
|
||||
])
|
||||
|
||||
/** 用户下拉菜单选择事件:跳转系统设置或退出登录 */
|
||||
/** 用户下拉菜单选择事件:跳转系统日志/系统设置或退出登录 */
|
||||
function handleUserMenuSelect(key: string) {
|
||||
if (key === 'settings') {
|
||||
if (key === 'logs') {
|
||||
router.push('/logs')
|
||||
} else if (key === 'settings') {
|
||||
router.push('/settings')
|
||||
} else if (key === 'logout') {
|
||||
handleLogout()
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
<script setup lang="ts">
|
||||
// 系统日志页:查看接口访问日志、登录日志与后端运行日志
|
||||
import { ref, computed, h, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
useMessage, NCard, NTabs, NTabPane, NDataTable, NPagination, NInput, NSelect,
|
||||
NButton, NSpace, NTag, NIcon, NInputNumber, NEmpty, NText, NSwitch,
|
||||
} from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { RefreshOutline, SearchOutline } from '@vicons/ionicons5'
|
||||
import { pageHttpLogs, pageLoginLogs, getAppLog, getLogRetention, updateLogRetention } from '../api/log'
|
||||
import type { HttpLog, LoginLog, AppLog } from '../api/log'
|
||||
import { useUserStore } from '../store/user'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 当前激活的标签页,取自 URL 子路径(/logs/http、/logs/login、/logs/app)
|
||||
const activeTab = ref<string>((route.params.tab as string) || 'http')
|
||||
|
||||
// 标签页偏好存储键,与路由 /logs 的重定向逻辑保持一致
|
||||
const LOGS_TAB_KEY = 'logs.tab'
|
||||
|
||||
// 日志保留天数(0 表示永久保留),默认 30 天
|
||||
const logRetention = ref(30)
|
||||
|
||||
/** 日志保留天数选项 */
|
||||
const retentionOptions = computed(() => [
|
||||
{ label: t('log.retention30'), value: 30 },
|
||||
{ label: t('log.retention90'), value: 90 },
|
||||
{ label: t('log.retention180'), value: 180 },
|
||||
{ label: t('log.retentionForever'), value: 0 },
|
||||
])
|
||||
|
||||
// ---------------- 访问日志 ----------------
|
||||
const httpRecords = ref<HttpLog[]>([])
|
||||
const httpLoading = ref(false)
|
||||
const httpPage = ref(1)
|
||||
const httpSize = ref(20)
|
||||
const httpTotal = ref(0)
|
||||
const httpKeyword = ref('')
|
||||
const httpMethod = ref<string | null>(null)
|
||||
const httpStatus = ref<number | null>(null)
|
||||
|
||||
// ---------------- 登录日志 ----------------
|
||||
const loginRecords = ref<LoginLog[]>([])
|
||||
const loginLoading = ref(false)
|
||||
const loginPage = ref(1)
|
||||
const loginSize = ref(20)
|
||||
const loginTotal = ref(0)
|
||||
const loginKeyword = ref('')
|
||||
const loginSuccess = ref<boolean | null>(null)
|
||||
|
||||
// ---------------- 运行日志 ----------------
|
||||
const appData = ref<AppLog | null>(null)
|
||||
const appLoading = ref(false)
|
||||
const appDate = ref('')
|
||||
const appLevel = ref<string | null>(null)
|
||||
const appKeyword = ref('')
|
||||
const appLines = ref<number | null>(500)
|
||||
// 时间范围(毫秒),默认最近 1 小时;0 表示全部
|
||||
const appRange = ref(3600000)
|
||||
// 实时获取:开启后通过 SSE 订阅日志新增内容
|
||||
const appRealtime = ref(false)
|
||||
// SSE 连接
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
/** 实时模式下缓冲的最大行数 */
|
||||
const MAX_REALTIME_LINES = 2000
|
||||
|
||||
/** 请求方法下拉选项 */
|
||||
const methodOptions = ['GET', 'POST', 'PUT', 'DELETE'].map((value) => ({ label: value, value }))
|
||||
|
||||
/** 状态码下拉选项 */
|
||||
const statusOptions = [200, 201, 400, 401, 403, 404, 500].map((value) => ({ label: String(value), value }))
|
||||
|
||||
/** 登录结果下拉选项 */
|
||||
const loginResultOptions = computed(() => [
|
||||
{ label: t('log.success'), value: true },
|
||||
{ label: t('log.failed'), value: false },
|
||||
])
|
||||
|
||||
/** 日志级别下拉选项 */
|
||||
const levelOptions = ['ERROR', 'WARN', 'INFO', 'DEBUG'].map((value) => ({ label: value, value }))
|
||||
|
||||
/** 运行日志时间范围下拉选项 */
|
||||
const rangeOptions = computed(() => [
|
||||
{ label: t('log.range1h'), value: 3600000 },
|
||||
{ label: t('log.range6h'), value: 21600000 },
|
||||
{ label: t('log.range24h'), value: 86400000 },
|
||||
{ label: t('log.rangeAll'), value: 0 },
|
||||
])
|
||||
|
||||
/** 计算运行日志查询的起始时间戳,范围为全部时返回 undefined */
|
||||
function appSince(): number | undefined {
|
||||
return appRange.value > 0 ? Date.now() - appRange.value : undefined
|
||||
}
|
||||
|
||||
/** 格式化后端时间字符串用于展示 */
|
||||
function formatTime(value: string | null) {
|
||||
return value ? value.replace('T', ' ').slice(0, 19) : ''
|
||||
}
|
||||
|
||||
/** 状态码对应的标签颜色 */
|
||||
function statusTagType(status: number | null) {
|
||||
if (status == null) return 'default'
|
||||
if (status >= 500) return 'error'
|
||||
if (status >= 400) return 'warning'
|
||||
if (status >= 300) return 'info'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
/** 请求方法对应的标签颜色 */
|
||||
function methodTagType(method: string | null) {
|
||||
switch (method) {
|
||||
case 'GET': return 'info'
|
||||
case 'POST': return 'success'
|
||||
case 'PUT': return 'warning'
|
||||
case 'DELETE': return 'error'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
/** 访问日志表格列 */
|
||||
const httpColumns = computed<DataTableColumns<HttpLog>>(() => [
|
||||
{
|
||||
title: t('log.time'), key: 'createdAt', width: 165,
|
||||
render: (row) => h('span', { class: 'cell-time' }, formatTime(row.createdAt)),
|
||||
},
|
||||
{
|
||||
title: t('log.method'), key: 'method', width: 90, align: 'center',
|
||||
render: (row) => (row.method
|
||||
? h(NTag, { type: methodTagType(row.method), size: 'small', bordered: false }, { default: () => row.method })
|
||||
: '-'),
|
||||
},
|
||||
{
|
||||
title: t('log.path'), key: 'path', minWidth: 180, ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: t('log.queryString'), key: 'queryString', minWidth: 140, ellipsis: { tooltip: true },
|
||||
render: (row) => row.queryString || '-',
|
||||
},
|
||||
{
|
||||
title: t('log.status'), key: 'status', width: 90, align: 'center',
|
||||
render: (row) => (row.status == null
|
||||
? '-'
|
||||
: h(NTag, { type: statusTagType(row.status), size: 'small', bordered: false }, { default: () => String(row.status) })),
|
||||
},
|
||||
{
|
||||
title: t('log.duration'), key: 'durationMs', width: 100, align: 'right',
|
||||
render: (row) => (row.durationMs == null ? '-' : `${row.durationMs} ms`),
|
||||
},
|
||||
{
|
||||
title: t('log.user'), key: 'userId', width: 90, align: 'center',
|
||||
render: (row) => (row.userId == null ? t('log.anonymous') : `#${row.userId}`),
|
||||
},
|
||||
{
|
||||
title: t('log.ip'), key: 'ip', width: 140, ellipsis: { tooltip: true },
|
||||
render: (row) => row.ip || '-',
|
||||
},
|
||||
])
|
||||
|
||||
/** 登录日志表格列 */
|
||||
const loginColumns = computed<DataTableColumns<LoginLog>>(() => [
|
||||
{
|
||||
title: t('log.time'), key: 'createdAt', width: 165,
|
||||
render: (row) => h('span', { class: 'cell-time' }, formatTime(row.createdAt)),
|
||||
},
|
||||
{
|
||||
title: t('log.username'), key: 'username', width: 140,
|
||||
render: (row) => row.username || '-',
|
||||
},
|
||||
{
|
||||
title: t('log.result'), key: 'success', width: 90, align: 'center',
|
||||
render: (row) => h(NTag, { type: row.success ? 'success' : 'error', size: 'small', bordered: false },
|
||||
{ default: () => (row.success ? t('log.success') : t('log.failed')) }),
|
||||
},
|
||||
{
|
||||
title: t('log.message'), key: 'message', minWidth: 140, ellipsis: { tooltip: true },
|
||||
render: (row) => row.message || '-',
|
||||
},
|
||||
{
|
||||
title: t('log.ip'), key: 'ip', width: 140, ellipsis: { tooltip: true },
|
||||
render: (row) => row.ip || '-',
|
||||
},
|
||||
{
|
||||
title: t('log.userAgent'), key: 'userAgent', minWidth: 180, ellipsis: { tooltip: true },
|
||||
render: (row) => row.userAgent || '-',
|
||||
},
|
||||
])
|
||||
|
||||
/** 加载访问日志 */
|
||||
async function loadHttp() {
|
||||
httpLoading.value = true
|
||||
try {
|
||||
const res: any = await pageHttpLogs({
|
||||
page: httpPage.value,
|
||||
size: httpSize.value,
|
||||
keyword: httpKeyword.value.trim() || undefined,
|
||||
method: httpMethod.value || undefined,
|
||||
status: httpStatus.value ?? undefined,
|
||||
})
|
||||
httpRecords.value = res.data.records || []
|
||||
httpTotal.value = res.data.total || 0
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('log.loadFailed'))
|
||||
} finally {
|
||||
httpLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载登录日志 */
|
||||
async function loadLogin() {
|
||||
loginLoading.value = true
|
||||
try {
|
||||
const res: any = await pageLoginLogs({
|
||||
page: loginPage.value,
|
||||
size: loginSize.value,
|
||||
keyword: loginKeyword.value.trim() || undefined,
|
||||
success: loginSuccess.value ?? undefined,
|
||||
})
|
||||
loginRecords.value = res.data.records || []
|
||||
loginTotal.value = res.data.total || 0
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('log.loadFailed'))
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载运行日志快照(非实时模式) */
|
||||
async function loadApp() {
|
||||
appLoading.value = true
|
||||
try {
|
||||
const res: any = await getAppLog({
|
||||
date: appDate.value.trim() || undefined,
|
||||
level: appLevel.value || undefined,
|
||||
keyword: appKeyword.value.trim() || undefined,
|
||||
lines: appLines.value || 500,
|
||||
since: appSince(),
|
||||
})
|
||||
appData.value = res.data
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('log.loadFailed'))
|
||||
} finally {
|
||||
appLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭 SSE 实时连接 */
|
||||
function stopRealtime() {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 建立 SSE 实时连接,订阅运行日志新增内容 */
|
||||
function startRealtime() {
|
||||
stopRealtime()
|
||||
|
||||
const params = new URLSearchParams()
|
||||
// EventSource 无法自定义请求头,token 通过查询参数传递
|
||||
params.set('token', userStore.token || '')
|
||||
if (appLevel.value) {
|
||||
params.set('level', appLevel.value)
|
||||
}
|
||||
if (appKeyword.value.trim()) {
|
||||
params.set('keyword', appKeyword.value.trim())
|
||||
}
|
||||
params.set('lines', String(appLines.value || 500))
|
||||
const since = appSince()
|
||||
if (since !== undefined) {
|
||||
params.set('since', String(since))
|
||||
}
|
||||
|
||||
const source = new EventSource(`/api/log/app/stream?${params.toString()}`)
|
||||
eventSource = source
|
||||
|
||||
// 元信息事件:初始化文件名与初始命中行数
|
||||
source.addEventListener('meta', (e: MessageEvent) => {
|
||||
try {
|
||||
const meta = JSON.parse(e.data)
|
||||
appData.value = { file: meta.file || '', total: meta.total || 0, lines: [] }
|
||||
} catch {
|
||||
appData.value = { file: '', total: 0, lines: [] }
|
||||
}
|
||||
})
|
||||
|
||||
// 日志行事件:按时间正序推送,插入列表头部实现最新在前
|
||||
source.addEventListener('line', (e: MessageEvent) => {
|
||||
if (!appData.value) {
|
||||
appData.value = { file: '', total: 0, lines: [] }
|
||||
}
|
||||
appData.value.lines.unshift(e.data)
|
||||
appData.value.total++
|
||||
if (appData.value.lines.length > MAX_REALTIME_LINES) {
|
||||
appData.value.lines.length = MAX_REALTIME_LINES
|
||||
}
|
||||
})
|
||||
|
||||
source.onerror = () => {
|
||||
// 服务端关闭连接(如认证失败)时停止实时并提示,避免无限重连
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
stopRealtime()
|
||||
if (appRealtime.value) {
|
||||
appRealtime.value = false
|
||||
message.error(t('log.loadFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 实时开关变化时建立/关闭 SSE 连接(仅在运行日志标签页生效)
|
||||
watch(appRealtime, (enabled) => {
|
||||
if (activeTab.value !== 'app') {
|
||||
return
|
||||
}
|
||||
if (enabled) {
|
||||
startRealtime()
|
||||
} else {
|
||||
stopRealtime()
|
||||
loadApp()
|
||||
}
|
||||
})
|
||||
|
||||
// URL 子路径变化(如浏览器前进/后退)时同步标签页
|
||||
watch(() => route.params.tab, (tab) => {
|
||||
const target = (tab as string) || 'http'
|
||||
if (target !== activeTab.value) {
|
||||
activeTab.value = target
|
||||
}
|
||||
})
|
||||
|
||||
// 标签页切换:记住偏好、加载对应数据、同步 URL 子路径、控制实时连接
|
||||
watch(activeTab, (tab) => {
|
||||
// 保存当前标签页偏好,供下次点击「系统日志」时恢复
|
||||
localStorage.setItem(LOGS_TAB_KEY, tab)
|
||||
if (tab === 'http') {
|
||||
loadHttp()
|
||||
} else if (tab === 'login') {
|
||||
loadLogin()
|
||||
} else if (tab === 'app') {
|
||||
if (appRealtime.value) {
|
||||
startRealtime()
|
||||
} else {
|
||||
loadApp()
|
||||
}
|
||||
}
|
||||
// 离开运行日志时关闭 SSE 连接
|
||||
if (tab !== 'app') {
|
||||
stopRealtime()
|
||||
}
|
||||
// URL 未反映当前标签时更新,避免与路由同步互相触发
|
||||
if ((route.params.tab as string) !== tab) {
|
||||
router.push(`/logs/${tab}`)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(stopRealtime)
|
||||
|
||||
/** 访问日志查询:重置到第一页 */
|
||||
function searchHttp() {
|
||||
httpPage.value = 1
|
||||
loadHttp()
|
||||
}
|
||||
|
||||
/** 登录日志查询:重置到第一页 */
|
||||
function searchLogin() {
|
||||
loginPage.value = 1
|
||||
loadLogin()
|
||||
}
|
||||
|
||||
/** 加载日志保留天数配置 */
|
||||
async function loadRetention() {
|
||||
try {
|
||||
const res: any = await getLogRetention()
|
||||
logRetention.value = res.data ?? 30
|
||||
} catch {
|
||||
// 读取失败时保持默认值
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存日志保留天数配置 */
|
||||
async function saveRetention(days: number) {
|
||||
try {
|
||||
await updateLogRetention(days)
|
||||
message.success(t('log.retentionSaved'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('log.retentionFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRetention)
|
||||
|
||||
/** 运行日志行级别样式 */
|
||||
function lineClass(line: string) {
|
||||
if (line.includes(' ERROR ')) return 'log-line log-error'
|
||||
if (line.includes(' WARN ')) return 'log-line log-warn'
|
||||
if (line.includes(' DEBUG ')) return 'log-line log-debug'
|
||||
return 'log-line'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="logs-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ t('log.title') }}</h2>
|
||||
<NSpace align="center" :size="8">
|
||||
<NText depth="3">{{ t('log.retention') }}</NText>
|
||||
<NSelect
|
||||
v-model:value="logRetention"
|
||||
:options="retentionOptions"
|
||||
style="width: 130px;"
|
||||
@update:value="saveRetention"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<NCard :bordered="false">
|
||||
<NTabs v-model:value="activeTab" type="line" animated>
|
||||
<!-- 访问日志 -->
|
||||
<NTabPane name="http" :tab="t('log.httpTab')">
|
||||
<NSpace align="center" :size="12" style="margin-bottom: 12px;">
|
||||
<NInput
|
||||
v-model:value="httpKeyword"
|
||||
:placeholder="t('log.keywordPlaceholder')"
|
||||
clearable
|
||||
style="width: 220px;"
|
||||
@keyup.enter="searchHttp"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="httpMethod"
|
||||
:options="methodOptions"
|
||||
:placeholder="t('log.method')"
|
||||
clearable
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="httpStatus"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('log.status')"
|
||||
clearable
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<NButton type="primary" @click="searchHttp">
|
||||
<template #icon><NIcon :component="SearchOutline" /></template>
|
||||
{{ t('log.search') }}
|
||||
</NButton>
|
||||
<NButton :loading="httpLoading" @click="loadHttp">
|
||||
<template #icon><NIcon :component="RefreshOutline" /></template>
|
||||
{{ t('log.refresh') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NDataTable
|
||||
:columns="httpColumns"
|
||||
:data="httpRecords"
|
||||
:loading="httpLoading"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:striped="true"
|
||||
:scroll-x="1000"
|
||||
>
|
||||
<template #empty>
|
||||
<NEmpty :description="t('log.noData')" />
|
||||
</template>
|
||||
</NDataTable>
|
||||
|
||||
<NSpace justify="end" style="margin-top: 12px;">
|
||||
<NPagination
|
||||
v-model:page="httpPage"
|
||||
v-model:page-size="httpSize"
|
||||
:item-count="httpTotal"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
show-size-picker
|
||||
@update:page="loadHttp"
|
||||
@update:page-size="searchHttp"
|
||||
/>
|
||||
</NSpace>
|
||||
</NTabPane>
|
||||
|
||||
<!-- 登录日志 -->
|
||||
<NTabPane name="login" :tab="t('log.loginTab')">
|
||||
<NSpace align="center" :size="12" style="margin-bottom: 12px;">
|
||||
<NInput
|
||||
v-model:value="loginKeyword"
|
||||
:placeholder="t('log.keywordPlaceholder')"
|
||||
clearable
|
||||
style="width: 220px;"
|
||||
@keyup.enter="searchLogin"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="loginSuccess"
|
||||
:options="loginResultOptions"
|
||||
:placeholder="t('log.result')"
|
||||
clearable
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<NButton type="primary" @click="searchLogin">
|
||||
<template #icon><NIcon :component="SearchOutline" /></template>
|
||||
{{ t('log.search') }}
|
||||
</NButton>
|
||||
<NButton :loading="loginLoading" @click="loadLogin">
|
||||
<template #icon><NIcon :component="RefreshOutline" /></template>
|
||||
{{ t('log.refresh') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NDataTable
|
||||
:columns="loginColumns"
|
||||
:data="loginRecords"
|
||||
:loading="loginLoading"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:striped="true"
|
||||
:scroll-x="900"
|
||||
>
|
||||
<template #empty>
|
||||
<NEmpty :description="t('log.noData')" />
|
||||
</template>
|
||||
</NDataTable>
|
||||
|
||||
<NSpace justify="end" style="margin-top: 12px;">
|
||||
<NPagination
|
||||
v-model:page="loginPage"
|
||||
v-model:page-size="loginSize"
|
||||
:item-count="loginTotal"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
show-size-picker
|
||||
@update:page="loadLogin"
|
||||
@update:page-size="searchLogin"
|
||||
/>
|
||||
</NSpace>
|
||||
</NTabPane>
|
||||
|
||||
<!-- 运行日志 -->
|
||||
<NTabPane name="app" :tab="t('log.appTab')">
|
||||
<NSpace align="center" :size="12" style="margin-bottom: 12px;">
|
||||
<NInput
|
||||
v-model:value="appDate"
|
||||
:placeholder="t('log.datePlaceholder')"
|
||||
clearable
|
||||
:disabled="appRealtime"
|
||||
style="width: 180px;"
|
||||
@keyup.enter="loadApp"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="appRange"
|
||||
:options="rangeOptions"
|
||||
:disabled="appRealtime"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="appLevel"
|
||||
:options="levelOptions"
|
||||
:placeholder="t('log.level')"
|
||||
clearable
|
||||
:disabled="appRealtime"
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="appKeyword"
|
||||
:placeholder="t('log.keyword')"
|
||||
clearable
|
||||
:disabled="appRealtime"
|
||||
style="width: 200px;"
|
||||
@keyup.enter="loadApp"
|
||||
/>
|
||||
<NInputNumber
|
||||
v-model:value="appLines"
|
||||
:min="1"
|
||||
:max="2000"
|
||||
:placeholder="t('log.lines')"
|
||||
:disabled="appRealtime"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<NButton type="primary" :loading="appLoading" :disabled="appRealtime" @click="loadApp()">
|
||||
<template #icon><NIcon :component="RefreshOutline" /></template>
|
||||
{{ t('log.refresh') }}
|
||||
</NButton>
|
||||
<NSpace align="center" :size="6">
|
||||
<NSwitch v-model:value="appRealtime" size="small" />
|
||||
<NText depth="3">{{ t('log.realtime') }}</NText>
|
||||
</NSpace>
|
||||
<NText depth="3">
|
||||
{{ t('log.file') }}: {{ appData?.file || '-' }}
|
||||
</NText>
|
||||
<NText depth="3">
|
||||
{{ t('log.totalLines', { count: appData?.total ?? 0 }) }}
|
||||
</NText>
|
||||
</NSpace>
|
||||
|
||||
<div class="log-viewer">
|
||||
<NEmpty v-if="!appLoading && !(appData?.lines?.length)" :description="t('log.appEmpty')" />
|
||||
<div v-for="(line, index) in appData?.lines ?? []" :key="index" :class="lineClass(line)">
|
||||
{{ line }}
|
||||
</div>
|
||||
</div>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logs-page {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-title {
|
||||
margin: 0;
|
||||
}
|
||||
.logs-page > :deep(.n-card) {
|
||||
border: 1px solid #efeff5;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.cell-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.log-viewer {
|
||||
max-height: 560px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
font-family: 'JetBrains Mono', Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.log-line {
|
||||
color: #d4d4d4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-warn {
|
||||
color: #e6b455;
|
||||
}
|
||||
.log-error {
|
||||
color: #f07178;
|
||||
}
|
||||
.log-debug {
|
||||
color: #7f9aa8;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user