feat: 群分析本地消息库 / 处理中占位图 / Web 日志窗口重构
- 群分析: 新增 history_store 只读本地消息源(读 learning_chat 落库,含 uninfo 昵称补齐/去重/截断判定),适配器优先读本地库、失败回退 OneBot 分页;分页 锚点字段回退链修复 NapCat 传 message_seq 翻页断裂;新增「本地记录」开关 - core/message_utils: 新增 common_proc_reply 占位图通用回复(引用消息 + 处理中 动图,支持后台任务显式指定 target),群分析/战况/倒放改用 - web_hub + web: 日志页改固定窗口滚动 + 翻页锚定 + 自动换行,SSE 日志轮转发 reset 帧,入口 HTML no-cache,行数统计增量缓存,CPU 改非阻塞采样,登录信息缓存 - 插件内 CLAUDE.md / DESIGN.md 不入库(.gitignore),galgame_card 两份文档取消 跟踪(文件保留在磁盘) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -140,13 +140,17 @@ function makeSSEStream(opts: SSEStreamOpts): () => void {
|
||||
export function hubLogStream(opts: {
|
||||
since: number
|
||||
onLine: (line: string) => void
|
||||
// 服务端检测到日志被轮转/清空时回调,调用方应丢弃已失效的旧窗口
|
||||
onReset?: () => void
|
||||
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
|
||||
}): () => void {
|
||||
let since = opts.since
|
||||
return makeSSEStream({
|
||||
url: () => '/hub/api/logs/stream?since=' + since,
|
||||
onData: (obj) => {
|
||||
if (obj && obj.line !== undefined) {
|
||||
if (!obj) return
|
||||
if (obj.reset) { opts.onReset?.(); return }
|
||||
if (obj.line !== undefined) {
|
||||
opts.onLine(obj.line)
|
||||
if (typeof obj.offset === 'number') since = obj.offset
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { hubDashboardStream } from './api/client'
|
||||
|
||||
export type ConnStatus = '连接中' | '实时' | '重连中' | '已断开'
|
||||
export type ConnStatus = '连接中' | '已连接' | '重连中' | '已断开'
|
||||
|
||||
interface ConnState {
|
||||
status: ConnStatus
|
||||
@@ -28,7 +28,7 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
||||
if (d && d.ok !== false) { setData(d); setError(''); setLastUpdate(Date.now()) }
|
||||
else if (d && d.ok === false) setError(d.msg || '采集失败')
|
||||
},
|
||||
onStatus: (s) => setStatus(s === 'open' ? '实时' : s === 'reconnecting' ? '重连中' : '已断开'),
|
||||
onStatus: (s) => setStatus(s === 'open' ? '已连接' : s === 'reconnecting' ? '重连中' : '已断开'),
|
||||
})
|
||||
return dispose
|
||||
}, [])
|
||||
|
||||
@@ -192,9 +192,9 @@ export function AppLayout() {
|
||||
<div className="flex shrink-0 items-center gap-2 md:gap-3">
|
||||
<span
|
||||
title={lastUpdate ? '最后更新 ' + new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false }) : '正在连接后端…'}
|
||||
className={'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors duration-300 ' + (status === '实时' ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : status === '重连中' ? 'border-amber-200 bg-amber-50 text-amber-600' : 'border-slate-200 bg-slate-50 text-slate-400')}
|
||||
className={'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors duration-300 ' + (status === '已连接' ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : status === '重连中' ? 'border-amber-200 bg-amber-50 text-amber-600' : 'border-slate-200 bg-slate-50 text-slate-400')}
|
||||
>
|
||||
<span className={'size-1.5 rounded-full ' + (status === '实时' ? 'bg-emerald-500' : status === '重连中' ? 'animate-pulse bg-amber-500' : 'bg-slate-300')} />
|
||||
<span className={'size-1.5 rounded-full ' + (status === '已连接' ? 'bg-emerald-500' : status === '重连中' ? 'animate-pulse bg-amber-500' : 'bg-slate-300')} />
|
||||
{status}
|
||||
{lastUpdate && <span className="hidden text-[10px] opacity-70 sm:inline">{new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false })}</span>}
|
||||
</span>
|
||||
|
||||
+119
-43
@@ -1,22 +1,36 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { hubLogs, hubLogStream } from './api/client'
|
||||
|
||||
// 保留的最大行数(窗口较大以容纳「加载更早」预置的历史行)
|
||||
const MAX_LINES = 6000
|
||||
// 渲染窗口:日志页**始终只渲染这么多行**,先入先出——新行从尾部挤入,最旧的行从头部被挤掉。
|
||||
// 行数直接决定每次更新的 DOM 协调与布局开销,是页面的主要成本,不要随意调大。
|
||||
const WINDOW_LINES = 1000
|
||||
|
||||
// 「向前翻页」每次额外取的行数:比窗口小,翻页时保留一半旧内容做重叠,
|
||||
// 配合浏览器的滚动锚定,视线里的那行不会跳走。
|
||||
const PAGE_LINES = 500
|
||||
|
||||
// 新行合并刷新间隔(ms):日志突发时避免「一行一次 setState」
|
||||
const FLUSH_MS = 120
|
||||
|
||||
export interface LogLine { id: number; text: string }
|
||||
export interface LogInfo { file: string; size: number; total: number }
|
||||
|
||||
interface LogState {
|
||||
lines: string[]
|
||||
lines: LogLine[]
|
||||
info: LogInfo
|
||||
error: string
|
||||
loading: boolean
|
||||
hasMore: boolean
|
||||
loadingEarlier: boolean
|
||||
paused: boolean
|
||||
setPaused: (v: boolean) => void
|
||||
/** true = 自动滚动(跟随最新,新行实时入列);false = 冻结视图(新行暂存,回到底部再补上) */
|
||||
follow: boolean
|
||||
setFollow: (v: boolean) => void
|
||||
/** 自动换行:false 时长行不折行,改为横向滚动 */
|
||||
wrap: boolean
|
||||
setWrap: (v: boolean) => void
|
||||
refresh: () => Promise<void>
|
||||
loadEarlier: () => Promise<void>
|
||||
/** 向前翻一页;返回本次前置进来的行数(0 表示没翻成),调用方据此把视图锚回原处 */
|
||||
loadEarlier: () => Promise<number>
|
||||
}
|
||||
|
||||
const Ctx = createContext<LogState>({
|
||||
@@ -26,62 +40,110 @@ const Ctx = createContext<LogState>({
|
||||
loading: false,
|
||||
hasMore: false,
|
||||
loadingEarlier: false,
|
||||
paused: false,
|
||||
setPaused: () => {},
|
||||
follow: true,
|
||||
setFollow: () => {},
|
||||
wrap: true,
|
||||
setWrap: () => {},
|
||||
refresh: async () => {},
|
||||
loadEarlier: async () => {},
|
||||
loadEarlier: async () => 0,
|
||||
})
|
||||
|
||||
/**
|
||||
* 日志数据与日志流常驻 Provider:首次进入(任意页面)时拉取一次,
|
||||
* SSE 流保持不断,页面之间切换只读缓存数据,不重建连接、不显示加载动画。
|
||||
*
|
||||
* 窗口模型:内存里始终只有 WINDOW_LINES 行(先入先出)。
|
||||
* - 自动滚动:新行进窗口尾部,最旧的行被挤掉(tail -f 行为)
|
||||
* - 冻结视图:新行只进暂存区(同样保留最新 WINDOW_LINES 行),窗口不变,
|
||||
* 回到底部时一次性补上
|
||||
* - 向前翻页:滚动到顶部时取更早的 PAGE_LINES 行,从尾部挤掉同样多的新行,
|
||||
* 窗口行数不变(反过来的先入先出)
|
||||
*/
|
||||
export function LogProvider({ children }: { children: ReactNode }) {
|
||||
const [lines, setLines] = useState<string[]>([])
|
||||
const [lines, setLines] = useState<LogLine[]>([])
|
||||
const [info, setInfo] = useState<LogInfo>({ file: '', size: 0, total: 0 })
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loadingEarlier, setLoadingEarlier] = useState(false)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [follow, setFollow] = useState(true)
|
||||
const [wrap, setWrap] = useState(true)
|
||||
|
||||
const sinceRef = useRef(0)
|
||||
const earliestRef = useRef(0)
|
||||
const pausedRef = useRef(false)
|
||||
const pendingRef = useRef<string[]>([])
|
||||
const followRef = useRef(true)
|
||||
const frozenRef = useRef<string[]>([])
|
||||
const disposeRef = useRef<(() => void) | null>(null)
|
||||
const loadingEarlierRef = useRef(false)
|
||||
|
||||
const append = useCallback((line: string) => {
|
||||
setLines(prev => [...prev, line].slice(-MAX_LINES))
|
||||
// 行 id:作为 React key 保持稳定(用下标做 key 时,窗口一滑动整列表都要 diff)
|
||||
const idRef = useRef(0)
|
||||
// 待入列的新行与合并刷新定时器
|
||||
const bufferRef = useRef<string[]>([])
|
||||
const timerRef = useRef<number | null>(null)
|
||||
|
||||
const toItems = useCallback((texts: string[]): LogLine[] => {
|
||||
const items: LogLine[] = []
|
||||
for (const text of texts) items.push({ id: idRef.current++, text })
|
||||
return items
|
||||
}, [])
|
||||
|
||||
const flush = useCallback(() => {
|
||||
timerRef.current = null
|
||||
const buffered = bufferRef.current
|
||||
if (!buffered.length) return
|
||||
bufferRef.current = []
|
||||
// 一次涌入超过整个窗口时(日志突发/整文件重放),只有最后 WINDOW_LINES 行留得下,
|
||||
// 先裁掉前面的,避免为注定被挤掉的行白渲染一遍
|
||||
const kept = buffered.length > WINDOW_LINES ? buffered.slice(-WINDOW_LINES) : buffered
|
||||
// 副作用放在 setState 之外:StrictMode 下 updater 可能被调用两次
|
||||
const items = toItems(kept)
|
||||
setLines(prev => {
|
||||
const next = prev.concat(items)
|
||||
return next.length > WINDOW_LINES ? next.slice(-WINDOW_LINES) : next
|
||||
})
|
||||
}, [toItems])
|
||||
|
||||
const enqueue = useCallback((line: string) => {
|
||||
if (!followRef.current) {
|
||||
// 冻结视图期间只暂存,且同样只保留最新 WINDOW_LINES 行
|
||||
frozenRef.current.push(line)
|
||||
if (frozenRef.current.length > WINDOW_LINES) {
|
||||
frozenRef.current.splice(0, frozenRef.current.length - WINDOW_LINES)
|
||||
}
|
||||
return
|
||||
}
|
||||
bufferRef.current.push(line)
|
||||
if (timerRef.current == null) timerRef.current = window.setTimeout(flush, FLUSH_MS)
|
||||
}, [flush])
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (disposeRef.current) disposeRef.current()
|
||||
disposeRef.current = hubLogStream({
|
||||
since: sinceRef.current,
|
||||
onLine: (line) => {
|
||||
if (pausedRef.current) {
|
||||
pendingRef.current.push(line)
|
||||
// 暂停期间只保留最近若干行,避免恢复时一次性渲染超大数组
|
||||
if (pendingRef.current.length > 500) pendingRef.current.splice(0, pendingRef.current.length - 500)
|
||||
return
|
||||
}
|
||||
append(line)
|
||||
onLine: enqueue,
|
||||
onReset: () => {
|
||||
// 服务端日志被轮转/清空:丢弃已失效的旧窗口,从新文件继续
|
||||
if (timerRef.current != null) { window.clearTimeout(timerRef.current); timerRef.current = null }
|
||||
bufferRef.current = []
|
||||
frozenRef.current = []
|
||||
setLines([])
|
||||
earliestRef.current = 0
|
||||
setHasMore(false)
|
||||
},
|
||||
})
|
||||
}, [append])
|
||||
}, [enqueue])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const d = await hubLogs(500)
|
||||
const d = await hubLogs(WINDOW_LINES)
|
||||
if (!d) throw new Error('读取日志失败')
|
||||
if (d.ok === false) throw new Error(d.msg || '读取日志失败')
|
||||
setLines(d.lines || [])
|
||||
setLines(toItems(d.lines || []))
|
||||
setInfo({ file: d.file || '', size: d.size || 0, total: d.total || 0 })
|
||||
// stream 从文件尾续读(end);「加载更早」从窗口起点向前翻(offset)
|
||||
// stream 从文件尾续读(end);向前翻页从窗口起点继续往前(offset)
|
||||
sinceRef.current = d.end || 0
|
||||
earliestRef.current = d.offset || 0
|
||||
setHasMore((d.offset || 0) > 0)
|
||||
@@ -89,47 +151,61 @@ export function LogProvider({ children }: { children: ReactNode }) {
|
||||
} catch (e: any) {
|
||||
setError(e.message || '读取日志失败')
|
||||
} finally { setLoading(false) }
|
||||
}, [connect])
|
||||
}, [connect, toItems])
|
||||
|
||||
const loadEarlier = useCallback(async () => {
|
||||
const loadEarlier = useCallback(async (): Promise<number> => {
|
||||
const before = earliestRef.current
|
||||
if (!before || loadingEarlierRef.current) return
|
||||
if (!before || loadingEarlierRef.current) return 0
|
||||
loadingEarlierRef.current = true
|
||||
setLoadingEarlier(true)
|
||||
setError('')
|
||||
try {
|
||||
const d = await hubLogs(500, before)
|
||||
const d = await hubLogs(PAGE_LINES, before)
|
||||
if (!d || d.ok === false) throw new Error((d && d.msg) || '读取日志失败')
|
||||
setLines(prev => [...(d.lines || []), ...prev].slice(-MAX_LINES))
|
||||
const items = toItems(d.lines || [])
|
||||
setLines(prev => {
|
||||
const next = items.concat(prev)
|
||||
// 往前翻时从尾部(较新的行)挤掉:窗口行数不变,新的行让位给历史
|
||||
return next.length > WINDOW_LINES ? next.slice(0, WINDOW_LINES) : next
|
||||
})
|
||||
earliestRef.current = d.offset || 0
|
||||
setHasMore((d.offset || 0) > 0)
|
||||
return items.length
|
||||
} catch (e: any) {
|
||||
setError(e.message || '读取日志失败')
|
||||
return 0
|
||||
} finally {
|
||||
loadingEarlierRef.current = false
|
||||
setLoadingEarlier(false)
|
||||
}
|
||||
}, [])
|
||||
}, [toItems])
|
||||
|
||||
// 首次进入就建立并保持;provider 卸载(登出/离开 /hub)时才断开
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
return () => { if (disposeRef.current) disposeRef.current() }
|
||||
return () => {
|
||||
if (disposeRef.current) disposeRef.current()
|
||||
// 组件卸载时清掉待刷新定时器,避免对已卸载组件 setState
|
||||
if (timerRef.current != null) { window.clearTimeout(timerRef.current); timerRef.current = null }
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => { pausedRef.current = paused }, [paused])
|
||||
|
||||
useEffect(() => {
|
||||
// 恢复接收时,把暂停期间暂存的行补进来
|
||||
if (!paused && pendingRef.current.length) {
|
||||
const flush = pendingRef.current
|
||||
pendingRef.current = []
|
||||
setLines(prev => [...prev, ...flush].slice(-MAX_LINES))
|
||||
followRef.current = follow
|
||||
// 恢复自动滚动时,把冻结期间暂存的行一次性补上(仍只保留 WINDOW_LINES 行)
|
||||
if (follow && frozenRef.current.length) {
|
||||
const frozen = frozenRef.current
|
||||
frozenRef.current = []
|
||||
const items = toItems(frozen)
|
||||
setLines(prev => {
|
||||
const next = prev.concat(items)
|
||||
return next.length > WINDOW_LINES ? next.slice(-WINDOW_LINES) : next
|
||||
})
|
||||
}
|
||||
}, [paused])
|
||||
}, [follow, toItems])
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ lines, info, error, loading, hasMore, loadingEarlier, paused, setPaused, refresh, loadEarlier }}>
|
||||
<Ctx.Provider value={{ lines, info, error, loading, hasMore, loadingEarlier, follow, setFollow, wrap, setWrap, refresh, loadEarlier }}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
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'
|
||||
@@ -67,7 +67,9 @@ function parseAnsi(text: string): { text: string; style: CSSProperties }[] {
|
||||
return out
|
||||
}
|
||||
|
||||
function AnsiLine({ text }: { text: string }) {
|
||||
// 记忆化:窗口内已渲染过的行必须能整体跳过,否则每次更新都要把整窗口的
|
||||
// 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) {
|
||||
@@ -78,13 +80,16 @@ function AnsiLine({ text }: { text: string }) {
|
||||
const color = m && LEVEL_COLORS[m[1]]
|
||||
if (color) return <span style={{ color }}>{text}</span>
|
||||
return <>{text}</>
|
||||
}
|
||||
})
|
||||
|
||||
export default function LogsPage() {
|
||||
// 数据与日志流由全局 LogProvider 常驻管理:离开/回来不重建连接、不转圈
|
||||
const { lines, info, error, loading, hasMore, loadingEarlier, paused, setPaused, loadEarlier } = useLogs()
|
||||
const [follow, setFollow] = useState(true)
|
||||
const { lines, info, error, loading, hasMore, loadingEarlier, follow, setFollow, wrap, setWrap, loadEarlier } = useLogs()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
// 一次「滚到顶」只翻一页,必须离开顶部后才会再次触发(避免连环翻页)
|
||||
const topLoadBlockRef = useRef(false)
|
||||
// 本次前置进来的行数:等 DOM 更新后据此把视图锚回原来的位置
|
||||
const pendingAnchorRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (follow && scrollRef.current) {
|
||||
@@ -92,19 +97,56 @@ export default function LogsPage() {
|
||||
}
|
||||
}, [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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold text-slate-800">查看日志</h2>
|
||||
<p className="text-sm text-slate-400">实时读取 bot 运行日志(SSE 推送)</p>
|
||||
<p className="text-sm text-slate-400">实时读取 bot 运行日志(SSE 推送,固定窗口滚动,向上滚动自动加载更早)</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 md:justify-end">
|
||||
{hasMore && (
|
||||
<Button size="sm" variant="tertiary" isDisabled={loadingEarlier} onPress={loadEarlier}>{loadingEarlier ? '加载中…' : '加载更早'}</Button>
|
||||
)}
|
||||
<Button size="sm" variant="tertiary" onPress={() => setPaused(!paused)}>{paused ? '恢复接收' : '暂停接收'}</Button>
|
||||
<Button size="sm" variant="tertiary" onPress={() => setFollow(v => !v)}>{follow ? '停止跟随' : '跟随最新'}</Button>
|
||||
<Button size="sm" variant="tertiary" onPress={() => setFollow(!follow)}>{follow ? '停止跟随' : '跟随最新'}</Button>
|
||||
<Button size="sm" variant="tertiary" onPress={() => setWrap(!wrap)}>{wrap ? '关闭换行' : '自动换行'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,16 +155,25 @@ export default function LogsPage() {
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Card.Title>日志流</Card.Title>
|
||||
<Card.Title>
|
||||
日志流
|
||||
{loadingEarlier && <span className="ml-2 text-xs font-normal text-slate-400">加载更早…</span>}
|
||||
</Card.Title>
|
||||
{info.file && <span className="text-xs text-slate-400">{info.file} · {fmtBytes(info.size)} · 共 {info.total} 行</span>}
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div ref={scrollRef} 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)]">
|
||||
<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'}`}
|
||||
>
|
||||
{loading && !lines.length ? (
|
||||
<div className="flex h-full items-center justify-center"><Spinner /></div>
|
||||
) : lines.length ? lines.map((ln, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all"><AnsiLine text={ln} /></div>
|
||||
) : lines.length ? lines.map(ln => (
|
||||
<div key={ln.id}><AnsiLine text={ln.text} /></div>
|
||||
)) : (
|
||||
<div className="text-slate-500">暂无日志…</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user