"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; const LEVEL_CLASS: Record = { trace: "text-zinc-600", debug: "text-zinc-500", info: "text-sky-400", warn: "text-amber-400", warning: "text-amber-400", error: "text-red-400", fatal: "text-red-500", }; function parseLine(line: string): { text: string; className: string } { const t = line.trim(); if (t.startsWith("{")) { try { const o = JSON.parse(t) as Record; if (o && typeof o === "object" && typeof o.lvl === "string") { return { text: typeof o.msg === "string" ? o.msg : t, className: LEVEL_CLASS[o.lvl.toLowerCase()] ?? "text-zinc-300", }; } } catch { // 不是合法 JSON,原样显示 } } return { text: line, className: "text-zinc-300" }; } interface LogViewerProps { /** SSE 模式:订阅 /api/runs/[runId]/events。runId 变化时请通过 key 重挂载 */ runId?: number; /** 静态模式:直接展示完整日志 */ log?: string; live?: boolean; className?: string; emptyText?: string; /** SSE 收到 done 事件时回调 */ onDone?: () => void; } export function LogViewer({ runId, log, live, className, emptyText = "暂无日志", onDone, }: LogViewerProps) { const isLive = live && runId != null; const [sseLines, setSseLines] = useState([]); const boxRef = useRef(null); const stickRef = useRef(true); const onDoneRef = useRef(onDone); useEffect(() => { onDoneRef.current = onDone; }, [onDone]); useEffect(() => { if (!isLive) return; const es = new EventSource(`/api/runs/${runId}/events`); es.onmessage = (e) => { try { const d = JSON.parse(e.data) as { line?: unknown }; setSseLines((prev) => [ ...prev, typeof d.line === "string" ? d.line : String(e.data), ]); } catch { setSseLines((prev) => [...prev, String(e.data)]); } }; es.addEventListener("done", () => { es.close(); onDoneRef.current?.(); }); es.onerror = () => es.close(); return () => es.close(); }, [isLive, runId]); const lines = useMemo(() => { if (isLive) return sseLines; if (log == null || log === "") return []; return log.replace(/\n+$/, "").split("\n"); }, [isLive, sseLines, log]); useEffect(() => { const el = boxRef.current; if (el && stickRef.current) el.scrollTop = el.scrollHeight; }, [lines]); return (
{ const el = boxRef.current; if (!el) return; // 用户上翻时暂停自动滚动,回到底部附近时恢复 stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; }} className={cn( "h-80 overflow-auto rounded-md border border-zinc-800 bg-zinc-950 p-3 font-mono text-xs leading-5", className )} > {lines.length === 0 ? (
{emptyText}
) : ( lines.map((line, i) => { const { text, className: lineCls } = parseLine(line); return (
{text || " "}
); }) )}
); }