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:
@@ -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