LogViewer 日志面板
终端风格的日志流面板:等宽字体、行级 level 着色、自动跟随底部、search 高亮、levels 过滤、用户滚开后出现「跳到底部」按钮。
English translation pending This page hasn't been translated yet — falling back to Chinese. PRs welcome on GitHub.
基础用法
logs 是 LogEntry[]:{ id?, timestamp?, level?, message, source? }。当 follow=true 且当前已经在底部时,新 log push 后自动滚到底;用户主动滚到上面就会停止跟随,并在右下角显示「跳到底部」按钮,点击恢复跟随。search 字符串会在 message 中高亮。
背景 视口
09:01:35.520INFOservice auth-svc started on :8080
09:01:36.520DEBUGdb connection pool size=10
09:01:38.520INFOrequest POST /login user=alice
09:01:39.520WARNslow query detected SELECT entries took 1.2s
09:01:40.520SUCCESScache warmed 1280 entries
09:01:42.020ERRORfailed to reach upstream: ECONNREFUSED 10.0.0.4:9092
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { CfLogViewer, type LogEntry } from '@chufix-design/vue';
const logs = ref<LogEntry[]>([
{ id: 1, timestamp: Date.now() - 8000, level: 'info', message: 'service auth-svc started on :8080' },
{ id: 2, timestamp: Date.now() - 7000, level: 'debug', message: 'db connection pool size=10' },
{ id: 3, timestamp: Date.now() - 5000, level: 'info', message: 'request POST /login user=alice' },
{ id: 4, timestamp: Date.now() - 4000, level: 'warn', message: 'slow query detected SELECT entries took 1.2s' },
{ id: 5, timestamp: Date.now() - 3000, level: 'success', message: 'cache warmed 1280 entries' },
{ id: 6, timestamp: Date.now() - 1500, level: 'error', message: 'failed to reach upstream: ECONNREFUSED 10.0.0.4:9092' },
]);
const search = ref('');
let nextId = 7;
let interval: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
interval = setInterval(() => {
const levels = ['info', 'debug', 'warn', 'error', 'success'] as const;
const lvl = levels[Math.floor(Math.random() * levels.length)];
logs.value = [
...logs.value,
{
id: nextId++,
timestamp: Date.now(),
level: lvl,
message: `tick ${nextId} from auto-feed (${lvl})`,
},
];
if (logs.value.length > 80) logs.value = logs.value.slice(-80);
}, 1800);
});
onBeforeUnmount(() => {
if (interval) clearInterval(interval);
});
</script>
<template>
<div class="lv-demo">
<input
v-model="search"
class="lv-demo__search"
placeholder="搜索日志…"
/>
<CfLogViewer :logs="logs" :search="search" :height="300" />
</div>
</template>
<style scoped>
.lv-demo {
display: grid;
gap: 8px;
}
.lv-demo__search {
height: 30px;
padding: 0 10px;
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-2);
color: var(--fg-1);
font-size: var(--t-13);
}
.lv-demo__search:focus {
outline: none;
border-color: var(--accent-1);
}
</style> <script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { CfLogViewer } from '@chufix-design/vue';
const logs = ref<LogEntry[]>([
{ id: 1, timestamp: Date.now() - 8000, level: 'info', message: 'service auth-svc started on :8080' },
{ id: 2, timestamp: Date.now() - 7000, level: 'debug', message: 'db connection pool size=10' },
{ id: 3, timestamp: Date.now() - 5000, level: 'info', message: 'request POST /login user=alice' },
{ id: 4, timestamp: Date.now() - 4000, level: 'warn', message: 'slow query detected SELECT entries took 1.2s' },
{ id: 5, timestamp: Date.now() - 3000, level: 'success', message: 'cache warmed 1280 entries' },
{ id: 6, timestamp: Date.now() - 1500, level: 'error', message: 'failed to reach upstream: ECONNREFUSED 10.0.0.4:9092' },
]);
const search = ref('');
let nextId = 7;
let interval= null;
onMounted(() => {
interval = setInterval(() => {
const levels = ['info', 'debug', 'warn', 'error', 'success'];
const lvl = levels[Math.floor(Math.random() * levels.length)];
logs.value = [
...logs.value,
{
id: nextId++,
timestamp: Date.now(),
level: lvl,
message: `tick ${nextId} from auto-feed (${lvl})`,
},
];
if (logs.value.length > 80) logs.value = logs.value.slice(-80);
}, 1800);
});
onBeforeUnmount(() => {
if (interval) clearInterval(interval);
});
</script>
<template>
<div class="lv-demo">
<input
v-model="search"
class="lv-demo__search"
placeholder="搜索日志…"
/>
<CfLogViewer :logs="logs" :search="search" :height="300" />
</div>
</template>
<style scoped>
.lv-demo {
display: grid;
gap: 8px;
}
.lv-demo__search {
height: 30px;
padding: 0 10px;
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-2);
color: var(--fg-1);
font-size: var(--t-13);
}
.lv-demo__search:focus {
outline: none;
border-color: var(--accent-1);
}
</style> import { useEffect, useState } from 'react';
import { CfLogViewer } from '@chufix-design/react';
export default function Demo() {
const [logs, setLogs] = useState<LogEntry[]>([
{ id: 1, timestamp: Date.now() - 8000, level: 'info', message: 'service auth-svc started on :8080' },
{ id: 2, timestamp: Date.now() - 7000, level: 'debug', message: 'db connection pool size=10' },
{ id: 3, timestamp: Date.now() - 5000, level: 'info', message: 'request POST /login user=alice' },
{ id: 4, timestamp: Date.now() - 4000, level: 'warn', message: 'slow query detected SELECT entries took 1.2s' },
{ id: 5, timestamp: Date.now() - 3000, level: 'success', message: 'cache warmed 1280 entries' },
{ id: 6, timestamp: Date.now() - 1500, level: 'error', message: 'failed to reach upstream: ECONNREFUSED 10.0.0.4:9092' },
]);
const [search, setSearch] = useState('');
let nextId = 7;
let interval: ReturnType<typeof setInterval> | null = null;
useEffect(() => {
interval = setInterval(() => {
const levels = ['info', 'debug', 'warn', 'error', 'success'] as const;
const lvl = levels[Math.floor(Math.random() * levels.length)];
setLogs([
...logs,
{
id: nextId++,
timestamp: Date.now(),
level: lvl,
message: `tick ${nextId} from auto-feed (${lvl})`,
},
]);
if (logs.length > 80) setLogs(logs.slice(-80));
}, 1800);
});
onBeforeUnmount(() => {
if (interval) clearInterval(interval);
});
return (
<>
<div className="lv-demo">
<input value={search} onChange={setSearch} className="lv-demo__search" placeholder="搜索日志…" />
<CfLogViewer logs={logs} search={search} height={300} />
</div>
</>
);
} import { useEffect, useState } from 'react';
import { CfLogViewer } from '@chufix-design/react';
export default function Demo() {
const [logs, setLogs] = useState<LogEntry[]>([
{ id: 1, timestamp: Date.now() - 8000, level: 'info', message: 'service auth-svc started on :8080' },
{ id: 2, timestamp: Date.now() - 7000, level: 'debug', message: 'db connection pool size=10' },
{ id: 3, timestamp: Date.now() - 5000, level: 'info', message: 'request POST /login user=alice' },
{ id: 4, timestamp: Date.now() - 4000, level: 'warn', message: 'slow query detected SELECT entries took 1.2s' },
{ id: 5, timestamp: Date.now() - 3000, level: 'success', message: 'cache warmed 1280 entries' },
{ id: 6, timestamp: Date.now() - 1500, level: 'error', message: 'failed to reach upstream: ECONNREFUSED 10.0.0.4:9092' },
]);
const [search, setSearch] = useState('');
let nextId = 7;
let interval= null;
useEffect(() => {
interval = setInterval(() => {
const levels = ['info', 'debug', 'warn', 'error', 'success'];
const lvl = levels[Math.floor(Math.random() * levels.length)];
setLogs([
...logs,
{
id: nextId++,
timestamp: Date.now(),
level: lvl,
message: `tick ${nextId} from auto-feed (${lvl})`,
},
]);
if (logs.length > 80) setLogs(logs.slice(-80));
}, 1800);
});
onBeforeUnmount(() => {
if (interval) clearInterval(interval);
});
return (
<>
<div className="lv-demo">
<input value={search} onChange={setSearch} className="lv-demo__search" placeholder="搜索日志…" />
<CfLogViewer logs={logs} search={search} height={300} />
</div>
</>
);
} API
Props
| 属性 | 类型 | 默认 | 说明 |
|---|---|---|---|
logs | LogEntry[] | — | 日志数组 |
follow | boolean | true | 自动跟随底部 |
search | string | — | 高亮关键字 |
height | number | string | 320 | 面板高 |
showTimestamp | boolean | true | 显示时间戳 |
showLevel | boolean | true | 显示 level 标签 |
showSource | boolean | false | 显示 source 字段 |
levels | LogLevel[] | — | 过滤;不传显示全部 |
反馈与讨论
LogViewer 日志面板 · Discussion