feat: Add real-time log streaming component and new Upload page.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Terminal, Trash2, Play, Pause } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button } from '../common'
|
||||
import { Card, Button } from '../common'
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string
|
||||
@@ -33,7 +33,12 @@ const stepLabels: Record<string, string> = {
|
||||
database: '数据库',
|
||||
}
|
||||
|
||||
export default function LogStream() {
|
||||
interface LogStreamProps {
|
||||
hideHeader?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function LogStream({ hideHeader = false, className = '' }: LogStreamProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [paused, setPaused] = useState(false)
|
||||
@@ -53,7 +58,7 @@ export default function LogStream() {
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const log = JSON.parse(event.data) as LogEntry
|
||||
setLogs((prev) => [...prev.slice(-199), log])
|
||||
setLogs((prev) => [...prev.slice(-499), log]) // 增加缓存行数
|
||||
} catch (e) {
|
||||
console.error('Failed to parse log:', e)
|
||||
}
|
||||
@@ -103,67 +108,129 @@ export default function LogStream() {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5" />
|
||||
实时日志
|
||||
const content = (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
{!hideHeader && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">实时日志</span>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
className={`w-2 h-2 rounded-full animate-pulse-slow ${connected ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]' : 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]'}`}
|
||||
/>
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs"
|
||||
onClick={togglePause}
|
||||
icon={paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
|
||||
icon={paused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
|
||||
>
|
||||
{paused ? '继续' : '暂停'}
|
||||
{paused ? '开始' : '暂停'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs text-slate-500 hover:text-red-500"
|
||||
onClick={handleClear}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="h-full overflow-y-auto bg-slate-900 dark:bg-slate-950 p-4 font-mono text-xs"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-slate-500 text-center py-8">等待日志...</div>
|
||||
) : (
|
||||
logs.map((log, i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5 hover:bg-slate-800/50">
|
||||
<span className="text-slate-500 flex-shrink-0">{formatTime(log.timestamp)}</span>
|
||||
)}
|
||||
|
||||
{/* 紧凑型控制栏 (当 hideHeader=true 时显示) */}
|
||||
{hideHeader && (
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-2 border-b border-white/5 bg-slate-900/40 backdrop-blur-md flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : 'bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.4)]'}`}
|
||||
/>
|
||||
<span className="text-[10px] font-medium text-slate-500 uppercase tracking-wider">系统输出</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={togglePause}
|
||||
className="flex items-center gap-1.5 text-[10px] text-slate-400 hover:text-blue-400 transition-colors py-0.5 px-1.5 rounded hover:bg-white/5"
|
||||
>
|
||||
{paused ? <Play className="h-3 w-3" /> : <Pause className="h-3 w-3" />}
|
||||
{paused ? '开始' : '暂停'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-1.5 text-[10px] text-slate-400 hover:text-red-400 transition-colors py-0.5 px-1.5 rounded hover:bg-white/5"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-1 overflow-y-auto bg-[#0a0c10] p-4 font-mono text-[11px] leading-relaxed selection:bg-blue-500/30 custom-scrollbar"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-slate-600 gap-2 opacity-50">
|
||||
<Terminal className="h-8 w-8 stroke-[1px]" />
|
||||
<p>等待系统日志输出...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="group flex gap-3 py-0.5 border-l-2 border-transparent hover:border-blue-500/30 hover:bg-white/5 transition-all">
|
||||
<span className="text-slate-600 flex-shrink-0 select-none opacity-60 group-hover:opacity-100">
|
||||
{formatTime(log.timestamp)}
|
||||
</span>
|
||||
|
||||
{log.step && (
|
||||
<span
|
||||
className={`px-1.5 rounded text-[10px] uppercase flex-shrink-0 ${stepColors[log.step] || 'bg-slate-500/20 text-slate-400'}`}
|
||||
className={`px-1.5 rounded-[4px] text-[9px] font-bold uppercase flex-shrink-0 h-4 flex items-center ${stepColors[log.step] || 'bg-slate-500/20 text-slate-400'}`}
|
||||
>
|
||||
{stepLabels[log.step] || log.step}
|
||||
</span>
|
||||
)}
|
||||
<span className={`flex-shrink-0 ${levelColors[log.level] || 'text-slate-300'}`}>
|
||||
|
||||
<span className={`flex-shrink-0 font-bold ${levelColors[log.level] || 'text-slate-300'}`}>
|
||||
{log.level === 'success' ? '✓' : log.level === 'error' ? '✗' : '•'}
|
||||
</span>
|
||||
<span className="text-slate-300 break-all">{log.message}</span>
|
||||
|
||||
<span className="text-slate-300 break-all leading-normal group-hover:text-white transition-colors">
|
||||
{log.message}
|
||||
</span>
|
||||
|
||||
{log.email && (
|
||||
<span className="text-slate-500 flex-shrink-0 ml-auto">[{log.email}]</span>
|
||||
<span className="text-slate-500 flex-shrink-0 ml-auto opacity-0 group-hover:opacity-100 transition-opacity bg-slate-800/50 px-1.5 rounded">
|
||||
{log.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 10px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #334155; }
|
||||
.animate-pulse-slow { animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
|
||||
`}} />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (hideHeader) return content
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col overflow-hidden border-0 shadow-lg bg-white dark:bg-slate-900">
|
||||
{content}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -412,7 +412,7 @@ export default function Upload() {
|
||||
<span className="text-sm font-medium text-slate-300">实时日志</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<LogStream />
|
||||
<LogStream hideHeader={true} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,7 +427,7 @@ export default function Upload() {
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
<div className="h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-900 shadow-sm">
|
||||
<LogStream />
|
||||
<LogStream hideHeader={true} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user