优化日志可视化表格、级别统计与完整详情
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-08 23:44:39 +08:00
parent 5f442354a1
commit d5b2f9c7be
5 changed files with 543 additions and 43 deletions

View File

@ -4,32 +4,73 @@
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
gap: 12px;
padding: 8px 10px 4px 2px;
box-sizing: border-box;
}
/* 只为日志页建立确定高度,滚动限制在表格内,不改变其他设置页。 */
:global(.settings-tabs .el-tab-pane:has(> .log-settings)) {
height: 100%;
}
.log-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
flex-shrink: 0;
}
.log-heading h2 {
margin: 0;
font-size: 20px;
font-size: 18px;
font-weight: 650;
}
.log-heading span,
.log-pagination span {
.log-pagination > span {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.log-retention {
padding: 3px 8px;
border-radius: 6px;
background: var(--el-fill-color-light);
}
.log-categories {
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
flex-shrink: 0;
padding: 4px;
border-radius: 10px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
}
.log-categories :deep(.el-radio-button__inner) {
width: 100%;
padding: 9px 5px;
font-size: 13px;
border: 0 !important;
border-radius: 7px !important;
background: transparent;
box-shadow: none !important;
}
.log-categories :deep(.el-radio-button.is-active .el-radio-button__inner) {
background: var(--el-bg-color-overlay);
color: var(--el-color-primary);
box-shadow: 0 1px 4px rgb(0 0 0 / 8%) !important;
font-weight: 600;
}
.log-categories :deep(.el-radio-button__original-radio:focus-visible + .el-radio-button__inner) {
outline: 2px solid var(--el-color-primary);
outline-offset: -2px;
}
.log-filters {
@ -37,6 +78,7 @@
align-items: center;
flex-wrap: wrap;
gap: 10px;
flex-shrink: 0;
}
.log-filters :deep(.el-date-editor) {
@ -52,36 +94,208 @@
.log-filters .el-select {
flex: 1;
min-width: 200px;
min-width: 0;
}
.log-filters-runtime {
flex-wrap: nowrap;
}
.log-filters :deep(.el-select__wrapper),
.log-filters :deep(.el-button) {
border-radius: 8px;
}
.log-file-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.log-file-option small {
color: var(--el-text-color-secondary);
}
.log-content {
flex: 1;
min-height: 160px;
min-height: 120px;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
border-radius: 12px;
background: var(--el-bg-color-overlay);
}
.runtime-lines {
height: 100%;
overflow: auto;
box-sizing: border-box;
.log-table-heading {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.log-table-title {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px;
font-size: 13px;
}
.log-file-meta,
.log-summary {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.log-summary {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 14px;
font-variant-numeric: tabular-nums;
}
.log-summary > span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.log-summary i {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.log-stat-info {
color: var(--el-color-primary);
}
.log-stat-warning {
color: var(--el-color-warning);
}
.log-stat-danger {
color: var(--el-color-danger);
}
.log-table {
flex: 1;
min-height: 0;
width: 100%;
--el-table-header-bg-color: var(--el-fill-color-light);
--el-table-header-text-color: var(--el-text-color-secondary);
--el-table-row-hover-bg-color: var(--el-color-primary-light-9);
--el-table-border-color: var(--el-border-color-extra-light);
}
.log-table :deep(.el-table__cell) {
padding: 9px 0;
}
.log-table :deep(.cell) {
padding: 0 10px;
line-height: 1.5;
}
.log-table :deep(th .cell) {
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.log-table :deep(.el-table__row) {
cursor: pointer;
}
.log-table :deep(.log-row-warning) {
--el-table-tr-bg-color: color-mix(in srgb, var(--el-color-warning) 5%, var(--el-bg-color-overlay));
}
.log-table :deep(.log-row-danger) {
--el-table-tr-bg-color: color-mix(in srgb, var(--el-color-danger) 6%, var(--el-bg-color-overlay));
}
.log-time {
display: flex;
flex-direction: column;
gap: 3px;
font-variant-numeric: tabular-nums;
}
.log-time strong {
font-size: 13px;
font-weight: 500;
}
.log-time span,
.log-source {
font-size: 11px;
color: var(--el-text-color-secondary);
}
.log-level-tag {
border: 0;
border-radius: 5px;
font-weight: 500;
}
.log-message {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
font-size: 13px;
color: var(--el-text-color-primary);
}
.log-source {
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: Consolas, monospace;
}
.runtime-detail-message,
.runtime-raw pre {
margin: 0;
padding: 12px;
font: 12px/1.7 Consolas, monospace;
padding: 14px;
font: 13px/1.7 Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-light);
}
.log-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
justify-content: space-between;
flex-shrink: 0;
flex-wrap: wrap;
gap: 8px;
}
.log-page-actions {
display: flex;
gap: 8px;
}
.log-page-actions :deep(.el-button) {
margin: 0;
border-radius: 6px;
}
.log-detail-grid {
display: grid;
grid-template-columns: 110px minmax(0, 1fr);
@ -92,3 +306,65 @@
margin: 0;
overflow-wrap: anywhere;
}
.runtime-detail {
max-height: 65vh;
overflow: auto;
}
.runtime-detail-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.runtime-detail-sequence {
margin-left: auto;
}
.runtime-detail-meta {
grid-template-columns: 70px minmax(0, 1fr);
margin: 18px 0;
font-size: 13px;
}
.runtime-detail-meta dt {
color: var(--el-text-color-secondary);
}
.runtime-detail h3 {
margin: 0 0 10px;
font-size: 13px;
}
.runtime-raw {
margin-top: 16px;
}
.runtime-raw summary {
padding: 8px 0;
color: var(--el-color-primary);
cursor: pointer;
}
@container settings-content (max-width: 560px) {
.log-settings {
padding-right: 0;
gap: 10px;
}
.log-table-heading {
padding: 10px;
}
.log-table :deep(.cell) {
padding: 0 7px;
}
.log-categories :deep(.el-radio-button__inner) {
font-size: 12px;
}
}

View File

@ -1,5 +1,6 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { http } from '@/utils/axios'
import { formatFileSize, parseRuntimeLine, summarizeRuntime, timeParts } from './RuntimeLog.js'
export function useLogs() {
const category = ref('config')
@ -14,6 +15,7 @@ export function useLogs() {
const actorId = ref('')
const dateRange = ref([])
const detail = ref(null)
const runtimeDetail = ref(null)
const pageSize = 50
let requestId = 0
let aborter
@ -32,6 +34,14 @@ export function useLogs() {
const displayActor = item => item.payload?.actorName || (item.actorId && item.actorId !== '0' ? item.actorId : '系统')
const details = computed(() => detail.value?.payload || {})
const changes = computed(() => details.value.changes || [])
const runtimeRows = computed(() => lines.value.map((line, index) => parseRuntimeLine(line, offset.value + index + 1)))
const runtimeSummary = computed(() => summarizeRuntime(runtimeRows.value))
const currentFile = computed(() => files.value.find(file => file.name === selectedFile.value))
const visibleCount = computed(() => isRuntime.value ? lines.value.length : items.value.length)
const pageRange = computed(() => visibleCount.value ? `第 ${offset.value + 1}–${offset.value + visibleCount.value} 条` : '暂无记录')
const currentCategory = computed(() => categories.find(item => item.value === category.value)?.label)
const resultType = value => ({ success: 'success', accepted: 'success', failure: 'danger' }[value] || 'info')
const runtimeRowClass = ({ row }) => ['warning', 'danger'].includes(row.type) ? `log-row-${row.type}` : ''
function requireData(result) {
if (result?.errorNo !== 0 || !result.data) throw new Error(result?.errorMsg || '日志查询失败')
@ -106,6 +116,7 @@ export function useLogs() {
}
watch(category, () => {
detail.value = null
runtimeDetail.value = null
search()
})
onMounted(load)
@ -117,6 +128,8 @@ export function useLogs() {
return {
category, categories, loading, error, items, files, selectedFile, lines, offset,
hasMore, actorId, dateRange, detail, details, changes, isRuntime,
runtimeDetail, runtimeRows, runtimeSummary, currentFile, visibleCount, pageRange,
currentCategory, resultType, runtimeRowClass, timeParts, formatFileSize,
formatTime, resultLabel, actionLabel, displayActor, search, next, previous
}
}

View File

@ -1,15 +1,15 @@
<template>
<section class="log-settings">
<header class="log-heading">
<h2>日志</h2>
<span>保留最近 30 天 · 运行日志满 7 天压缩</span>
<h2>日志记录</h2>
<span class="log-retention">最近 30 天</span>
</header>
<el-radio-group v-model="category" class="log-categories" aria-label="日志分类">
<el-radio-button v-for="item in categories" :key="item.value" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
<div class="log-filters">
<div class="log-filters" :class="{ 'log-filters-runtime': isRuntime }">
<template v-if="isRuntime">
<el-select v-model="selectedFile" placeholder="选择日志文件" aria-label="日志文件" @change="search">
<el-option
@ -17,7 +17,12 @@
:key="file.name"
:value="file.name"
:label="`${file.name}${file.compressed ? '(已压缩)' : ''}`"
/>
>
<div class="log-file-option">
<span>{{ file.name }}</span>
<small>{{ file.compressed ? '已压缩 · ' : '' }}{{ formatFileSize(file.bytes) }}</small>
</div>
</el-option>
</el-select>
</template>
<template v-else>
@ -31,39 +36,113 @@
/>
<el-input v-model="actorId" clearable placeholder="操作者编号" aria-label="操作者编号" @keyup.enter="search" />
</template>
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
<el-button type="primary" :loading="loading" @click="search">{{ isRuntime ? '刷新' : '查询' }}</el-button>
</div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<div v-loading="loading" class="log-content">
<template v-if="isRuntime">
<pre v-if="lines.length" class="runtime-lines">{{ lines.join('\n') }}</pre>
<el-empty v-else description="暂无运行日志" />
</template>
<el-table v-else :data="items" height="100%" empty-text="暂无日志记录" @row-click="item => detail = item">
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="140">
<template #default="{ row }">{{ actionLabel(row.action) }}</template>
</el-table-column>
<el-table-column label="操作者" min-width="110">
<template #default="{ row }">{{ displayActor(row) }}</template>
</el-table-column>
<el-table-column label="结果" width="90">
<div v-loading="loading" class="log-content" :aria-busy="loading">
<div class="log-table-heading">
<div class="log-table-title">
<strong>{{ currentCategory }}</strong>
<span v-if="isRuntime && currentFile" class="log-file-meta">
{{ formatFileSize(currentFile.bytes) }} · {{ currentFile.compressed ? '压缩归档' : '原始文件' }}
</span>
<span v-else class="log-file-meta">本页 {{ visibleCount }} 条</span>
</div>
<div v-if="isRuntime" class="log-summary" aria-label="本页日志统计">
<span>本页 {{ runtimeSummary.total }} 条</span>
<span class="log-stat-info"><i aria-hidden="true" />信息 {{ runtimeSummary.info }}</span>
<span class="log-stat-warning"><i aria-hidden="true" />警告 {{ runtimeSummary.warning }}</span>
<span class="log-stat-danger"><i aria-hidden="true" />错误 {{ runtimeSummary.error }}</span>
</div>
</div>
<el-table
v-if="isRuntime"
:data="runtimeRows"
class="log-table runtime-table"
height="100%"
row-key="sequence"
:row-class-name="runtimeRowClass"
empty-text="暂无运行日志"
@row-click="row => runtimeDetail = row"
>
<el-table-column label="时间(本地)" width="116">
<template #default="{ row }">
<el-tag :type="row.result === 'failure' ? 'danger' : 'info'" size="small">{{ resultLabel(row.result) }}</el-tag>
<div class="log-time"><strong>{{ row.time }}</strong><span>{{ row.date }}</span></div>
</template>
</el-table-column>
<el-table-column label="详情" width="72">
<el-table-column label="级别" width="76" align="center">
<template #default="{ row }">
<el-tag :type="row.type" size="small" effect="light" class="log-level-tag">{{ row.label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="日志内容" min-width="170">
<template #default="{ row }">
<div class="log-message">{{ row.summary }}</div>
<div v-if="row.sourceShort" class="log-source">{{ row.sourceShort }}</div>
</template>
</el-table-column>
<el-table-column label="详情" width="60" align="center">
<template #default="{ row }">
<el-button link type="primary" :aria-label="`查看第 ${row.sequence} 条日志详情`" @click.stop="runtimeDetail = row">查看</el-button>
</template>
</el-table-column>
</el-table>
<el-table v-else :data="items" class="log-table" height="100%" row-key="id" stripe empty-text="暂无日志记录" @row-click="item => detail = item">
<el-table-column label="时间(本地)" width="116">
<template #default="{ row }">
<div class="log-time"><strong>{{ timeParts(row.createdAt).time }}</strong><span>{{ timeParts(row.createdAt).date }}</span></div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="125" show-overflow-tooltip>
<template #default="{ row }">{{ actionLabel(row.action) }}</template>
</el-table-column>
<el-table-column label="操作者" min-width="85" show-overflow-tooltip>
<template #default="{ row }">{{ displayActor(row) }}</template>
</el-table-column>
<el-table-column label="结果" width="82" align="center">
<template #default="{ row }">
<el-tag :type="resultType(row.result)" size="small" class="log-level-tag">{{ resultLabel(row.result) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="详情" width="60" align="center">
<template #default="{ row }"><el-button link type="primary" @click.stop="detail = row">查看</el-button></template>
</el-table-column>
</el-table>
</div>
<footer class="log-pagination">
<span>从第 {{ offset + 1 }} 条开始</span>
<el-button :disabled="loading || offset === 0" @click="previous">上一页</el-button>
<el-button :disabled="loading || !hasMore" @click="next">下一页</el-button>
<span aria-live="polite">{{ pageRange }}</span>
<div class="log-page-actions">
<el-button size="small" :disabled="loading || offset === 0" @click="previous">上一页</el-button>
<el-button size="small" :disabled="loading || !hasMore" @click="next">下一页</el-button>
</div>
</footer>
<el-dialog
:model-value="Boolean(runtimeDetail)"
title="运行日志详情"
width="min(760px, 92vw)"
append-to-body
@close="runtimeDetail = null"
>
<div v-if="runtimeDetail" class="runtime-detail">
<div class="runtime-detail-heading">
<el-tag :type="runtimeDetail.type" effect="light">{{ runtimeDetail.label }}</el-tag>
<span>{{ formatTime(runtimeDetail.createdAt) }}</span>
<span class="runtime-detail-sequence">第 {{ runtimeDetail.sequence }} 条</span>
</div>
<dl class="log-detail-grid runtime-detail-meta">
<dt>代码来源</dt><dd>{{ runtimeDetail.source || '未记录' }}</dd>
<template v-if="runtimeDetail.requestId">
<dt>请求标识</dt><dd>{{ runtimeDetail.requestId }}</dd>
</template>
</dl>
<h3>完整内容</h3>
<pre class="runtime-detail-message">{{ runtimeDetail.message }}</pre>
<details class="runtime-raw">
<summary>原始记录</summary>
<pre>{{ runtimeDetail.raw }}</pre>
</details>
</div>
</el-dialog>
<el-dialog :model-value="Boolean(detail)" title="日志详情" width="min(760px, 92vw)" append-to-body @close="detail = null">
<template v-if="detail">
<p>{{ formatTime(detail.createdAt) }} · {{ displayActor(detail) }} · {{ actionLabel(detail.action) }}</p>
@ -96,6 +175,8 @@ import { useLogs } from './LogSettings.js'
const {
category, categories, loading, error, items, files, selectedFile, lines, offset,
hasMore, actorId, dateRange, detail, details, changes, isRuntime,
runtimeDetail, runtimeRows, runtimeSummary, currentFile, visibleCount, pageRange,
currentCategory, resultType, runtimeRowClass, timeParts, formatFileSize,
formatTime, resultLabel, actionLabel, displayActor, search, next, previous
} = useLogs()
</script>

View File

@ -0,0 +1,73 @@
const levels = {
trace: { label: '跟踪', type: 'info' },
debug: { label: '调试', type: 'info' },
info: { label: '信息', type: 'primary' },
warning: { label: '警告', type: 'warning' },
error: { label: '错误', type: 'danger' },
fatal: { label: '致命', type: 'danger' },
panic: { label: '异常', type: 'danger' },
unknown: { label: '未标注', type: 'info' }
}
const localDate = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit'
})
const localTime = new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23'
})
export function timeParts(value) {
const date = new Date(value)
if (!value || Number.isNaN(date.getTime())) return { date: '—', time: '—' }
return { date: localDate.format(date), time: localTime.format(date) }
}
// 只识别已知的日志头;未知格式原样展示,不丢弃记录或猜测级别。
export function parseRuntimeLine(line, sequence) {
const raw = String(line ?? '')
let message = raw
let createdAt = ''
let level = 'unknown'
let source = ''
let requestId = ''
const timestamp = message.match(/^\[(\d{4}-\d{2}-\d{2}T[^\]]+)\]\s*/)
if (timestamp && !Number.isNaN(Date.parse(timestamp[1]))) {
createdAt = timestamp[1]
message = message.slice(timestamp[0].length)
const header = message.match(/^\[(trace|debug|info|warn|warning|error|fatal|panic)\]\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]/i)
if (header) {
level = header[1].toLowerCase().replace(/^warn$/, 'warning')
message = message.slice(header[0].length)
const location = message.match(/^(?:\[([^\]\r\n]*)\])?\[([^\]\r\n]+\.go:\d+)\]/)
if (location) {
requestId = location[1] || ''
source = location[2]
message = message.slice(location[0].length)
}
}
}
const separator = /^\*{3,}$/.test(message.trim())
const summary = separator ? '启动分隔线' : message.trim() || '(空消息)'
const sourceShort = source.replace(/^git\.pcux\.cn\/xuans\/Smail\//, '')
return {
sequence, raw, createdAt, level, source, sourceShort, requestId, message, summary,
...timeParts(createdAt), ...levels[level]
}
}
export function summarizeRuntime(rows) {
return {
total: rows.length,
info: rows.filter(row => row.level === 'info').length,
warning: rows.filter(row => row.level === 'warning').length,
error: rows.filter(row => ['error', 'fatal', 'panic'].includes(row.level)).length
}
}
export function formatFileSize(value) {
const bytes = Number(value)
if (!Number.isFinite(bytes) || bytes < 0) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}

View File

@ -4,17 +4,18 @@ import { readFileSync } from 'node:fs'
import { compileFunction } from 'node:vm'
import { createRequire } from 'node:module'
import { computed, ref } from './vue.mjs'
import { formatFileSize, parseRuntimeLine, summarizeRuntime, timeParts } from '../../frontend/src/views/components/LogSettings/RuntimeLog.js'
const root = new URL('../../frontend/src/views/components/LogSettings/', import.meta.url)
const read = name => readFileSync(new URL(name, root), 'utf8')
const create = compileFunction(
read('LogSettings.js').replace(/^import .*$/gm, '').replace('export function', 'function') + '\nreturn useLogs()',
['computed', 'onBeforeUnmount', 'onMounted', 'ref', 'watch', 'http']
['computed', 'onBeforeUnmount', 'onMounted', 'ref', 'watch', 'http', 'formatFileSize', 'parseRuntimeLine', 'summarizeRuntime', 'timeParts']
)
function fixture(get) {
let dispose
const page = create(computed, callback => { dispose = callback }, () => {}, ref, () => {}, { get })
const page = create(computed, callback => { dispose = callback }, () => {}, ref, () => {}, { get }, formatFileSize, parseRuntimeLine, summarizeRuntime, timeParts)
return { page, dispose: () => dispose() }
}
@ -91,3 +92,59 @@ test('慢响应及卸载后的响应不得覆盖最新日志', async () => {
await last
assert.equal(page.items.value[0].id, 'new')
})
test('运行日志拆分时间级别来源及请求标识,同时保留完整原文', () => {
const raw = '[2026-09-08T14:55:23.2188949Z] [warning][2026-09-08 22:55:23][request-42][git.pcux.cn/xuans/Smail/res_init/init.go:423]开发模式:已停用恢复通道'
const row = parseRuntimeLine(raw, 8)
assert.equal(row.sequence, 8)
assert.equal(row.level, 'warning')
assert.equal(row.label, '警告')
assert.equal(row.type, 'warning')
assert.equal(row.requestId, 'request-42')
assert.equal(row.sourceShort, 'res_init/init.go:423')
assert.equal(row.message, '开发模式:已停用恢复通道')
assert.equal(row.raw, raw)
assert.equal(row.time, timeParts(row.createdAt).time)
})
test('无请求标识、Windows 代码路径与原文方括号均能安全展示', () => {
const row = parseRuntimeLine('[2026-09-08T14:55:23Z] [info][2026-09-08 22:55:23][D:\\source\\app.go:39][业务]启动成功\\n下一行', 1)
assert.equal(row.source, 'D:\\source\\app.go:39')
assert.equal(row.requestId, '')
assert.equal(row.message, '[业务]启动成功\\n下一行')
assert.equal(row.level, 'info')
})
test('未知格式和非法时间不丢失消息,分隔线只改摘要不改原文', () => {
for (const raw of ['[2026-99-99T00:00:00Z] 错误', '<img src=x onerror=alert(1)>', '[业务]消息', '']) {
const row = parseRuntimeLine(raw, 1)
assert.equal(row.raw, raw)
assert.equal(row.message, raw)
assert.equal(row.level, 'unknown')
assert.equal(row.time, '—')
}
const row = parseRuntimeLine('[2026-09-08T14:55:23Z] [info][2026-09-08 22:55:23][app.go:39]********', 1)
assert.equal(row.summary, '启动分隔线')
assert.equal(row.message, '********')
assert.equal(parseRuntimeLine('[2026-09-08T14:55:23Z] [业务]保留内容', 2).message, '[业务]保留内容')
})
test('本页级别统计准确,压缩文件大小和空页区间明确', () => {
const rows = ['info', 'warn', 'error', 'fatal', 'panic', 'debug'].map(level => (
parseRuntimeLine(`[2026-09-08T14:55:23Z] [${level}][2026-09-08 22:55:23][app.go:39]内容`, 1)
))
assert.deepEqual(summarizeRuntime(rows), { total: 6, info: 1, warning: 1, error: 3 })
assert.equal(formatFileSize(2048), '2.0 KB')
assert.equal(formatFileSize(0), '0 B')
assert.equal(formatFileSize(undefined), '—')
const { page } = fixture(async () => ({ errorNo: 0, data: {} }))
assert.equal(page.pageRange.value, '暂无记录')
page.category.value = 'runtime'
page.lines.value = rows.map(row => row.raw)
page.offset.value = 8
assert.equal(page.runtimeRows.value[0].sequence, 9)
assert.equal(page.pageRange.value, '第 9–14 条')
assert.deepEqual(page.runtimeSummary.value, summarizeRuntime(rows))
assert.match(read('LogSettings.vue'), /本页日志统计/)
assert.doesNotMatch(read('LogSettings.vue'), /lines\.join/)
})