220 lines
8.2 KiB
TypeScript
220 lines
8.2 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
import { Terminal, Play, Pause, Trash2, ChevronDown } from 'lucide-react'
|
|
|
|
interface LogEntry {
|
|
type: string
|
|
timestamp: string
|
|
level: string
|
|
message: string
|
|
email?: string
|
|
module?: string
|
|
}
|
|
|
|
const levelColors: Record<string, string> = {
|
|
success: 'text-green-400',
|
|
error: 'text-red-400',
|
|
warning: 'text-yellow-400',
|
|
info: 'text-cyan-400',
|
|
}
|
|
|
|
const levelBgColors: Record<string, string> = {
|
|
success: 'bg-green-900/20',
|
|
error: 'bg-red-900/20',
|
|
warning: 'bg-yellow-900/20',
|
|
info: 'bg-cyan-900/20',
|
|
}
|
|
|
|
interface LiveLogViewerProps {
|
|
maxLogs?: number
|
|
autoScroll?: boolean
|
|
className?: string
|
|
}
|
|
|
|
export default function LiveLogViewer({
|
|
maxLogs = 200,
|
|
autoScroll: initialAutoScroll = true,
|
|
className = '',
|
|
}: LiveLogViewerProps) {
|
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
|
const [isConnected, setIsConnected] = useState(false)
|
|
const [isPaused, setIsPaused] = useState(false)
|
|
const [autoScroll, setAutoScroll] = useState(initialAutoScroll)
|
|
const logContainerRef = useRef<HTMLDivElement>(null)
|
|
const eventSourceRef = useRef<EventSource | null>(null)
|
|
const pausedLogsRef = useRef<LogEntry[]>([])
|
|
|
|
const isPausedRef = useRef(isPaused)
|
|
isPausedRef.current = isPaused
|
|
|
|
// 加载历史日志
|
|
const loadHistoryLogs = async () => {
|
|
try {
|
|
const res = await fetch('/api/logs')
|
|
const data = await res.json()
|
|
if (data.code === 0 && Array.isArray(data.data)) {
|
|
// 转换为 LogEntry 格式
|
|
const historyLogs: LogEntry[] = data.data.map((log: { level: string; message: string; timestamp: string; email?: string; module?: string }) => ({
|
|
type: 'log',
|
|
level: log.level || 'info',
|
|
message: log.message || '',
|
|
timestamp: log.timestamp ? new Date(log.timestamp).toLocaleTimeString('zh-CN') : '',
|
|
email: log.email,
|
|
module: log.module || 'system',
|
|
}))
|
|
setLogs(historyLogs.slice(-maxLogs))
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load history logs:', e)
|
|
}
|
|
}
|
|
|
|
// 组件挂载时先加载历史日志,再连接 SSE
|
|
useEffect(() => {
|
|
loadHistoryLogs()
|
|
|
|
const es = new EventSource('/api/logs/stream')
|
|
eventSourceRef.current = es
|
|
|
|
es.onopen = () => {
|
|
setIsConnected(true)
|
|
}
|
|
|
|
es.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data) as LogEntry
|
|
|
|
if (data.type === 'connected') {
|
|
return
|
|
}
|
|
|
|
if (data.type === 'log') {
|
|
if (isPausedRef.current) {
|
|
pausedLogsRef.current.push(data)
|
|
} else {
|
|
setLogs((prev) => {
|
|
const newLogs = [...prev, data]
|
|
return newLogs.slice(-maxLogs)
|
|
})
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Parse error:', e)
|
|
}
|
|
}
|
|
|
|
es.onerror = () => {
|
|
setIsConnected(false)
|
|
}
|
|
|
|
return () => {
|
|
es.close()
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
// 自动滚动
|
|
useEffect(() => {
|
|
if (autoScroll && logContainerRef.current) {
|
|
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
|
}
|
|
}, [logs, autoScroll])
|
|
|
|
// 恢复暂停的日志
|
|
const handleResume = () => {
|
|
setIsPaused(false)
|
|
if (pausedLogsRef.current.length > 0) {
|
|
setLogs((prev) => {
|
|
const newLogs = [...prev, ...pausedLogsRef.current]
|
|
pausedLogsRef.current = []
|
|
return newLogs.slice(-maxLogs)
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleClear = () => {
|
|
setLogs([])
|
|
}
|
|
|
|
return (
|
|
<div className={`bg-slate-900 rounded-xl border border-slate-700 overflow-hidden ${className}`}>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-2 bg-slate-800/50 border-b border-slate-700">
|
|
<div className="flex items-center gap-2">
|
|
<Terminal className="h-4 w-4 text-green-400" />
|
|
<span className="text-sm font-medium text-slate-200">实时日志</span>
|
|
<span className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
|
|
<span className="text-xs text-slate-400">
|
|
{isConnected ? '已连接' : '连接中...'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-slate-500">{logs.length} 条日志</span>
|
|
<button
|
|
onClick={() => isPaused ? handleResume() : setIsPaused(true)}
|
|
className={`p-1.5 rounded-lg transition-colors ${isPaused
|
|
? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
|
|
: 'bg-slate-700 text-slate-400 hover:bg-slate-600'
|
|
}`}
|
|
title={isPaused ? '继续' : '暂停'}
|
|
>
|
|
{isPaused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
|
|
</button>
|
|
<button
|
|
onClick={handleClear}
|
|
className="p-1.5 rounded-lg bg-slate-700 text-slate-400 hover:bg-slate-600 transition-colors"
|
|
title="清空"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => setAutoScroll(!autoScroll)}
|
|
className={`p-1.5 rounded-lg transition-colors ${autoScroll
|
|
? 'bg-blue-500/20 text-blue-400'
|
|
: 'bg-slate-700 text-slate-400'
|
|
}`}
|
|
title={autoScroll ? '自动滚动: 开' : '自动滚动: 关'}
|
|
>
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Log Content */}
|
|
<div
|
|
ref={logContainerRef}
|
|
className="h-80 overflow-y-auto p-3 font-mono text-xs space-y-0.5"
|
|
>
|
|
{logs.length === 0 ? (
|
|
<div className="flex items-center justify-center h-full text-slate-500">
|
|
等待日志...
|
|
</div>
|
|
) : (
|
|
logs.map((log, index) => (
|
|
<div
|
|
key={index}
|
|
className={`flex gap-2 px-2 py-0.5 rounded ${levelBgColors[log.level] || ''}`}
|
|
>
|
|
<span className="text-slate-500 flex-shrink-0">{log.timestamp}</span>
|
|
<span className={`flex-shrink-0 uppercase font-semibold w-16 ${levelColors[log.level] || 'text-slate-400'}`}>
|
|
[{log.level}]
|
|
</span>
|
|
<span className="text-slate-400 flex-shrink-0">[{log.module}]</span>
|
|
{log.email && (
|
|
<span className="text-purple-400 flex-shrink-0 truncate max-w-[150px]">{log.email}</span>
|
|
)}
|
|
<span className="text-slate-200 flex-1">{log.message}</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Paused indicator */}
|
|
{isPaused && pausedLogsRef.current.length > 0 && (
|
|
<div className="px-4 py-2 bg-yellow-500/10 border-t border-yellow-500/30 text-yellow-400 text-xs text-center">
|
|
已暂停 - 有 {pausedLogsRef.current.length} 条新日志等待显示
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|