feat: revamp web hub — global connection status, array config editor, log streaming

- Global connection badge in app header (persistent dashboard SSE via ConnectionProvider)
- LogProvider keeps log data and SSE stream alive across page switches (no reload spinner)
- Config drawer: password reveal toggle, array-type row editor for list fields
- Plugin management: optimistic updates, race-free loading, reset all group overrides
- Logs: load-earlier pagination; backend tail-read optimization (before/end offsets)
- web_auth: Web password changes persist (sync_admin seeding only when .env has explicit key)
- Dashboard: collect in thread pool + 1s result cache
- ErrorBoundary + local Suspense: plugin chunk loading stays inside content area
- UI polish: table row hover, mobile header/breadcrumb fixes, wider config drawer
- config_standard: array type inference for list fields; array env skip
- random_jm_code / steam_info: array schema migration; README web frontend note

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-03 15:39:10 +08:00
co-authored by Claude Code
parent 9371a28e35
commit c53b210413
45 changed files with 1312 additions and 576 deletions
+18 -86
View File
@@ -1,17 +1,7 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
import { Button, Card } from '@heroui/react'
import { hubLogs, hubLogStream } from '../../api/client'
const MAX_LINES = 2000
function fmtBytes(n: number): string {
if (!Number.isFinite(n)) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let v = n
let i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
return v.toFixed(1) + ' ' + units[i]
}
import { 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<number, string> = {
@@ -91,68 +81,10 @@ function AnsiLine({ text }: { text: string }) {
}
export default function LogsPage() {
const [lines, setLines] = useState<string[]>([])
const [info, setInfo] = useState<{ file: string; size: number; total: number }>({ file: '', size: 0, total: 0 })
const [error, setError] = useState('')
const [streamStatus, setStreamStatus] = useState<'连接中' | '实时' | '重连中' | '已断开'>('连接中')
// 数据与日志流由全局 LogProvider 常驻管理:离开/回来不重建连接、不转圈
const { lines, info, error, loading, hasMore, loadingEarlier, paused, setPaused, loadEarlier } = useLogs()
const [follow, setFollow] = useState(true)
const [paused, setPaused] = useState(false)
const [loading, setLoading] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const sinceRef = useRef(0)
const pausedRef = useRef(false)
const pendingRef = useRef<string[]>([])
const disposeRef = useRef<(() => void) | null>(null)
const append = useCallback((line: string) => {
setLines(prev => [...prev, line].slice(-MAX_LINES))
}, [])
const connect = useCallback(() => {
if (disposeRef.current) disposeRef.current()
disposeRef.current = hubLogStream({
since: sinceRef.current,
onLine: (line) => {
if (pausedRef.current) { pendingRef.current.push(line); return }
append(line)
},
onStatus: (s) => setStreamStatus(s === 'open' ? '实时' : s === 'reconnecting' ? '重连中' : '已断开'),
})
}, [append])
const refresh = useCallback(async () => {
setLoading(true)
setError('')
try {
const d = await hubLogs(500)
if (!d) throw new Error('读取日志失败')
if (d.ok === false) throw new Error(d.msg || '读取日志失败')
setLines(d.lines || [])
setInfo({ file: d.file || '', size: d.size || 0, total: d.total || 0 })
sinceRef.current = d.offset || 0
connect()
} catch (e: any) {
setError(e.message || '读取日志失败')
} finally { setLoading(false) }
}, [connect])
useEffect(() => {
pausedRef.current = paused
}, [paused])
useEffect(() => {
// 恢复接收时,把暂停期间暂存的行补进来
if (!paused && pendingRef.current.length) {
const flush = pendingRef.current
pendingRef.current = []
setLines(prev => [...prev, ...flush].slice(-MAX_LINES))
}
}, [paused])
useEffect(() => {
refresh()
return () => { if (disposeRef.current) disposeRef.current() }
}, [refresh])
useEffect(() => {
if (follow && scrollRef.current) {
@@ -160,17 +92,18 @@ export default function LogsPage() {
}
}, [lines, follow])
const statusClass = streamStatus === '实时' ? 'text-emerald-600' : streamStatus === '重连中' ? 'text-amber-600' : 'text-slate-500'
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<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>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="tertiary" onPress={() => setPaused(v => !v)}>{paused ? '恢复接收' : '暂停接收'}</Button>
<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>
</div>
</div>
@@ -181,15 +114,14 @@ export default function LogsPage() {
<Card.Header>
<div className="flex w-full items-center justify-between">
<Card.Title>日志流</Card.Title>
<div className="flex items-center gap-3 text-xs text-slate-400">
<span className={statusClass}>● {streamStatus}</span>
{info.file && <span>{info.file} · {fmtBytes(info.size)} · 共 {info.total} 行</span>}
</div>
{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-[calc(100vh-320px)] min-h-[320px] overflow-auto rounded-lg bg-slate-900 p-3 font-mono text-xs leading-relaxed text-slate-100">
{lines.length ? lines.map((ln, i) => (
<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)]">
{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>
)) : (
<div className="text-slate-500">暂无日志…</div>