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,402 @@
|
|||||||
|
import http from './http'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「日志管理」前端胶水:对应 MiGu.Server `LogsController`(`/api/logs/*`)。
|
||||||
|
*
|
||||||
|
* 数据是 SimpleLite 内核 `Diagnosis.Post / Diagnosis.Log` 写到工作目录 `log/{日期}/xxx.log` 的落盘日志
|
||||||
|
* (俗称 DLog)。后端直接读文件并解析为结构化条目,提供:概览 / 文件列表 / 条目分页 /
|
||||||
|
* 「按标签合订」/ 原文 / 下载。
|
||||||
|
*
|
||||||
|
* 与 config.ts 一致直接返回数据对象(非 reflection 的 success/data 信封);错误由 http.ts
|
||||||
|
* 拦截器翻译成中文。VITE_USE_MOCK=true 时返回内置样例,便于无后端调试界面。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||||
|
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
|
||||||
|
|
||||||
|
/** SimpleLite projection 投影 API 统一信封(同 reflection.ts)。 */
|
||||||
|
interface SlEnvelope<T> { success: boolean; code: number; data: T | null; message: string }
|
||||||
|
|
||||||
|
/** 经 YARP 反代到 SimpleLite EmbedIO 的诊断投影端点(/api/sl/projection/diagnosis)。 */
|
||||||
|
const SL_DIAG = '/sl/projection/diagnosis'
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
lineNo: number
|
||||||
|
/** ISO 时间;无法解析时间戳的续行/异常行为 null。 */
|
||||||
|
time: string | null
|
||||||
|
prefix: string
|
||||||
|
/** 空串表示无标签(滚动记录)。 */
|
||||||
|
tag: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogFile {
|
||||||
|
/** 相对日志根的路径,如 `2026-06-01/20260601-12Q(30).log`,作为其它接口的 file 参数。 */
|
||||||
|
rel: string
|
||||||
|
name: string
|
||||||
|
/** 一级目录(通常是日期),无子目录时为「(根目录)」。 */
|
||||||
|
day: string
|
||||||
|
dir: string
|
||||||
|
bytes: number
|
||||||
|
mtime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogDayStat {
|
||||||
|
day: string
|
||||||
|
files: number
|
||||||
|
bytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogOverview {
|
||||||
|
exists: boolean
|
||||||
|
root: string | null
|
||||||
|
workingDirectory: string | null
|
||||||
|
totalFiles?: number
|
||||||
|
totalBytes?: number
|
||||||
|
latestFileTime?: string | null
|
||||||
|
days?: LogDayStat[]
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogFilesResult {
|
||||||
|
root: string
|
||||||
|
total: number
|
||||||
|
returned: number
|
||||||
|
files: LogFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntriesResult {
|
||||||
|
file: string
|
||||||
|
bytes: number
|
||||||
|
scannedLines: number
|
||||||
|
truncated: boolean
|
||||||
|
total: number
|
||||||
|
offset: number
|
||||||
|
limit: number
|
||||||
|
order: string
|
||||||
|
entries: LogEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合订本「一册」:同一标签聚合后的视图。 */
|
||||||
|
export interface LogBook {
|
||||||
|
tag: string
|
||||||
|
count: number
|
||||||
|
firstTime?: string | null
|
||||||
|
lastTime?: string | null
|
||||||
|
latest?: string
|
||||||
|
latestTime?: string | null
|
||||||
|
entries: LogEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogDigest {
|
||||||
|
source: 'file' | 'day' | string
|
||||||
|
target: string
|
||||||
|
files: number
|
||||||
|
truncated: boolean
|
||||||
|
tagCount: number
|
||||||
|
untaggedCount: number
|
||||||
|
books: LogBook[]
|
||||||
|
untagged: LogBook
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntriesQuery {
|
||||||
|
file: string
|
||||||
|
keyword?: string
|
||||||
|
tag?: string
|
||||||
|
onlyTagged?: boolean
|
||||||
|
order?: 'asc' | 'desc'
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigestQuery {
|
||||||
|
file?: string
|
||||||
|
day?: string
|
||||||
|
keyword?: string
|
||||||
|
maxPerTag?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实时诊断单条:对应 SimpleLite 内核 Diagnosis 的内存态 Post/Toast。 */
|
||||||
|
export interface LiveDiagItem {
|
||||||
|
index: number
|
||||||
|
time: string
|
||||||
|
/** 空串=无标签(滚动记录);非空=按标签合订(同标签仅留最新一条)。 */
|
||||||
|
tag: string
|
||||||
|
tagged: boolean
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveDiagnosis {
|
||||||
|
serverTime: string
|
||||||
|
total: number
|
||||||
|
taggedCount: number
|
||||||
|
untaggedCount: number
|
||||||
|
items: LiveDiagItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 目录浏览:子文件夹。 */
|
||||||
|
export interface BrowseDir {
|
||||||
|
name: string
|
||||||
|
rel: string
|
||||||
|
mtime: string
|
||||||
|
dirCount: number
|
||||||
|
fileCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 目录浏览:文件。 */
|
||||||
|
export interface BrowseFile {
|
||||||
|
name: string
|
||||||
|
rel: string
|
||||||
|
bytes: number
|
||||||
|
mtime: string
|
||||||
|
isLog: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowseResult {
|
||||||
|
root: string
|
||||||
|
exists: boolean
|
||||||
|
/** 当前相对路径(""=日志根)。 */
|
||||||
|
path: string
|
||||||
|
/** 上一级相对路径;位于根时为 null。 */
|
||||||
|
parent: string | null
|
||||||
|
dirCount: number
|
||||||
|
fileCount: number
|
||||||
|
dirs: BrowseDir[]
|
||||||
|
files: BrowseFile[]
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────── 日志分析器 ──
|
||||||
|
|
||||||
|
/** 标签分布一项。 */
|
||||||
|
export interface AnalyzeTag { tag: string; count: number; percent: number }
|
||||||
|
|
||||||
|
/** 日志量直方图:稀疏桶(仅含有数据的时刻)+ Top 标签拆分(与 buckets 等长对齐)。 */
|
||||||
|
export interface AnalyzeVolume {
|
||||||
|
granularity: string
|
||||||
|
buckets: string[]
|
||||||
|
total: number[]
|
||||||
|
topTags: Array<{ tag: string; counts: number[] }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 识别出的数值字段统计。 */
|
||||||
|
export interface AnalyzeField {
|
||||||
|
name: string
|
||||||
|
samples: number
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
avg: number
|
||||||
|
last: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选定字段的时序点序列。 */
|
||||||
|
export interface AnalyzeSeries {
|
||||||
|
field: string
|
||||||
|
tag: string
|
||||||
|
count: number
|
||||||
|
points: Array<{ t: string; v: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyzeResult {
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
files: number
|
||||||
|
truncated: boolean
|
||||||
|
total: number
|
||||||
|
timeRange: { start: string | null; end: string | null }
|
||||||
|
granularity: string
|
||||||
|
tags: AnalyzeTag[]
|
||||||
|
volume: AnalyzeVolume
|
||||||
|
fields: AnalyzeField[]
|
||||||
|
series: AnalyzeSeries | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyzeQuery {
|
||||||
|
file?: string
|
||||||
|
day?: string
|
||||||
|
granularity?: 'second' | 'minute' | 'hour'
|
||||||
|
tag?: string
|
||||||
|
keyword?: string
|
||||||
|
field?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────── mock ──
|
||||||
|
|
||||||
|
function mockOverview(): LogOverview {
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
root: 'E:\\...\\SimpleLite\\bin\\Debug\\log',
|
||||||
|
workingDirectory: 'E:\\...\\SimpleLite\\bin\\Debug',
|
||||||
|
totalFiles: 3,
|
||||||
|
totalBytes: 5_233_649,
|
||||||
|
latestFileTime: new Date().toISOString(),
|
||||||
|
days: [{ day: '2026-06-01', files: 3, bytes: 5_233_649 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFiles(): LogFilesResult {
|
||||||
|
const files: LogFile[] = [
|
||||||
|
{ rel: '2026-06-01/20260601-12Q(30).log', name: '20260601-12Q(30).log', day: '2026-06-01', dir: '2026-06-01', bytes: 5_223_512, mtime: '2026-06-01T12:44:59' },
|
||||||
|
{ rel: '2026-06-01/20260601-12Q(15).log', name: '20260601-12Q(15).log', day: '2026-06-01', dir: '2026-06-01', bytes: 1188, mtime: '2026-06-01T12:21:44' },
|
||||||
|
{ rel: '2026-06-01/20260601-08Q(45).log', name: '20260601-08Q(45).log', day: '2026-06-01', dir: '2026-06-01', bytes: 949, mtime: '2026-06-01T08:47:20' }
|
||||||
|
]
|
||||||
|
return { root: 'mock', total: files.length, returned: files.length, files }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockEntries(q: EntriesQuery): LogEntriesResult {
|
||||||
|
const base: LogEntry[] = [
|
||||||
|
{ lineNo: 1, time: '2026-06-01T12:21:40.120', prefix: '', tag: 'Persistence', content: 'worker started, schema=1.2' },
|
||||||
|
{ lineNo: 2, time: '2026-06-01T12:21:41.330', prefix: '', tag: '', content: 'loaded 12 sites, 18 tracks' },
|
||||||
|
{ lineNo: 3, time: '2026-06-01T12:21:42.880', prefix: '', tag: 'Dispatch', content: 'dispatch loop 50Hz online' },
|
||||||
|
{ lineNo: 4, time: '2026-06-01T12:21:44.010', prefix: '', tag: 'Persistence', content: 'flush 4 entities ok' },
|
||||||
|
{ lineNo: 5, time: '2026-06-01T12:21:45.220', prefix: '', tag: 'UI-Error', content: 'panel repaint skipped (terminal closing)' }
|
||||||
|
]
|
||||||
|
let list = base
|
||||||
|
if (q.onlyTagged) list = list.filter((e) => e.tag)
|
||||||
|
if (q.tag) list = list.filter((e) => e.tag.includes(q.tag!))
|
||||||
|
if (q.keyword) list = list.filter((e) => e.content.includes(q.keyword!) || e.tag.includes(q.keyword!))
|
||||||
|
if (q.order !== 'asc') list = [...list].reverse()
|
||||||
|
return { file: q.file, bytes: 5_223_512, scannedLines: base.length, truncated: false, total: list.length, offset: 0, limit: 300, order: q.order ?? 'desc', entries: list }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockDigest(): LogDigest {
|
||||||
|
const mk = (tag: string, n: number, latest: string): LogBook => ({
|
||||||
|
tag, count: n, firstTime: '2026-06-01T12:00:00', lastTime: '2026-06-01T12:44:00',
|
||||||
|
latest, latestTime: '2026-06-01T12:44:00',
|
||||||
|
entries: Array.from({ length: Math.min(n, 3) }, (_, i) => ({
|
||||||
|
lineNo: i + 1, time: '2026-06-01T12:4' + i + ':00.000', prefix: '', tag, content: `${latest} #${i + 1}`
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
source: 'file', target: 'mock', files: 1, truncated: false, tagCount: 3, untaggedCount: 42,
|
||||||
|
books: [mk('Persistence', 128, 'flush ok'), mk('Dispatch', 64, 'loop tick'), mk('UI-Error', 3, 'repaint skipped')],
|
||||||
|
untagged: { tag: '', count: 42, latest: 'misc rolling line', entries: [
|
||||||
|
{ lineNo: 2, time: '2026-06-01T12:21:41.330', prefix: '', tag: '', content: 'loaded 12 sites, 18 tracks' }
|
||||||
|
] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────── client ──
|
||||||
|
|
||||||
|
function mockLiveDiagnosis(): LiveDiagnosis {
|
||||||
|
const now = new Date()
|
||||||
|
const iso = (sec: number) => new Date(now.getTime() - sec * 1000).toISOString().slice(0, 23)
|
||||||
|
const items: LiveDiagItem[] = [
|
||||||
|
{ index: 0, time: iso(2), tag: 'Persistence', tagged: true, content: 'flush 4 entities ok' },
|
||||||
|
{ index: 1, time: iso(4), tag: 'Dispatch', tagged: true, content: 'dispatch loop 50Hz online' },
|
||||||
|
{ index: 2, time: iso(6), tag: 'InternalAuth', tagged: true, content: '仅放行本机回环(无 internal token 配置)' },
|
||||||
|
{ index: 3, time: iso(1), tag: '', tagged: false, content: '[SimpleLite] Projection API listening on http://127.0.0.1:8222/projection/' },
|
||||||
|
{ index: 4, time: iso(8), tag: '', tagged: false, content: 'loaded 12 sites, 18 tracks' }
|
||||||
|
]
|
||||||
|
const taggedCount = items.filter((i) => i.tagged).length
|
||||||
|
return { serverTime: now.toISOString().slice(0, 23), total: items.length, taggedCount, untaggedCount: items.length - taggedCount, items }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockBrowse(path?: string): BrowseResult {
|
||||||
|
if (!path) {
|
||||||
|
return {
|
||||||
|
root: 'mock-log', exists: true, path: '', parent: null, dirCount: 1, fileCount: 0,
|
||||||
|
dirs: [{ name: '2026-06-01', rel: '2026-06-01', mtime: '2026-06-01T12:44:59', dirCount: 0, fileCount: 3 }],
|
||||||
|
files: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
root: 'mock-log', exists: true, path, parent: '', dirCount: 0, fileCount: 3,
|
||||||
|
dirs: [],
|
||||||
|
files: [
|
||||||
|
{ name: '20260601-12Q(30).log', rel: `${path}/20260601-12Q(30).log`, bytes: 5_223_512, mtime: '2026-06-01T12:44:59', isLog: true },
|
||||||
|
{ name: '20260601-12Q(15).log', rel: `${path}/20260601-12Q(15).log`, bytes: 1188, mtime: '2026-06-01T12:21:44', isLog: true },
|
||||||
|
{ name: '20260601-08Q(45).log', rel: `${path}/20260601-08Q(45).log`, bytes: 949, mtime: '2026-06-01T08:47:20', isLog: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockAnalyze(q: AnalyzeQuery): AnalyzeResult {
|
||||||
|
const now = Date.now()
|
||||||
|
const gran = q.granularity ?? 'minute'
|
||||||
|
const step = gran === 'second' ? 1000 : gran === 'hour' ? 3_600_000 : 60_000
|
||||||
|
const n = 30
|
||||||
|
const buckets = Array.from({ length: n }, (_, i) => new Date(now - (n - 1 - i) * step).toISOString())
|
||||||
|
const wave = (amp: number, base: number, ph: number) =>
|
||||||
|
buckets.map((_, i) => Math.max(0, Math.round(base + amp * Math.sin(i / 3 + ph))))
|
||||||
|
const fields: AnalyzeField[] = [
|
||||||
|
{ name: 'cost', samples: 420, min: 2, max: 88, avg: 18.4, last: 21 },
|
||||||
|
{ name: 'queue', samples: 380, min: 0, max: 32, avg: 6.1, last: 4 },
|
||||||
|
{ name: 'speed', samples: 300, min: 0, max: 1.5, avg: 0.7, last: 0.9 }
|
||||||
|
]
|
||||||
|
const series: AnalyzeSeries | null = q.field
|
||||||
|
? {
|
||||||
|
field: q.field, tag: q.tag ?? '', count: n,
|
||||||
|
points: buckets.map((t, i) => ({ t, v: Math.round((18 + 10 * Math.sin(i / 2)) * 10) / 10 }))
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
return {
|
||||||
|
source: q.file ? 'file' : 'day', target: q.file ?? q.day ?? 'mock', files: 1, truncated: false,
|
||||||
|
total: 1280,
|
||||||
|
timeRange: { start: buckets[0], end: buckets[n - 1] },
|
||||||
|
granularity: gran,
|
||||||
|
tags: [
|
||||||
|
{ tag: 'Dispatch', count: 520, percent: 40.6 },
|
||||||
|
{ tag: 'Persistence', count: 360, percent: 28.1 },
|
||||||
|
{ tag: '交管#3', count: 210, percent: 16.4 },
|
||||||
|
{ tag: 'UI-Error', count: 90, percent: 7.0 },
|
||||||
|
{ tag: '', count: 100, percent: 7.8 }
|
||||||
|
],
|
||||||
|
volume: {
|
||||||
|
granularity: gran, buckets,
|
||||||
|
total: wave(20, 40, 0),
|
||||||
|
topTags: [
|
||||||
|
{ tag: 'Dispatch', counts: wave(10, 18, 0) },
|
||||||
|
{ tag: 'Persistence', counts: wave(8, 12, 1) },
|
||||||
|
{ tag: '交管#3', counts: wave(6, 7, 2) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fields,
|
||||||
|
series
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logsApi = {
|
||||||
|
overview: (): Promise<LogOverview> => MOCK
|
||||||
|
? Promise.resolve(mockOverview())
|
||||||
|
: http.get<LogOverview>('/logs/overview').then((r) => r.data),
|
||||||
|
|
||||||
|
files: (params?: { day?: string; keyword?: string; limit?: number }): Promise<LogFilesResult> => MOCK
|
||||||
|
? Promise.resolve(mockFiles())
|
||||||
|
: http.get<LogFilesResult>('/logs/files', { params }).then((r) => r.data),
|
||||||
|
|
||||||
|
entries: (q: EntriesQuery): Promise<LogEntriesResult> => MOCK
|
||||||
|
? Promise.resolve(mockEntries(q))
|
||||||
|
: http.get<LogEntriesResult>('/logs/entries', { params: q }).then((r) => r.data),
|
||||||
|
|
||||||
|
digest: (q: DigestQuery): Promise<LogDigest> => MOCK
|
||||||
|
? Promise.resolve(mockDigest())
|
||||||
|
: http.get<LogDigest>('/logs/digest', { params: q }).then((r) => r.data),
|
||||||
|
|
||||||
|
/** 实时内存诊断(SimpleLite Diagnosis.GetAllDiagnosis,经 YARP 反代)。需 SimpleLite 在运行,否则 502。 */
|
||||||
|
liveDiagnosis: (): Promise<LiveDiagnosis> => MOCK
|
||||||
|
? Promise.resolve(mockLiveDiagnosis())
|
||||||
|
: http.get<SlEnvelope<LiveDiagnosis>>(`${SL_DIAG}/all`).then((r) => {
|
||||||
|
if (!r.data?.success || !r.data.data) throw new Error(r.data?.message ?? '获取实时诊断失败')
|
||||||
|
return r.data.data
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** 目录浏览:列出 log/ 下某相对目录的子文件夹 + 文件(path 为空=根)。 */
|
||||||
|
browse: (path?: string): Promise<BrowseResult> => MOCK
|
||||||
|
? Promise.resolve(mockBrowse(path))
|
||||||
|
: http.get<BrowseResult>('/logs/browse', { params: path ? { path } : undefined }).then((r) => r.data),
|
||||||
|
|
||||||
|
/** 日志分析:标签分布 / 时间直方图 / 数值字段识别 / 选定字段时序。file 或 day 二选一。 */
|
||||||
|
analyze: (q: AnalyzeQuery): Promise<AnalyzeResult> => MOCK
|
||||||
|
? Promise.resolve(mockAnalyze(q))
|
||||||
|
: http.get<AnalyzeResult>('/logs/analyze', { params: q }).then((r) => r.data),
|
||||||
|
|
||||||
|
/** 取文件尾部 N 行原文(text/plain)。 */
|
||||||
|
raw: (file: string, tail = 2000): Promise<string> => MOCK
|
||||||
|
? Promise.resolve('[2026/06/01-12:21:40.120] >Persistence: worker started\n[2026/06/01-12:21:41.330] >/: loaded 12 sites')
|
||||||
|
: http.get('/logs/raw', { params: { file, tail }, responseType: 'text' }).then((r) => r.data as string),
|
||||||
|
|
||||||
|
/** 浏览器直链下载(GET,靠 httpOnly Cookie 鉴权)。 */
|
||||||
|
downloadUrl: (file: string): string =>
|
||||||
|
`${API_BASE}/logs/download?file=${encodeURIComponent(file)}`
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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