feat(web/logs): 日志管理页与 JSON 折叠组件

新增日志管理页(实时诊断/文件浏览/日志分析三区,echarts 按需引入绘制标签分布、日志量、字段时序图,
图表实例与定时器在卸载时释放);新增 api/logs 封装后端日志接口;新增通用 JsonFold 折叠查看组件
(折叠子树惰性渲染 v-if,避免大 JSON 一次性铺满 DOM)。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-06-08 16:11:20 +08:00
co-authored by Cursor
parent c0a33723ca
commit 991502b4b1
4 changed files with 1433 additions and 0 deletions
@@ -0,0 +1,174 @@
<template>
<div class="jfn" :class="{ 'jfn--root': depth === 0 }">
<template v-if="isCollection">
<div class="jfn-line" @click="toggle">
<span class="jfn-toggle">{{ expanded ? '▼' : '▶' }}</span>
<span v-if="label" class="jfn-key">{{ label }}: </span>
<span class="jfn-brace">{{ openBrace }}</span>
<span v-if="!expanded" class="jfn-ellipsis">{{ collapsedHint }}</span>
<span v-if="!expanded" class="jfn-brace">{{ closeBrace }}</span>
</div>
<div v-if="expanded" class="jfn-children">
<JsonFoldNode
v-for="(entry, idx) in entries"
:key="entry.key"
:label="entry.label"
:value="entry.value"
:depth="depth + 1"
:comma="idx < entries.length - 1"
/>
<div class="jfn-line jfn-close">
<span class="jfn-brace">{{ closeBrace }}</span>
<span v-if="comma" class="jfn-comma">,</span>
</div>
</div>
</template>
<div v-else class="jfn-line jfn-primitive">
<span v-if="label" class="jfn-key">{{ label }}: </span>
<span :class="primitiveClass">{{ primitiveText }}</span>
<span v-if="comma" class="jfn-comma">,</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import JsonFoldNode from './JsonFoldNode.vue'
const props = withDefaults(
defineProps<{
value: unknown
label?: string
depth?: number
comma?: boolean
}>(),
{
depth: 0,
comma: false
}
)
const expanded = ref(props.depth < 1)
const isArray = computed(() => Array.isArray(props.value))
const isObject = computed(
() => props.value !== null && typeof props.value === 'object' && !isArray.value
)
const isCollection = computed(() => isArray.value || isObject.value)
const openBrace = computed(() => (isArray.value ? '[' : '{'))
const closeBrace = computed(() => (isArray.value ? ']' : '}'))
const entries = computed(() => {
if (isArray.value) {
return (props.value as unknown[]).map((v, i) => ({
key: String(i),
label: String(i),
value: v
}))
}
if (isObject.value) {
return Object.entries(props.value as Record<string, unknown>).map(([k, v]) => ({
key: k,
label: JSON.stringify(k),
value: v
}))
}
return []
})
const collapsedHint = computed(() => {
const n = entries.value.length
if (isArray.value) return `${n} items `
return `${n} keys `
})
const primitiveClass = computed(() => {
const t = typeof props.value
if (t === 'string') return 'jfn-val jfn-val--str'
if (t === 'number') return 'jfn-val jfn-val--num'
if (t === 'boolean') return 'jfn-val jfn-val--bool'
return 'jfn-val jfn-val--null'
})
const primitiveText = computed(() => {
if (props.value === null) return 'null'
if (props.value === undefined) return 'undefined'
if (typeof props.value === 'string') return JSON.stringify(props.value)
return String(props.value)
})
function toggle() {
expanded.value = !expanded.value
}
</script>
<style scoped>
.jfn {
font-family: var(--mg-font-mono, Consolas, monospace);
font-size: 12px;
line-height: 1.55;
color: var(--mg-text-light, #e8e0f0);
}
.jfn-line {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 2px;
cursor: default;
user-select: text;
}
.jfn-line:not(.jfn-primitive):not(.jfn-close) {
cursor: pointer;
}
.jfn-toggle {
display: inline-block;
width: 14px;
flex-shrink: 0;
color: var(--mg-text-muted, #9a8fb0);
font-size: 10px;
}
.jfn-children {
padding-left: 16px;
border-left: 1px solid rgba(255, 255, 255, 0.06);
margin-left: 6px;
}
.jfn-key {
color: #c792ea;
}
.jfn-brace {
color: #89ddff;
}
.jfn-ellipsis {
color: var(--mg-text-muted, #9a8fb0);
font-style: italic;
}
.jfn-comma {
color: var(--mg-text-muted, #9a8fb0);
}
.jfn-val--str {
color: #c3e88d;
}
.jfn-val--num {
color: #f78c6c;
}
.jfn-val--bool {
color: #ffcb6b;
}
.jfn-val--null {
color: #89ddff;
font-style: italic;
}
</style>
@@ -0,0 +1,46 @@
<template>
<div class="json-fold-viewer">
<JsonFoldNode v-if="parsed !== undefined" :value="parsed" :depth="0" />
<pre v-else-if="raw" class="json-fold-fallback">{{ raw }}</pre>
<el-empty v-else description="无 JSON 内容" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import JsonFoldNode from './JsonFoldNode.vue'
const props = defineProps<{
raw?: string
data?: unknown
}>()
const parsed = computed(() => {
if (props.data !== undefined) return props.data
if (!props.raw?.trim()) return undefined
try {
return JSON.parse(props.raw) as unknown
} catch {
return undefined
}
})
</script>
<style scoped>
.json-fold-viewer {
flex: 1;
min-height: 0;
overflow: auto;
padding: 4px 2px;
}
.json-fold-fallback {
margin: 0;
font-family: var(--mg-font-mono, Consolas, monospace);
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-all;
color: var(--mg-text-light);
}
</style>