码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/pages/LogConsole.tsx
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>
  )
}