2026-09-14 18:59:01 +08:00
|
|
|
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
|
2026-09-03 15:39:10 +08:00
|
|
|
import { Button, Card, Spinner } from '@heroui/react'
|
|
|
|
|
import { useLogs } from '../../logs'
|
|
|
|
|
import { fmtBytes } from '../../lib/format'
|
2026-09-03 00:44:38 +08:00
|
|
|
|
|
|
|
|
// ANSI SGR 颜色码 → 内联样式,用于还原 loguru 彩色日志
|
|
|
|
|
const ANSI_FG: Record<number, string> = {
|
|
|
|
|
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<number, string> = {
|
|
|
|
|
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<string, string> = {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 18:59:01 +08:00
|
|
|
// 记忆化:窗口内已渲染过的行必须能整体跳过,否则每次更新都要把整窗口的
|
|
|
|
|
// ANSI 解析 + DOM 协调重做一遍。
|
|
|
|
|
const AnsiLine = memo(function AnsiLine({ text }: { text: string }) {
|
2026-09-03 00:44:38 +08:00
|
|
|
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) => <span key={i} style={p.style}>{p.text}</span>)}</>
|
|
|
|
|
}
|
|
|
|
|
// 无 ANSI 时按等级标记整体上色,保证非彩色日志文件也能在 Web 里看到颜色。
|
|
|
|
|
const m = text.match(/\[(TRACE|DEBUG|INFO|SUCCESS|WARNING|ERROR|CRITICAL)\]/)
|
|
|
|
|
const color = m && LEVEL_COLORS[m[1]]
|
|
|
|
|
if (color) return <span style={{ color }}>{text}</span>
|
|
|
|
|
return <>{text}</>
|
2026-09-14 18:59:01 +08:00
|
|
|
})
|
2026-09-03 00:44:38 +08:00
|
|
|
|
|
|
|
|
export default function LogsPage() {
|
2026-09-03 15:39:10 +08:00
|
|
|
// 数据与日志流由全局 LogProvider 常驻管理:离开/回来不重建连接、不转圈
|
2026-09-14 18:59:01 +08:00
|
|
|
const { lines, info, error, loading, hasMore, loadingEarlier, follow, setFollow, wrap, setWrap, loadEarlier } = useLogs()
|
2026-09-03 00:44:38 +08:00
|
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
2026-09-14 18:59:01 +08:00
|
|
|
// 一次「滚到顶」只翻一页,必须离开顶部后才会再次触发(避免连环翻页)
|
|
|
|
|
const topLoadBlockRef = useRef(false)
|
|
|
|
|
// 本次前置进来的行数:等 DOM 更新后据此把视图锚回原来的位置
|
|
|
|
|
const pendingAnchorRef = useRef<number | null>(null)
|
2026-09-03 00:44:38 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (follow && scrollRef.current) {
|
|
|
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
|
|
|
|
}
|
|
|
|
|
}, [lines, follow])
|
|
|
|
|
|
2026-09-14 18:59:01 +08:00
|
|
|
// 向前翻页后把新行(它们被插在顶部)往上推出视野,让原本正在看的那一行留在原处。
|
|
|
|
|
// 窗口行数固定,翻页前后总高度几乎不变,浏览器自带的滚动锚定在 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])
|
|
|
|
|
|
2026-09-03 00:44:38 +08:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
2026-09-03 15:39:10 +08:00
|
|
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
|
|
|
<div className="min-w-0">
|
2026-09-03 00:44:38 +08:00
|
|
|
<h2 className="text-xl font-semibold text-slate-800">查看日志</h2>
|
2026-09-14 18:59:01 +08:00
|
|
|
<p className="text-sm text-slate-400">实时读取 bot 运行日志(SSE 推送,固定窗口滚动,向上滚动自动加载更早)</p>
|
2026-09-03 00:44:38 +08:00
|
|
|
</div>
|
2026-09-03 15:39:10 +08:00
|
|
|
<div className="flex shrink-0 flex-wrap items-center gap-2 md:justify-end">
|
2026-09-14 18:59:01 +08:00
|
|
|
<Button size="sm" variant="tertiary" onPress={() => setFollow(!follow)}>{follow ? '停止跟随' : '跟随最新'}</Button>
|
|
|
|
|
<Button size="sm" variant="tertiary" onPress={() => setWrap(!wrap)}>{wrap ? '关闭换行' : '自动换行'}</Button>
|
2026-09-03 00:44:38 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error && <Card className="border border-red-200 bg-red-50"><Card.Content><p className="text-sm text-red-600">{error}</p></Card.Content></Card>}
|
|
|
|
|
|
|
|
|
|
<Card className="border border-slate-200 bg-white">
|
|
|
|
|
<Card.Header>
|
|
|
|
|
<div className="flex w-full items-center justify-between">
|
2026-09-14 18:59:01 +08:00
|
|
|
<Card.Title>
|
|
|
|
|
日志流
|
|
|
|
|
{loadingEarlier && <span className="ml-2 text-xs font-normal text-slate-400">加载更早…</span>}
|
|
|
|
|
</Card.Title>
|
2026-09-03 15:39:10 +08:00
|
|
|
{info.file && <span className="text-xs text-slate-400">{info.file} · {fmtBytes(info.size)} · 共 {info.total} 行</span>}
|
2026-09-03 00:44:38 +08:00
|
|
|
</div>
|
|
|
|
|
</Card.Header>
|
|
|
|
|
<Card.Content>
|
2026-09-14 18:59:01 +08:00
|
|
|
<div
|
|
|
|
|
ref={scrollRef}
|
|
|
|
|
onScroll={onScroll}
|
|
|
|
|
// white-space / word-break 都是可继承属性,放在容器上:切换「自动换行」只改一个 class,
|
|
|
|
|
// 不必逐行改 1000 个元素(那会导致整列表重排,实测一次卡 100ms+)
|
|
|
|
|
className={`h-[60vh] min-h-[320px] overflow-auto rounded-lg bg-slate-900 p-3 font-mono text-xs leading-relaxed text-slate-100 md:h-[calc(100vh-320px)] ${wrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}
|
|
|
|
|
>
|
2026-09-03 15:39:10 +08:00
|
|
|
{loading && !lines.length ? (
|
|
|
|
|
<div className="flex h-full items-center justify-center"><Spinner /></div>
|
2026-09-14 18:59:01 +08:00
|
|
|
) : lines.length ? lines.map(ln => (
|
|
|
|
|
<div key={ln.id}><AnsiLine text={ln.text} /></div>
|
2026-09-03 00:44:38 +08:00
|
|
|
)) : (
|
|
|
|
|
<div className="text-slate-500">暂无日志…</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</Card.Content>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|