feat: Add real-time log streaming component and new Upload page.

This commit is contained in:
2026-01-30 09:58:02 +08:00
parent a58e2a6502
commit b3841e08ea
2 changed files with 105 additions and 38 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Terminal, Trash2, Play, Pause } from 'lucide-react' import { Terminal, Trash2, Play, Pause } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button } from '../common' import { Card, Button } from '../common'
interface LogEntry { interface LogEntry {
timestamp: string timestamp: string
@@ -33,7 +33,12 @@ const stepLabels: Record<string, string> = {
database: '数据库', 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 [logs, setLogs] = useState<LogEntry[]>([])
const [connected, setConnected] = useState(false) const [connected, setConnected] = useState(false)
const [paused, setPaused] = useState(false) const [paused, setPaused] = useState(false)
@@ -53,7 +58,7 @@ export default function LogStream() {
eventSource.onmessage = (event) => { eventSource.onmessage = (event) => {
try { try {
const log = JSON.parse(event.data) as LogEntry const log = JSON.parse(event.data) as LogEntry
setLogs((prev) => [...prev.slice(-199), log]) setLogs((prev) => [...prev.slice(-499), log]) // 增加缓存行数
} catch (e) { } catch (e) {
console.error('Failed to parse log:', e) console.error('Failed to parse log:', e)
} }
@@ -103,67 +108,129 @@ export default function LogStream() {
} }
} }
return ( const content = (
<Card className="h-full flex flex-col"> <div className={`flex flex-col h-full ${className}`}>
<CardHeader className="flex-shrink-0"> {!hideHeader && (
<div className="flex items-center justify-between"> <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">
<CardTitle className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Terminal className="h-5 w-5" /> <Terminal className="h-4 w-4 text-blue-500" />
<span className="text-sm font-bold text-slate-900 dark:text-slate-100"></span>
<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>
<div className="flex gap-2"> <div className="flex items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 px-2 text-xs"
onClick={togglePause} 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>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 px-2 text-xs text-slate-500 hover:text-red-500"
onClick={handleClear} onClick={handleClear}
icon={<Trash2 className="h-4 w-4" />} icon={<Trash2 className="h-3.5 w-3.5" />}
> >
</Button> </Button>
</div> </div>
</div> </div>
</CardHeader> )}
<CardContent className="flex-1 overflow-hidden p-0">
{/* 紧凑型控制栏 (当 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 <div
ref={logContainerRef} ref={logContainerRef}
className="h-full overflow-y-auto bg-slate-900 dark:bg-slate-950 p-4 font-mono text-xs" 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 ? ( {logs.length === 0 ? (
<div className="text-slate-500 text-center py-8">...</div> <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>
) : ( ) : (
logs.map((log, i) => ( <div className="space-y-0.5">
<div key={i} className="flex gap-2 py-0.5 hover:bg-slate-800/50"> {logs.map((log, i) => (
<span className="text-slate-500 flex-shrink-0">{formatTime(log.timestamp)}</span> <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 && ( {log.step && (
<span <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} {stepLabels[log.step] || log.step}
</span> </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' ? '✗' : '•'} {log.level === 'success' ? '✓' : log.level === 'error' ? '✗' : '•'}
</span> </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 && ( {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>
)) ))}
</div>
)} )}
</div> </div>
</CardContent>
<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> </Card>
) )
} }

View File

@@ -412,7 +412,7 @@ export default function Upload() {
<span className="text-sm font-medium text-slate-300"></span> <span className="text-sm font-medium text-slate-300"></span>
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<LogStream /> <LogStream hideHeader={true} />
</div> </div>
</div> </div>
</div> </div>
@@ -427,7 +427,7 @@ export default function Upload() {
{activeTab === 'logs' && ( {activeTab === 'logs' && (
<div className="h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-900 shadow-sm"> <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> </div>
)} )}