import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' import { Button, Card, Spinner } from '@heroui/react' import { useLogs } from '../../logs' import { fmtBytes } from '../../lib/format' // ANSI SGR 颜色码 → 内联样式,用于还原 loguru 彩色日志 const ANSI_FG: Record = { 30: '#9ca3af', 31: '#f87171', 32: '#4ade80', 33: '#fbbf24', 34: '#60a5fa', 35: '#c084fc', 36: '#22d3ee', 37: '#e5e7eb', 90: '#6b7280', 91: '#f87171', 92: '#4ade80', 93: '#fbbf24', 94: '#60a5fa', 95: '#c084fc', 96: '#22d3ee', 97: '#f9fafb', } const ANSI_BG: Record = { 40: '#1f2937', 41: '#7f1d1d', 42: '#14532d', 43: '#713f12', 44: '#1e3a8a', 45: '#581c87', 46: '#155e75', 47: '#111827', 100: '#374151', 101: '#7f1d1d', 102: '#14532d', 103: '#713f12', 104: '#1e3a8a', 105: '#581c87', 106: '#155e75', 107: '#111827', } // 当日志行不含 ANSI 颜色码(例如历史日志/旧进程写入的非彩色文本)时, // 按 loguru 等级标记降级上色,确保 Web 日志查看器始终有颜色。 const LEVEL_COLORS: Record = { TRACE: '#c084fc', DEBUG: '#60a5fa', INFO: '#22d3ee', SUCCESS: '#4ade80', WARNING: '#fbbf24', ERROR: '#f87171', CRITICAL: '#f87171', } function parseAnsi(text: string): { text: string; style: CSSProperties }[] { const out: { text: string; style: CSSProperties }[] = [] let fg: string | undefined let bg: string | undefined let bold = false let underline = false let run = '' const flush = () => { if (!run) return const style: CSSProperties = {} if (fg) style.color = fg if (bg) style.backgroundColor = bg if (bold) style.fontWeight = 'bold' if (underline) style.textDecoration = 'underline' out.push({ text: run, style }) run = '' } for (let i = 0; i < text.length; i++) { const ch = text[i] if (ch === '\u001b' && text[i + 1] === '[') { const end = text.indexOf('m', i) if (end !== -1) { const codes = text.slice(i + 2, end).split(';').map(s => parseInt(s, 10)) flush() for (const c of codes) { if (c === 0) { fg = undefined; bg = undefined; bold = false; underline = false } else if (c === 1) bold = true else if (c === 4) underline = true else if (c >= 30 && c <= 37 || c >= 90 && c <= 97) fg = ANSI_FG[c] else if (c >= 40 && c <= 47 || c >= 100 && c <= 107) bg = ANSI_BG[c] else if (c === 39) fg = undefined else if (c === 49) bg = undefined } i = end continue } } run += ch } flush() return out } // 记忆化:窗口内已渲染过的行必须能整体跳过,否则每次更新都要把整窗口的 // ANSI 解析 + DOM 协调重做一遍。 const AnsiLine = memo(function AnsiLine({ text }: { text: string }) { const parts = parseAnsi(text) const hasAnsi = parts.some((p) => Boolean(p.style.color || p.style.backgroundColor || p.style.fontWeight || p.style.textDecoration)) if (hasAnsi) { return <>{parts.map((p, i) => {p.text})} } // 无 ANSI 时按等级标记整体上色,保证非彩色日志文件也能在 Web 里看到颜色。 const m = text.match(/\[(TRACE|DEBUG|INFO|SUCCESS|WARNING|ERROR|CRITICAL)\]/) const color = m && LEVEL_COLORS[m[1]] if (color) return {text} return <>{text} }) export default function LogsPage() { // 数据与日志流由全局 LogProvider 常驻管理:离开/回来不重建连接、不转圈 const { lines, info, error, loading, hasMore, loadingEarlier, follow, setFollow, wrap, setWrap, loadEarlier } = useLogs() const scrollRef = useRef(null) // 一次「滚到顶」只翻一页,必须离开顶部后才会再次触发(避免连环翻页) const topLoadBlockRef = useRef(false) // 本次前置进来的行数:等 DOM 更新后据此把视图锚回原来的位置 const pendingAnchorRef = useRef(null) useEffect(() => { if (follow && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight } }, [lines, follow]) // 向前翻页后把新行(它们被插在顶部)往上推出视野,让原本正在看的那一行留在原处。 // 窗口行数固定,翻页前后总高度几乎不变,浏览器自带的滚动锚定在 scrollTop=0 时也不生效, // 所以这里手动按元素位置锚定。 useEffect(() => { const n = pendingAnchorRef.current if (n == null) return const el = scrollRef.current if (!el) { pendingAnchorRef.current = null; return } const first = el.firstElementChild as HTMLElement | null const target = el.children[n] as HTMLElement | undefined if (!first || !target) return // DOM 还没更新,等下一次 lines 变化再试 el.scrollTop = target.offsetTop - first.offsetTop pendingAnchorRef.current = null }, [lines]) const handleLoadEarlier = useCallback(async () => { const n = await loadEarlier() if (n > 0) pendingAnchorRef.current = n }, [loadEarlier]) const onScroll = useCallback(() => { const el = scrollRef.current if (!el) return const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24 if (atBottom) { // 回到底部即恢复跟随(否则新日志会把视图拽走,永远看不了历史) if (!follow) setFollow(true) return } if (follow) setFollow(false) if (el.scrollTop > 8) { topLoadBlockRef.current = false return } if (!topLoadBlockRef.current && hasMore && !loadingEarlier) { topLoadBlockRef.current = true handleLoadEarlier() } }, [follow, hasMore, loadingEarlier, handleLoadEarlier, setFollow]) return (

查看日志

实时读取 bot 运行日志(SSE 推送,固定窗口滚动,向上滚动自动加载更早)

{error &&

{error}

}
日志流 {loadingEarlier && 加载更早…} {info.file && {info.file} · {fmtBytes(info.size)} · 共 {info.total} 行}
{loading && !lines.length ? (
) : lines.length ? lines.map(ln => (
)) : (
暂无日志…
)}
) }