码桶
发现社区成员的开源项目
LogConsole.tsx4.5 KB
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Terminal, Pause, Play, Download, Trash2, RefreshCw } from 'lucide-react'
type Level = 'ERROR' | 'WARN' | 'DEBUG' | 'INFO' | 'LOG'
function levelOf(line: string): Level {
const m = line.match(/^\s*\[[^\]]*\]\s*\[(\w+)\]/)
return (m?.[1] as Level) || 'LOG'
}
function lineClass(level: Level): string {
switch (level) {
case 'ERROR': return 'text-red-400'
case 'WARN': return 'text-yellow-400'
case 'DEBUG': return 'text-zinc-500'
case 'INFO': return 'text-emerald-300'
default: return 'text-zinc-300'
}
}
export default function LogConsole() {
const [lines, setLines] = useState<string[]>([])
const [total, setTotal] = useState(0)
const [file, setFile] = useState('')
const [filter, setFilter] = useState('')
const [auto, setAuto] = useState(true)
const [loading, setLoading] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const atBottomRef = useRef(true)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await api.post('/api/logs', { tail: 1000, q: filter })
if (r.ok) {
setLines(r.lines || [])
setTotal(r.total || 0)
setFile(r.file || '')
}
} catch {
/* 忽略瞬时错误,下次轮询重试 */
} finally {
setLoading(false)
}
}, [filter])
useEffect(() => {
load()
let t: ReturnType<typeof setInterval> | undefined
if (auto) t = setInterval(load, 2000)
return () => { if (t) clearInterval(t) }
}, [auto, load])
useEffect(() => {
if (auto && atBottomRef.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [lines, auto])
const onScroll = () => {
const el = scrollRef.current
if (!el) return
atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
}
const download = () => {
const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = file || 'logs.txt'
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="flex h-full min-h-[calc(100vh-3.5rem)] flex-col gap-3 p-3 md:p-5">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<Terminal className="h-5 w-5 text-primary" />
<h1 className="text-lg font-semibold">日志控制台</h1>
</div>
<span className="text-xs text-muted-foreground">
共 {total} 行 · {file} {loading && <span className="text-primary">· 刷新中…</span>}
</span>
<div className="ml-auto flex flex-wrap items-center gap-2">
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="关键词过滤…"
className="h-9 w-44"
/>
<Button variant="outline" size="sm" className="h-9" onClick={() => load()}>
<RefreshCw className="h-4 w-4" /> 刷新
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setAuto((v) => !v)}>
{auto ? <><Pause className="h-4 w-4" /> 暂停</> : <><Play className="h-4 w-4" /> 自动</>}
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={download}>
<Download className="h-4 w-4" /> 下载
</Button>
<Button
variant="outline"
size="sm"
className="h-9"
onClick={() => { setLines([]); atBottomRef.current = true }}
>
<Trash2 className="h-4 w-4" /> 清屏
</Button>
</div>
</div>
<div
ref={scrollRef}
onScroll={onScroll}
className="min-h-0 flex-1 overflow-auto rounded-lg border bg-zinc-950 p-3 font-mono text-[12.5px] leading-relaxed"
>
{lines.length === 0 ? (
<div className="flex h-full items-center justify-center text-zinc-600">暂无日志</div>
) : (
lines.map((l, i) => (
<div key={i} className={lineClass(levelOf(l)) + ' whitespace-pre-wrap break-all'}>
{l}
</div>
))
)}
</div>
</div>
)
}