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,811 @@
<template>
<PermissionGuard widget-id="ConfigCenter">
<div class="logview">
<!-- 顶部概览 -->
<el-card shadow="never" class="ov-card">
<div class="ov-head">
<div class="ov-title">
<el-icon><Document /></el-icon>
<span>日志管理</span>
<el-tag size="small" effect="plain" type="info">Diagnosis · Post / Toast / DLog</el-tag>
</div>
<el-button size="small" :icon="Refresh" :loading="overviewLoading" @click="refreshAll">刷新</el-button>
</div>
<p class="ov-desc">
<b>实时诊断</b>直连 SimpleLite 内核 <code>Diagnosis</code> 的内存态 <code>Post</code> / <code>Toast</code>
有标签按标签合订无标签滚动记录<b>日志文件</b>浏览工作目录 <code>log/</code> 下的落盘日志DLog
</p>
<div v-if="overview" class="ov-stats">
<div class="ov-stat"><span class="k">日志根目录</span><span class="v path" :title="overview.root ?? ''">{{ overview.root ?? '—' }}</span></div>
<div class="ov-stat"><span class="k">文件数</span><span class="v">{{ overview.totalFiles ?? 0 }}</span></div>
<div class="ov-stat"><span class="k">总大小</span><span class="v">{{ formatBytes(overview.totalBytes ?? 0) }}</span></div>
<div class="ov-stat"><span class="k">最近写入</span><span class="v">{{ overview.latestFileTime ? formatTime(overview.latestFileTime) : '—' }}</span></div>
</div>
</el-card>
<!-- 主体两视图 -->
<el-card shadow="never" class="body-card" body-style="padding: 0 14px 14px">
<el-tabs v-model="activeTab" class="log-tabs">
<!-- 实时诊断 -->
<el-tab-pane name="live">
<template #label><el-icon><DataLine /></el-icon><span class="tab-lbl">实时诊断</span></template>
<div class="toolbar">
<el-switch v-model="autoRefresh" size="small" inline-prompt active-text="自动" inactive-text="手动" />
<el-select v-model="intervalSec" size="small" class="sel-interval" :disabled="!autoRefresh">
<el-option :value="1" label="每 1 秒" />
<el-option :value="2" label="每 2 秒" />
<el-option :value="3" label="每 3 秒" />
<el-option :value="5" label="每 5 秒" />
</el-select>
<el-button size="small" :icon="Refresh" :loading="liveLoading" @click="loadLive">刷新</el-button>
<el-input v-model="liveKeyword" size="small" clearable placeholder="标签 / 内容过滤" class="sel-kw" :prefix-icon="Search" />
<el-checkbox v-model="liveOnlyTagged" size="small">仅带标签</el-checkbox>
<div class="spacer" />
<span v-if="live" class="muted">
合订 {{ live.taggedCount }} · 滚动 {{ live.untaggedCount }} · {{ live.total }}
<span v-if="live.serverTime"> · 更新 {{ formatClock(live.serverTime) }}</span>
</span>
</div>
<el-alert v-if="liveError" type="warning" :closable="false" show-icon :title="liveError" style="margin: 0 0 10px" />
<el-table
v-loading="liveLoading && liveFirstLoad"
:data="filteredLive" size="small"
max-height="calc(100vh - 392px)"
:row-class-name="liveRowClass"
class="live-table" @row-dblclick="(r: LiveDiagItem) => showDetail(r)">
<el-table-column label="时间" width="130">
<template #default="{ row }">{{ formatClock(row.time) }}</template>
</el-table-column>
<el-table-column label="标签" width="170">
<template #default="{ row }">
<el-tag v-if="row.tagged" size="small" effect="dark">{{ row.tag }}</el-tag>
<span v-else class="muted">— 滚动</span>
</template>
</el-table-column>
<el-table-column label="内容" show-overflow-tooltip>
<template #default="{ row }"><span class="mono">{{ row.content }}</span></template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template #default="{ row }">
<el-button link type="primary" :icon="View" @click="showDetail(row)" />
</template>
</el-table-column>
<template #empty>
<span class="muted">{{ liveError ? 'SimpleLite 未连接' : '暂无诊断消息' }}</span>
</template>
</el-table>
</el-tab-pane>
<!-- 日志文件 -->
<el-tab-pane name="files">
<template #label><el-icon><FolderOpened /></el-icon><span class="tab-lbl">日志文件</span></template>
<div class="toolbar">
<el-button size="small" :icon="Back" :disabled="browse?.parent == null" @click="enterDir(browse?.parent ?? '')">上一级</el-button>
<el-breadcrumb separator="/" class="crumbs">
<el-breadcrumb-item v-for="c in crumbs" :key="c.path">
<a class="crumb" @click="enterDir(c.path)">{{ c.label }}</a>
</el-breadcrumb-item>
</el-breadcrumb>
<el-button size="small" :icon="Refresh" :loading="browseLoading" @click="loadBrowse">刷新</el-button>
<el-input v-model="browseKeyword" size="small" clearable placeholder="按名称过滤" class="sel-kw" :prefix-icon="Search" />
<div class="spacer" />
<span v-if="browse" class="muted">{{ browse.dirCount }} 个文件夹 · {{ browse.fileCount }} 个文件</span>
</div>
<el-alert v-if="browse && !browse.exists" type="info" :closable="false" show-icon
:title="browse.message || '日志目录尚未生成。'" style="margin: 0 0 10px" />
<el-table
v-loading="browseLoading" :data="browseRows" size="small"
max-height="calc(100vh - 392px)"
class="files-table" @row-dblclick="onRowActivate">
<el-table-column label="名称" min-width="280">
<template #default="{ row }">
<span class="name-cell" :class="{ 'is-dir': row.kind === 'dir' }">
<el-icon><component :is="row.kind === 'dir' ? Folder : Document" /></el-icon>
<span class="mono">{{ row.name }}</span>
</span>
</template>
</el-table-column>
<el-table-column label="类型" width="160">
<template #default="{ row }">
<span v-if="row.kind === 'dir'" class="muted">文件夹({{ row.fileCount }} 文件)</span>
<el-tag v-else size="small" effect="plain" :type="row.isLog ? 'success' : 'info'">{{ row.ext || '文件' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="大小" width="110" align="right">
<template #default="{ row }">{{ row.kind === 'dir' ? '—' : formatBytes(row.bytes) }}</template>
</el-table-column>
<el-table-column label="修改时间" width="180">
<template #default="{ row }">{{ formatTime(row.mtime) }}</template>
</el-table-column>
<el-table-column label="操作" width="240" align="center">
<template #default="{ row }">
<template v-if="row.kind === 'dir'">
<el-button link type="primary" :icon="FolderOpened" @click="enterDir(row.rel)">进入</el-button>
</template>
<template v-else>
<el-button link type="primary" :icon="View" @click="openFile(row.rel)">打开</el-button>
<el-button v-if="row.isLog" link type="primary" :icon="TrendCharts" @click="analyzeFileFromBrowser(row.rel)">分析</el-button>
<el-button link type="primary" :icon="Download" @click="download(row.rel)">下载</el-button>
</template>
</template>
</el-table-column>
<template #empty><span class="muted">该目录为空</span></template>
</el-table>
</el-tab-pane>
<!-- 日志分析 -->
<el-tab-pane name="analyze">
<template #label><el-icon><TrendCharts /></el-icon><span class="tab-lbl">日志分析</span></template>
<div v-if="!analyzeFile" class="analyze-empty">
<el-empty description="日志文件选择一个 .log 文件进行分析">
<el-button type="primary" :icon="FolderOpened" @click="gotoFiles">去选择日志文件</el-button>
</el-empty>
</div>
<template v-else>
<div class="toolbar">
<el-tag size="small" type="info" effect="plain" class="an-target">
<el-icon><Document /></el-icon><span class="mono">{{ analyzeFile }}</span>
</el-tag>
<el-button size="small" :icon="FolderOpened" @click="gotoFiles">换文件</el-button>
<div class="spacer" />
<span class="muted">粒度</span>
<el-select v-model="anGran" size="small" class="sel-interval">
<el-option value="second" label="按秒" />
<el-option value="minute" label="按分" />
<el-option value="hour" label="按时" />
</el-select>
<el-select v-model="anTag" size="small" clearable placeholder="聚焦标签" class="sel-tag">
<el-option v-for="t in (analysis?.tags ?? [])" :key="t.tag || '__none__'" :value="t.tag" :label="`${tagLabel(t.tag)} (${t.count})`" />
</el-select>
<el-input v-model="anKeyword" size="small" clearable placeholder="关键字过滤" class="sel-kw" :prefix-icon="Search" @keyup.enter="loadAnalyze" />
<el-button size="small" type="primary" :icon="Refresh" :loading="analyzeLoading" @click="loadAnalyze">分析</el-button>
</div>
<el-alert v-if="analysis?.truncated" type="warning" :closable="false" show-icon
title="文件较大仅分析了前部分内容统计为近似值" style="margin: 0 0 10px" />
<div v-if="analysis" class="an-stats">
<div class="ov-stat"><span class="k">总条数</span><span class="v">{{ analysis.total }}</span></div>
<div class="ov-stat"><span class="k">标签数</span><span class="v">{{ analysis.tags.length }}</span></div>
<div class="ov-stat"><span class="k">数值字段</span><span class="v">{{ analysis.fields.length }}</span></div>
<div class="ov-stat"><span class="k">时间范围</span><span class="v">{{ anTimeRange }}</span></div>
</div>
<div class="an-grid" v-loading="analyzeLoading">
<el-card shadow="never" class="an-card">
<div class="an-card-title">标签分布(Top 12</div>
<div ref="tagChartRef" class="an-chart"></div>
</el-card>
<el-card shadow="never" class="an-card">
<div class="an-card-title">日志量随时间(Top 标签堆叠 + 总量)</div>
<div ref="volChartRef" class="an-chart"></div>
</el-card>
</div>
<el-card shadow="never" class="an-card an-fields">
<div class="an-card-title">
数值字段 <span class="muted">(识别 key=value / key:value,点「绘制」查看时序)</span>
</div>
<div class="an-fields-body">
<el-table :data="analysis?.fields ?? []" size="small" max-height="280" class="fields-table">
<el-table-column label="字段" min-width="110"><template #default="{ row }"><span class="mono">{{ row.name }}</span></template></el-table-column>
<el-table-column label="样本" width="76" align="right"><template #default="{ row }">{{ row.samples }}</template></el-table-column>
<el-table-column label="最小" width="86" align="right"><template #default="{ row }">{{ row.min }}</template></el-table-column>
<el-table-column label="最大" width="86" align="right"><template #default="{ row }">{{ row.max }}</template></el-table-column>
<el-table-column label="均值" width="96" align="right"><template #default="{ row }">{{ row.avg }}</template></el-table-column>
<el-table-column label="操作" width="92" align="center">
<template #default="{ row }">
<el-button link :type="anField === row.name ? 'success' : 'primary'" @click="pickField(row.name)">
{{ anField === row.name ? '已绘制' : '绘制' }}
</el-button>
</template>
</el-table-column>
<template #empty><span class="muted">未识别到数值字段</span></template>
</el-table>
<div class="an-field-chart-wrap">
<div v-show="anField" ref="fieldChartRef" class="an-chart tall"></div>
<div v-show="!anField" class="an-field-hint muted">选择左侧一个数值字段查看其时序曲线</div>
</div>
</div>
</el-card>
</template>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 条目详情 -->
<el-dialog v-model="detailVisible" title="日志条目详情" width="680px" append-to-body>
<template v-if="detailItem">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="时间">{{ detailItem.time ? formatTime(detailItem.time) : '(无时间戳)' }}</el-descriptions-item>
<el-descriptions-item label="标签">
<el-tag v-if="detailItem.tag" size="small" effect="dark">{{ detailItem.tag }}</el-tag>
<span v-else class="muted">(无,滚动记录)</span>
</el-descriptions-item>
</el-descriptions>
<div class="detail-content">
<div class="detail-bar">
<span>内容</span>
<el-button size="small" link type="primary" :icon="CopyDocument" @click="copy(detailItem.content)">复制</el-button>
</div>
<pre class="detail-pre">{{ detailItem.content }}</pre>
</div>
</template>
</el-dialog>
<!-- 打开文件抽屉 -->
<el-drawer v-model="fileDrawer" :title="fileRel" size="64%" append-to-body>
<div class="file-bar">
<el-radio-group v-model="fileMode" size="small" @change="onFileModeChange">
<el-radio-button label="structured">结构化</el-radio-button>
<el-radio-button label="raw">原文</el-radio-button>
</el-radio-group>
<template v-if="fileMode === 'structured'">
<el-input v-model="fileKeyword" size="small" clearable placeholder="内容 / 标签过滤" class="sel-kw" :prefix-icon="Search" />
<el-checkbox v-model="fileOnlyTagged" size="small">仅带标签</el-checkbox>
</template>
<template v-else>
<el-select v-model="rawTail" size="small" class="sel-tail" @change="reloadFile">
<el-option :value="1000" label="尾部 1000 " />
<el-option :value="3000" label="尾部 3000 " />
<el-option :value="10000" label="尾部 1 万行" />
<el-option :value="50000" label="尾部 5 万行" />
</el-select>
</template>
<span v-if="fileMode === 'raw' && rawText" class="muted">{{ rawLineCount }} 行</span>
<span v-else-if="fileMode === 'structured' && fileEntries" class="muted">{{ filteredFileEntries.length }} / {{ fileEntries.total }} 条</span>
<div class="spacer" />
<el-button v-if="fileRel.toLowerCase().endsWith('.log')" size="small" type="primary" plain :icon="TrendCharts" @click="analyzeFileFromBrowser(fileRel)">分析此文件</el-button>
<el-button size="small" :icon="Download" @click="download(fileRel)">下载</el-button>
<el-button size="small" :icon="Refresh" :loading="fileLoading" @click="reloadFile">刷新</el-button>
</div>
<el-alert v-if="fileMode === 'structured' && fileEntries?.truncated" type="warning" :closable="false" show-icon
title="文件较大仅解析了前部分条目" style="margin: 0 0 10px" />
<el-table
v-if="fileMode === 'structured'"
v-loading="fileLoading" :data="filteredFileEntries" size="small"
max-height="calc(100vh - 220px)" class="entries-table"
@row-dblclick="(r: LogEntry) => showDetail({ time: r.time, tag: r.tag, content: r.content })">
<el-table-column label="时间" width="190">
<template #default="{ row }">{{ row.time ? formatTime(row.time) : '—' }}</template>
</el-table-column>
<el-table-column label="标签" width="160">
<template #default="{ row }">
<el-tag v-if="row.tag" size="small" effect="dark">{{ row.tag }}</el-tag>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="内容" show-overflow-tooltip>
<template #default="{ row }"><span class="mono">{{ row.content }}</span></template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template #default="{ row }">
<el-button link type="primary" :icon="View" @click="showDetail({ time: row.time, tag: row.tag, content: row.content })" />
</template>
</el-table-column>
</el-table>
<pre v-else v-loading="fileLoading" class="raw-pre">{{ rawText }}</pre>
</el-drawer>
</div>
</PermissionGuard>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
Refresh, Document, Search, View, Download, CopyDocument,
Folder, FolderOpened, Back, DataLine, TrendCharts
} from '@element-plus/icons-vue'
// 按需引入 echarts:仅 bar/line 图 + grid/tooltip/legend/dataZoom 组件 + canvas 渲染器,
// 避免全量 echarts(~1MB) 打进 bundle。
import * as echarts from 'echarts/core'
import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import PermissionGuard from '@/components/PermissionGuard.vue'
import {
logsApi,
type LogOverview, type LiveDiagnosis, type LiveDiagItem,
type BrowseResult, type LogEntry, type LogEntriesResult,
type AnalyzeResult
} from '@/api/logs'
echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer])
type Tab = 'live' | 'files' | 'analyze'
interface DetailItem { time: string | null; tag: string; content: string }
interface BrowseRow {
kind: 'dir' | 'file'
name: string
rel: string
mtime: string
bytes: number
fileCount: number
isLog: boolean
ext: string
}
const activeTab = ref<Tab>('live')
const overview = ref<LogOverview | null>(null)
const overviewLoading = ref(false)
// ── 实时诊断 ──
const live = ref<LiveDiagnosis | null>(null)
const liveLoading = ref(false)
const liveFirstLoad = ref(true)
const liveError = ref('')
const autoRefresh = ref(true)
const intervalSec = ref(2)
const liveKeyword = ref('')
const liveOnlyTagged = ref(false)
let timer: number | undefined
// ── 文件浏览 ──
const browse = ref<BrowseResult | null>(null)
const browseLoading = ref(false)
const browsePath = ref('')
const browseKeyword = ref('')
// ── 详情 ──
const detailVisible = ref(false)
const detailItem = ref<DetailItem | null>(null)
// ── 文件抽屉 ──
const fileDrawer = ref(false)
const fileRel = ref('')
const fileMode = ref<'structured' | 'raw'>('structured')
const fileLoading = ref(false)
const fileEntries = ref<LogEntriesResult | null>(null)
const rawText = ref('')
const rawTail = ref(3000)
const fileKeyword = ref('')
const fileOnlyTagged = ref(false)
const rawLineCount = computed(() => (rawText.value ? rawText.value.split('\n').length : 0))
function formatBytes(n: number): string {
if (!n) return '0 B'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(2)} MB`
}
function formatTime(iso: string): string {
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN', { hour12: false })
}
function formatClock(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleTimeString('zh-CN', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
}
// ── 实时诊断逻辑 ──
function tms(s: string): number {
const d = new Date(s).getTime()
return Number.isNaN(d) ? 0 : d
}
const filteredLive = computed<LiveDiagItem[]>(() => {
let list = live.value?.items ?? []
if (liveOnlyTagged.value) list = list.filter((i) => i.tagged)
const kw = liveKeyword.value.trim().toLowerCase()
if (kw) list = list.filter((i) => i.content.toLowerCase().includes(kw) || i.tag.toLowerCase().includes(kw))
// 最新置顶:按时间倒序(合订条目用其最新时间)。
return [...list].sort((a, b) => tms(b.time) - tms(a.time))
})
function liveRowClass({ row }: { row: LiveDiagItem }): string {
return row.tagged ? 'tagged-row' : ''
}
async function loadLive() {
liveLoading.value = true
try {
live.value = await logsApi.liveDiagnosis()
liveError.value = ''
} catch (e) {
liveError.value = `实时诊断不可用:${(e as Error).message}`
} finally {
liveLoading.value = false
liveFirstLoad.value = false
}
}
function startTimer() {
stopTimer()
if (autoRefresh.value && activeTab.value === 'live') {
timer = window.setInterval(loadLive, intervalSec.value * 1000)
}
}
function stopTimer() {
if (timer) { window.clearInterval(timer); timer = undefined }
}
watch([autoRefresh, intervalSec], startTimer)
watch(activeTab, (t) => {
if (t === 'live') { void loadLive(); startTimer() } else { stopTimer() }
if (t === 'analyze') nextTick(renderAnalyzeCharts)
})
// ── 文件浏览逻辑 ──
const crumbs = computed(() => {
const segs = browsePath.value ? browsePath.value.split('/') : []
const acc: Array<{ label: string; path: string }> = [{ label: 'log', path: '' }]
let cur = ''
for (const s of segs) { cur = cur ? `${cur}/${s}` : s; acc.push({ label: s, path: cur }) }
return acc
})
const browseRows = computed<BrowseRow[]>(() => {
const b = browse.value
if (!b) return []
const dirs: BrowseRow[] = b.dirs.map((d) => ({
kind: 'dir', name: d.name, rel: d.rel, mtime: d.mtime, bytes: 0, fileCount: d.fileCount, isLog: false, ext: ''
}))
const files: BrowseRow[] = b.files.map((f) => ({
kind: 'file', name: f.name, rel: f.rel, mtime: f.mtime, bytes: f.bytes, fileCount: 0, isLog: f.isLog,
ext: f.name.includes('.') ? f.name.slice(f.name.lastIndexOf('.')) : ''
}))
let rows = [...dirs, ...files]
const kw = browseKeyword.value.trim().toLowerCase()
if (kw) rows = rows.filter((r) => r.name.toLowerCase().includes(kw))
return rows
})
async function loadBrowse() {
browseLoading.value = true
try {
browse.value = await logsApi.browse(browsePath.value || undefined)
browsePath.value = browse.value.path
} catch (e) {
ElMessage.error(`浏览目录失败:${(e as Error).message}`)
} finally {
browseLoading.value = false
}
}
function enterDir(path: string) {
browsePath.value = path
void loadBrowse()
}
function onRowActivate(row: BrowseRow) {
if (row.kind === 'dir') enterDir(row.rel)
else openFile(row.rel)
}
// ── 文件抽屉逻辑 ──
function openFile(rel: string) {
fileRel.value = rel
fileDrawer.value = true
fileKeyword.value = ''
fileOnlyTagged.value = false
fileEntries.value = null
rawText.value = ''
fileMode.value = rel.toLowerCase().endsWith('.log') ? 'structured' : 'raw'
void loadFileContent()
}
async function loadFileContent() {
fileLoading.value = true
try {
if (fileMode.value === 'structured') {
fileEntries.value = await logsApi.entries({ file: fileRel.value, order: 'desc', limit: 1000 })
} else {
rawText.value = await logsApi.raw(fileRel.value, rawTail.value)
}
} catch (e) {
if (fileMode.value === 'raw') rawText.value = `读取失败:${(e as Error).message}`
else ElMessage.error(`解析失败:${(e as Error).message}`)
} finally {
fileLoading.value = false
}
}
function onFileModeChange() {
if (fileMode.value === 'structured' && !fileEntries.value) void loadFileContent()
else if (fileMode.value === 'raw' && !rawText.value) void loadFileContent()
}
function reloadFile() {
if (fileMode.value === 'structured') fileEntries.value = null
else rawText.value = ''
void loadFileContent()
}
const filteredFileEntries = computed<LogEntry[]>(() => {
let list = fileEntries.value?.entries ?? []
if (fileOnlyTagged.value) list = list.filter((e) => e.tag)
const kw = fileKeyword.value.trim().toLowerCase()
if (kw) list = list.filter((e) => e.content.toLowerCase().includes(kw) || e.tag.toLowerCase().includes(kw))
return list
})
// ── 日志分析器 ──
const analyzeFile = ref('')
const analyzeLoading = ref(false)
const analysis = ref<AnalyzeResult | null>(null)
const anGran = ref<'second' | 'minute' | 'hour'>('minute')
const anTag = ref('')
const anField = ref('')
const anKeyword = ref('')
const tagChartRef = ref<HTMLDivElement | null>(null)
const volChartRef = ref<HTMLDivElement | null>(null)
const fieldChartRef = ref<HTMLDivElement | null>(null)
type EChartsInstance = ReturnType<typeof echarts.init>
let tagInst: EChartsInstance | null = null
let volInst: EChartsInstance | null = null
let fieldInst: EChartsInstance | null = null
const CHART_PALETTE = ['#7c3aed', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#06b6d4', '#ec4899', '#84cc16']
function cssVar(name: string, fallback: string): string {
if (typeof window === 'undefined') return fallback
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return v || fallback
}
function tagLabel(t: string): string { return t === '' ? '(滚动记录)' : t }
const anTimeRange = computed(() => {
const r = analysis.value?.timeRange
if (!r?.start || !r?.end) return '—'
return `${formatTime(r.start)} ~ ${formatTime(r.end)}`
})
async function loadAnalyze() {
if (!analyzeFile.value) return
analyzeLoading.value = true
try {
analysis.value = await logsApi.analyze({
file: analyzeFile.value,
granularity: anGran.value,
tag: anTag.value || undefined,
field: anField.value || undefined,
keyword: anKeyword.value.trim() || undefined
})
} catch (e) {
ElMessage.error(`分析失败:${(e as Error).message}`)
} finally {
analyzeLoading.value = false
}
}
function analyzeFileFromBrowser(rel: string) {
fileDrawer.value = false
analyzeFile.value = rel
anTag.value = ''
anField.value = ''
anKeyword.value = ''
activeTab.value = 'analyze'
void loadAnalyze()
}
function pickField(name: string) {
anField.value = anField.value === name ? '' : name
}
function gotoFiles() { activeTab.value = 'files' }
watch([anGran, anTag, anField], () => { if (analyzeFile.value) void loadAnalyze() })
// ── echarts 渲染 ──
function renderTagChart() {
if (!tagChartRef.value) return
const a = analysis.value
tagInst?.dispose()
tagInst = echarts.init(tagChartRef.value, undefined, { renderer: 'canvas' })
const textMuted = cssVar('--mg-text-muted', '#909399')
const grid = cssVar('--mg-divider', 'rgba(255,255,255,0.08)')
const top = (a?.tags ?? []).slice(0, 12).slice().reverse()
tagInst.setOption({
backgroundColor: 'transparent',
grid: { left: 8, right: 28, top: 10, bottom: 6, containLabel: true },
tooltip: {
trigger: 'axis', axisPointer: { type: 'shadow' },
formatter: (params: unknown) => {
const p = (params as Array<{ name: string; value: number }>)[0]
return `${p.name}: <b>${p.value}</b> 条`
}
},
xAxis: { type: 'value', axisLine: { show: false }, axisLabel: { color: textMuted, fontSize: 11 }, splitLine: { lineStyle: { color: grid, type: 'dashed' } } },
yAxis: { type: 'category', data: top.map((t) => tagLabel(t.tag)), axisLine: { lineStyle: { color: grid } }, axisLabel: { color: textMuted, fontSize: 11 } },
series: [{
type: 'bar', barMaxWidth: 16,
data: top.map((t, i) => ({ value: t.count, itemStyle: { color: CHART_PALETTE[(top.length - 1 - i) % CHART_PALETTE.length], borderRadius: [0, 3, 3, 0] } })),
label: { show: true, position: 'right', color: textMuted, fontSize: 11 }
}]
})
}
function renderVolChart() {
if (!volChartRef.value) return
const a = analysis.value
volInst?.dispose()
volInst = echarts.init(volChartRef.value, undefined, { renderer: 'canvas' })
const textMuted = cssVar('--mg-text-muted', '#909399')
const grid = cssVar('--mg-divider', 'rgba(255,255,255,0.08)')
const buckets = (a?.volume.buckets ?? []).map((t) => new Date(t).getTime())
const topTags = a?.volume.topTags ?? []
const series: Array<Record<string, unknown>> = topTags.map((tt, i) => ({
name: tagLabel(tt.tag), type: 'bar', stack: 'vol', barMaxWidth: 18,
data: buckets.map((b, j) => [b, tt.counts[j]]),
itemStyle: { color: CHART_PALETTE[i % CHART_PALETTE.length] }
}))
series.push({
name: '总量', type: 'line', smooth: true, showSymbol: false, z: 5,
data: buckets.map((b, j) => [b, a?.volume.total[j] ?? 0]),
lineStyle: { width: 2, color: cssVar('--mg-text-light', '#e5e7eb') }
})
volInst.setOption({
backgroundColor: 'transparent',
grid: { left: 36, right: 16, top: 30, bottom: 28, containLabel: true },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { top: 0, textStyle: { color: textMuted, fontSize: 11 }, itemHeight: 8, itemWidth: 12 },
xAxis: { type: 'time', axisLine: { lineStyle: { color: grid } }, axisLabel: { color: textMuted, fontSize: 11 }, splitLine: { show: false } },
yAxis: { type: 'value', axisLine: { show: false }, axisLabel: { color: textMuted, fontSize: 11 }, splitLine: { lineStyle: { color: grid, type: 'dashed' } } },
series
})
}
function renderFieldChart() {
if (!fieldChartRef.value) return
const a = analysis.value
fieldInst?.dispose()
fieldInst = echarts.init(fieldChartRef.value, undefined, { renderer: 'canvas' })
const textMuted = cssVar('--mg-text-muted', '#909399')
const grid = cssVar('--mg-divider', 'rgba(255,255,255,0.08)')
const accent = cssVar('--mg-accent', '#7c3aed')
const pts = a?.series?.points ?? []
fieldInst.setOption({
backgroundColor: 'transparent',
grid: { left: 44, right: 16, top: 18, bottom: pts.length > 60 ? 40 : 28, containLabel: true },
tooltip: { trigger: 'axis' },
dataZoom: pts.length > 60 ? [{ type: 'inside' }, { type: 'slider', height: 16, bottom: 4 }] : [],
xAxis: { type: 'time', axisLine: { lineStyle: { color: grid } }, axisLabel: { color: textMuted, fontSize: 11 }, splitLine: { show: false } },
yAxis: { type: 'value', scale: true, axisLine: { show: false }, axisLabel: { color: textMuted, fontSize: 11 }, splitLine: { lineStyle: { color: grid, type: 'dashed' } } },
series: [{
name: a?.series?.field ?? '', type: 'line', smooth: true, showSymbol: pts.length < 80, symbolSize: 4,
data: pts.map((p) => [new Date(p.t).getTime(), p.v]),
lineStyle: { width: 2, color: accent }, itemStyle: { color: accent },
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: accent + '44' }, { offset: 1, color: accent + '00' }] } }
}]
})
}
function renderAnalyzeCharts() {
renderTagChart()
renderVolChart()
renderFieldChart()
}
watch(analysis, () => { if (activeTab.value === 'analyze') nextTick(renderAnalyzeCharts) })
function onChartResize() { tagInst?.resize(); volInst?.resize(); fieldInst?.resize() }
// ── 通用 ──
function showDetail(item: DetailItem | LiveDiagItem) {
detailItem.value = { time: item.time, tag: item.tag, content: item.content }
detailVisible.value = true
}
function download(rel: string) {
const a = document.createElement('a')
a.href = logsApi.downloadUrl(rel)
a.download = rel.split('/').pop() ?? 'log.log'
document.body.appendChild(a)
a.click()
a.remove()
}
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择文本')
}
}
async function loadOverview() {
overviewLoading.value = true
try {
overview.value = await logsApi.overview()
} catch (e) {
ElMessage.error(`加载概览失败:${(e as Error).message}`)
} finally {
overviewLoading.value = false
}
}
async function refreshAll() {
await loadOverview()
if (activeTab.value === 'live') await loadLive()
else await loadBrowse()
}
onMounted(async () => {
await loadOverview()
await loadLive()
startTimer()
void loadBrowse()
window.addEventListener('resize', onChartResize)
})
onUnmounted(() => {
stopTimer()
window.removeEventListener('resize', onChartResize)
tagInst?.dispose(); tagInst = null
volInst?.dispose(); volInst = null
fieldInst?.dispose(); fieldInst = null
})
</script>
<style scoped>
.logview {
display: flex;
flex-direction: column;
gap: 14px;
flex: 1;
min-height: 0;
}
.ov-card :deep(.el-card__body) { padding: 16px 18px; }
.ov-head { display: flex; align-items: center; justify-content: space-between; }
.ov-title { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 600; color: var(--mg-text-light); }
.ov-desc { color: var(--mg-text-muted); font-size: 12.5px; line-height: 1.6; margin: 10px 0 12px; }
.ov-desc code { font-family: var(--mg-font-mono, monospace); color: var(--mg-accent); }
.ov-stats { display: grid; grid-template-columns: 2.2fr 0.8fr 0.8fr 1.4fr; gap: 12px; }
.ov-stat { display: flex; flex-direction: column; gap: 3px; }
.ov-stat .k { font-size: 11px; color: var(--mg-text-muted); }
.ov-stat .v { font-size: 13px; color: var(--mg-text-light); font-variant-numeric: tabular-nums; }
.ov-stat .v.path { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: var(--mg-font-mono, monospace); }
.body-card { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.body-card :deep(.el-card__body) { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.log-tabs { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.log-tabs :deep(.el-tabs__content) { flex: 1; min-height: 0; }
.tab-lbl { margin-left: 5px; }
.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 6px 0 12px; }
.toolbar .spacer { flex: 1; }
.sel-kw { width: 200px; }
.sel-interval { width: 120px; }
.muted { color: var(--mg-text-muted); font-size: 12px; }
.mono { font-family: var(--mg-font-mono, monospace); font-size: 12.5px; }
.crumbs { display: inline-flex; align-items: center; }
.crumb { cursor: pointer; color: var(--mg-accent); }
.crumb:hover { text-decoration: underline; }
.live-table, .files-table, .entries-table { width: 100%; }
.live-table :deep(.tagged-row) { background: rgba(var(--mg-primary-rgb), 0.06); }
.name-cell { display: inline-flex; align-items: center; gap: 6px; }
.name-cell.is-dir { color: var(--mg-accent); font-weight: 500; cursor: pointer; }
.detail-content { margin-top: 14px; }
.detail-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 13px; color: var(--mg-text-light); }
.detail-pre, .raw-pre {
margin: 0; padding: 12px;
font-family: var(--mg-font-mono, monospace); font-size: 12.5px; line-height: 1.55;
background: var(--mg-veil-2, rgba(0,0,0,0.25)); color: var(--mg-text-light);
border-radius: 8px; white-space: pre-wrap; word-break: break-all;
max-height: 360px; overflow: auto;
}
.raw-pre { max-height: calc(100vh - 200px); white-space: pre; word-break: normal; }
.file-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.file-bar .spacer { flex: 1; }
.sel-tail { width: 140px; }
/* ── 日志分析 ── */
.sel-tag { width: 200px; }
.analyze-empty { display: flex; align-items: center; justify-content: center; min-height: 320px; }
.an-target { max-width: 360px; display: inline-flex; align-items: center; gap: 4px; overflow: hidden; }
.an-target .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.an-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 0 0 12px; }
.an-grid { display: grid; grid-template-columns: 1fr 1.4fr; gap: 12px; margin-bottom: 12px; }
.an-card :deep(.el-card__body) { padding: 12px 14px; }
.an-card-title { font-size: 13px; font-weight: 600; color: var(--mg-text-light); margin-bottom: 8px; }
.an-card-title .muted { font-weight: 400; }
.an-chart { width: 100%; height: 260px; }
.an-chart.tall { height: 300px; }
.an-fields-body { display: grid; grid-template-columns: minmax(420px, 1fr) 1.2fr; gap: 14px; align-items: stretch; }
.an-field-chart-wrap { position: relative; min-height: 300px; border-left: 1px solid var(--mg-divider, rgba(255,255,255,0.08)); padding-left: 14px; }
.an-field-hint { display: flex; align-items: center; justify-content: center; height: 300px; }
@media (max-width: 1200px) {
.an-grid { grid-template-columns: 1fr; }
.an-fields-body { grid-template-columns: 1fr; }
.an-field-chart-wrap { border-left: none; border-top: 1px solid var(--mg-divider, rgba(255,255,255,0.08)); padding-left: 0; padding-top: 12px; }
}
</style>