feat: 新增系统日志记录、查看与自动清理,VO 转换改用 MapStruct

- 新增访问/登录日志表与拦截器,登录成功/失败均记录,接口访问同时输出到应用日志
- 新增 /api/log/* 查询与 SSE 实时推送,运行日志默认最近 1 小时并支持日期/级别/关键字筛选
- 新增日志定时清理,保留天数可配置(默认 30 天,0 为永久保留)
- 前端新增系统日志页(独立 URL 子路径、标签偏好记忆),入口位于顶部用户下拉菜单
- 实体到视图对象的属性拷贝改用 MapStruct 编译期生成
This commit is contained in:
2026-09-18 17:18:03 +08:00
parent e49b6b7b81
commit e82e0a4ef9
38 changed files with 2464 additions and 153 deletions
+85
View File
@@ -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 })
}
+49
View File
@@ -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: 'ダッシュボード' },
+14
View File
@@ -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'), // 系统日志(访问日志/登录日志/运行日志)
},
],
},
],
+6 -3
View File
@@ -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()
+655
View File
@@ -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>