fix(web): FRP 日志页签解析、级别筛选与呈现对齐系统日志

解析:容忍行首 [组件] 前缀(真实行为 [web] …),来源取 [文件:行号]、缺失时回落组件名,级别统一大写

筛选:级别选项值 INFO/WARN/ERROR 与 frpc 单字母级别对齐(原先与中文标签比较永不相等,选中即过滤为空),并补筛选变化重置页码

呈现:级别/来源改小号标签、消息单行省略带 tooltip、时间走 logTimeCell,与「系统日志」页签一致;空态按筛选结果判定,区分「暂无日志」与「没有匹配的日志」
This commit is contained in:
2026-09-23 18:08:31 +08:00
parent fd1661622e
commit 1b30be45a0
+57 -11
View File
@@ -60,9 +60,9 @@
:item-count="filteredFrpLogs.length"
/>
<EmptyState
v-if="!loadingFrp && frpLogs.length === 0"
title="暂无 FRP 日志"
description="启动 frpc 后,登录、隧道注册与连接错误会记录在这里。"
v-if="!loadingFrp && filteredFrpLogs.length === 0"
:title="frpLogs.length === 0 ? '暂无 FRP 日志' : '没有匹配的日志'"
:description="frpLogs.length === 0 ? '启动 frpc 后,登录、隧道注册与连接错误会记录在这里。' : '换个关键词或级别再试。'"
/>
</n-tab-pane>
@@ -374,18 +374,31 @@ const frpPage = ref(1)
const loadingFrp = ref(false)
const frpPageSize = 50
const frpLevelText: Record<string, string> = { I: '信息', W: '警告', E: '错误', D: '调试' }
// 工具条的级别选项值是 INFO / WARN / ERROR,而 frpc 的级别是单字母 I/W/E/D,需转换后再比较
const frpLevelAlias: Record<string, string> = { INFO: 'I', WARN: 'W', ERROR: 'E' }
// frpc 行形如:2026-09-23 15:20:31.123 [I] [service.go:295] login to server success
// frpc 行有两种形态:
// 2026-09-23 15:20:31.123 [I] [service.go:295] login to server success
// [web] 2026-09-20 19:38:51.771 [I] [client/control.go:174] [trace-id] [web-http] start proxy success
// 行首可能带一个 [组件] 前缀,来源优先取 [文件:行号],缺失时回落为该组件名
function parseFrpLogLine(line: string): FrpLogEntry {
const matched = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?)\s*\[([A-Z])\]\s*(?:\[([^\]]+)\])?\s*(.*)$/)
const matched = line.match(
/^(?:\[([^\]]+)\]\s+)?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)\s*\[([A-Za-z])\]\s*(?:\[([^\]]+)\])?\s*(.*)$/,
)
if (!matched) return { time: '', level: '', source: '', message: line }
return { time: matched[1], level: matched[2], source: matched[3] ?? '', message: matched[4] ?? '' }
const component = matched[1] ?? ''
return {
time: matched[2],
level: matched[3].toUpperCase(),
source: matched[4] || component,
message: matched[5] ?? '',
}
}
const filteredFrpLogs = computed(() => {
const keyword = frpKeyword.value.trim().toLowerCase()
return frpLogs.value.filter((log) => {
if (frpLevel.value && frpLevelText[log.level] !== frpLevel.value) return false
if (frpLevel.value && frpLevelAlias[frpLevel.value] !== log.level) return false
if (keyword && !`${log.source} ${log.message}`.toLowerCase().includes(keyword)) return false
return true
})
@@ -396,11 +409,40 @@ const pagedFrpLogs = computed(() => {
return filteredFrpLogs.value.slice(start, start + frpPageSize)
})
// 与「系统日志」页签同一套渲染:时间走 logTimeCell,级别/来源用小号标签,消息单行省略并带 tooltip
const frpColumns: DataTableColumns<FrpLogEntry> = [
{ title: '时间', key: 'time', width: 168, render: (row) => (row.time ? logTimeCell(row.time) : '—') },
{ title: '级别', key: 'level', width: 80, render: (row) => frpLevelText[row.level] ?? (row.level || '—') },
{ title: '来源', key: 'source', width: 170, render: (row) => row.source || '—' },
{ title: '消息', key: 'message', render: (row) => row.message },
{ title: '时间', key: 'time', width: 168, render: (row) => logTimeCell(row.time) },
{
title: '级别',
key: 'level',
width: 80,
render: (row) => {
const label = frpLevelText[row.level] ?? (row.level || '—')
return h(NTag, { size: 'small', bordered: false, type: systemLevelTagType(label) }, () => label)
},
},
{
title: '来源',
key: 'source',
width: 170,
render: (row) =>
h(
NTag,
{
size: 'small',
bordered: false,
type: systemModuleTagType(row.source),
class: 'log-module-tag',
},
() => row.source || '—',
),
},
{
title: '消息',
key: 'message',
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'log-message' }, row.message),
},
]
function frpRowClassName(row: FrpLogEntry) {
@@ -595,6 +637,10 @@ watch([systemKeyword, systemLevel], () => {
systemPage.value = 1
})
watch([frpKeyword, frpLevel], () => {
frpPage.value = 1
})
watch(autoRefresh, (on) => {
if (refreshTimer) clearInterval(refreshTimer)
if (on) refreshTimer = setInterval(() => loadAccess(), 10000)