feat(web/logs): 日志管理页与 JSON 折叠组件
新增日志管理页(实时诊断/文件浏览/日志分析三区,echarts 按需引入绘制标签分布、日志量、字段时序图, 图表实例与定时器在卸载时释放);新增 api/logs 封装后端日志接口;新增通用 JsonFold 折叠查看组件 (折叠子树惰性渲染 v-if,避免大 JSON 一次性铺满 DOM)。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user