refactor: restructure plugins per MTSS standard, consolidate assets to res/
Per docs/plugin-audit-report.md (plugins normalized to Trigger(handlers) → Service(services) → Model(repository/models) + utils): - Split monolithic __init__.py into handlers/services/utils across dailywife, deer_pipe, dice, galgame_card, helldivers_tools, huoziyinshua, learning_chat, makeaquote, mc_server_status, ncm_saying, picfinder_take, picstatus, random_jm_code, regif, steam_info, video_analysis, group_tools - Move static assets under res/: deer_pipe font/img, makeaquote font, helldivers img/templates, huoziyinshua HuoZiYinShua - Add config.py + register_config_items to ncm_saying, random_jm_code, group_tools; learning_chat unified config bridge - Remove deprecated: voice_trans plugin, bf_bot/test.py, dead code in dailywife/deer_pipe, empty dirs, debug scripts under helldivers temp - Disable brash_general_supercredits_tools (stub comment only) - bot.py: optional stdout/stderr redirect to log file for Web log viewer, force ANSI colorize on non-TTY sinks - Move runtime data (jm_code.json) out of plugin dir into hexi/data - Docs: plugin-audit-report.md; README reflects removed plugins Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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]
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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) => <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}</>
|
||||
}
|
||||
|
||||
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<'连接中' | '实时' | '重连中' | '已断开'>('连接中')
|
||||
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) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [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>
|
||||
<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>
|
||||
<Button size="sm" variant="tertiary" onPress={() => setFollow(v => !v)}>{follow ? '停止跟随' : '跟随最新'}</Button>
|
||||
</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">
|
||||
<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>
|
||||
</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 key={i} className="whitespace-pre-wrap break-all"><AnsiLine text={ln} /></div>
|
||||
)) : (
|
||||
<div className="text-slate-500">暂无日志…</div>
|
||||
)}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user