This commit is contained in:
parent
5f442354a1
commit
d5b2f9c7be
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
73
frontend/src/views/components/LogSettings/RuntimeLog.js
Normal file
73
frontend/src/views/components/LogSettings/RuntimeLog.js
Normal 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`
|
||||
}
|
||||
@ -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/)
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user