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:
2026-09-03 00:44:38 +08:00
co-authored by Claude
parent 25bc30611f
commit 9371a28e35
2389 changed files with 5100 additions and 10127 deletions
+171 -47
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Avatar, Button, Card } from '@heroui/react'
import { hubDashboard } from '../../api/client'
import { Avatar, Card } from '@heroui/react'
import { hubDashboard, hubDashboardStream } from '../../api/client'
interface BotInfo {
self_id: string
@@ -52,6 +52,29 @@ interface DiskInfo {
used: number
total: number
}
interface NicInfo {
name: string
up: boolean
sent: number
recv: number
ip: string
}
interface NetworkInfo {
sent: number
recv: number
sent_rate: number
recv_rate: number
packets_sent: number
packets_recv: number
interfaces: NicInfo[]
}
interface BackendInfo {
nonebot_version: string
plugin_count: number
python: string
driver: string
driver_type: string
}
interface DashboardData {
ok: boolean
bots: BotInfo[]
@@ -60,6 +83,8 @@ interface DashboardData {
memory: MemInfo
process: ProcessInfo
disk: DiskInfo[]
network: NetworkInfo
backend: BackendInfo
}
function fmtBytes(n: number): string {
@@ -71,6 +96,11 @@ function fmtBytes(n: number): string {
return v.toFixed(1) + ' ' + units[i]
}
function fmtRate(n: number): string {
if (!Number.isFinite(n) || n < 0) return '0 B/s'
return fmtBytes(n) + '/s'
}
function fmtUptime(sec: number): string {
if (!Number.isFinite(sec)) return '—'
const d = Math.floor(sec / 86400)
@@ -100,6 +130,24 @@ function coreColor(v: number): string {
return 'bg-emerald-500'
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between border-b border-slate-100 pb-1.5">
<span className="text-xs text-slate-400">{label}</span>
<span className="text-sm font-medium text-slate-700 text-right">{value || '—'}</span>
</div>
)
}
function SectionTitle({ title, desc }: { title: string; desc: string }) {
return (
<div className="mb-1">
<h3 className="text-base font-semibold text-slate-800">{title}</h3>
<p className="text-xs text-slate-400">{desc}</p>
</div>
)
}
export default function HomePage() {
const [data, setData] = useState<DashboardData | null>(null)
const [error, setError] = useState('')
@@ -124,53 +172,93 @@ export default function HomePage() {
}, [])
useEffect(() => {
refresh(true) // 初始静默加载
const timer = setInterval(() => {
if (document.visibilityState !== 'hidden') refresh(true)
}, 15000)
const onVisible = () => {
if (document.visibilityState === 'visible') refresh(true)
}
document.addEventListener('visibilitychange', onVisible)
return () => {
clearInterval(timer)
document.removeEventListener('visibilitychange', onVisible)
}
refresh(true) // 初始静默加载,快速出首屏
const dispose = hubDashboardStream({
onData: (d) => {
if (d && d.ok !== false) { setData(d); setError('') }
},
})
return () => { dispose() }
}, [refresh])
const cpu = data?.cpu
const mem = data?.memory
const proc = data?.process
const sys = data?.system
const hostSubtitle = [
cpu?.brand,
cpu?.logical && cpu.logical + ' 核',
(data ? sys?.python : ''),
data ? 'NoneBot ' + sys?.nonebot : '',
].filter(Boolean).join(' · ')
const net = data?.network
const backend = data?.backend
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">主机与服务状态</p>
<p className="text-sm text-slate-400">主机与 BOT 综合状态(每 2 秒刷新)</p>
</div>
<Button size="sm" isDisabled={loading} onPress={() => refresh(false)}>{loading ? '刷新中…' : '刷新'}</Button>
</div>
{error && !data && <Card className="border border-red-200 bg-red-50"><Card.Content><p className="text-sm text-red-600">{error}</p></Card.Content></Card>}
{/* 主机资源 */}
{/* 1. 主机规格信息:系统 / 处理器 / 内存 / 硬盘 */}
<Card className="border border-slate-200 bg-white">
<Card.Header>
<div className="flex w-full items-center justify-between">
<div>
<Card.Title>主机资源</Card.Title>
<p className="text-xs text-slate-400">{hostSubtitle || '加载中…'}</p>
<SectionTitle title="主机规格信息" desc="系统 / 处理器 / 内存 / 硬盘" />
</Card.Header>
<Card.Content>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="rounded-xl border border-slate-200 p-4">
<div className="mb-2 text-sm font-medium text-slate-700">系统</div>
<div className="space-y-1.5">
<InfoRow label="主机名" value={sys?.hostname || ''} />
<InfoRow label="操作系统" value={sys?.os || ''} />
<InfoRow label="运行时长" value={sys ? fmtUptime(sys.system_uptime_seconds) : '—'} />
</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="mb-2 text-sm font-medium text-slate-700">处理器</div>
<div className="space-y-1.5">
<InfoRow label="型号" value={cpu?.brand || ''} />
<InfoRow label="逻辑核心" value={cpu?.logical != null ? cpu.logical + ' 核' : '—'} />
<InfoRow label="物理核心" value={cpu?.physical != null ? cpu.physical + ' 核' : '—'} />
</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="mb-2 text-sm font-medium text-slate-700">内存</div>
<div className="space-y-1.5">
<InfoRow label="总量" value={mem ? fmtBytes(mem.total) : '—'} />
<InfoRow label="可用" value={mem ? fmtBytes(mem.available) : '—'} />
<InfoRow label="本进程占用" value={mem ? fmtBytes(mem.process_rss) : '—'} />
</div>
</div>
</div>
<div className="mt-4">
<div className="mb-2 text-sm font-medium text-slate-700">硬盘</div>
{!data ? (
<p className="text-sm text-slate-400">加载中…</p>
) : !data.disk.length ? (
<p className="text-sm text-slate-400">未获取到磁盘信息</p>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data.disk.map((d) => (
<div key={d.mount} className="rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between">
<span className="truncate text-xs font-medium text-slate-600" title={d.mount}>{d.mount}</span>
<span className="text-xs text-slate-500">{d.percent}%</span>
</div>
<div className="mt-2"><Bar value={d.percent} /></div>
<p className="mt-2 text-xs text-slate-400">{fmtBytes(d.used)} / {fmtBytes(d.total)}</p>
</div>
))}
</div>
)}
</div>
</Card.Content>
</Card>
{/* 2. 资源占用信息:CPU / 内存 / 网络 */}
<Card className="border border-slate-200 bg-white">
<Card.Header>
<SectionTitle title="资源占用信息" desc="CPU / 内存 / 网络实时占用" />
</Card.Header>
<Card.Content>
<div className="grid gap-4 md:grid-cols-3">
@@ -181,9 +269,7 @@ export default function HomePage() {
<span className="text-sm font-semibold text-slate-800">{cpu ? cpu.percent + '%' : '—'}</span>
</div>
<div className="mt-3"><Bar value={cpu?.percent || 0} /></div>
<p className="mt-2 text-xs text-slate-400">
负载: {cpu?.load_avg ? cpu.load_avg.join(' / ') : '—'}
</p>
<p className="mt-2 text-xs text-slate-400">负载: {cpu?.load_avg ? cpu.load_avg.join(' / ') : '—'}</p>
{cpu?.per_core?.length ? (
<div className="mt-3 grid grid-cols-4 gap-2">
{cpu.per_core.map((v, i) => (
@@ -204,35 +290,72 @@ export default function HomePage() {
<span className="text-sm font-semibold text-slate-800">{mem ? mem.percent + '%' : '—'}</span>
</div>
<div className="mt-3"><Bar value={mem?.percent || 0} /></div>
<p className="mt-2 text-xs text-slate-400">
本进程 {mem ? fmtBytes(mem.process_rss) : '—'} · 其他已用 {mem ? fmtBytes(Math.max(0, mem.used - mem.process_rss)) : '—'} · 空间 {mem ? fmtBytes(Math.max(0, mem.total - mem.used)) : '—'}
</p>
<p className="mt-2 text-xs text-slate-400">已用 {mem ? fmtBytes(mem.used) : '—'} · 可用 {mem ? fmtBytes(mem.available) : '—'}</p>
<p className="mt-1 text-xs text-slate-400">共 {mem ? fmtBytes(mem.total) : '—'}</p>
</div>
{/* 进程 */}
{/* 网络 */}
<div className="rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-700">运行进程</span>
<span className="text-xs text-slate-500">PID {proc?.pid ?? '—'}</span>
<span className="text-sm font-medium text-slate-700">网络流量</span>
{net?.interfaces?.length ? <span className="text-xs text-slate-400">{net.interfaces.length} 网卡</span> : null}
</div>
<dl className="mt-3 space-y-2 text-xs">
<div className="flex justify-between"><dt className="text-slate-400">RSS</dt><dd className="text-slate-700">{proc ? fmtBytes(proc.rss) : '—'}</dd></div>
<div className="flex justify-between"><dt className="text-slate-400">进程</dt><dd className="text-slate-700">{proc?.name || '—'}</dd></div>
<div className="flex justify-between"><dt className="text-slate-400">CPU</dt><dd className="text-slate-700">{proc?.cpu_percent != null ? proc.cpu_percent + '%' : '—'}</dd></div>
<div className="flex justify-between"><dt className="text-slate-400">内存占用</dt><dd className="text-slate-700">{proc?.memory_percent != null ? proc.memory_percent + '%' : '—'}</dd></div>
<div className="flex justify-between"><dt className="text-slate-400">启动时长</dt><dd className="text-slate-700">{sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}</dd></div>
</dl>
<div className="mt-3 space-y-2 text-xs">
<div className="flex justify-between"><dt className="text-slate-400">↓ 下行速率</dt><dd className="text-slate-700">{net ? fmtRate(net.recv_rate) : '—'}</dd></div>
<div className="flex justify-between"><dt className="text-slate-400">↑ 上行速率</dt><dd className="text-slate-700">{net ? fmtRate(net.sent_rate) : '—'}</dd></div>
<div className="flex justify-between"><dt className="text-slate-400">累计下行</dt><dd className="text-slate-700">{net ? fmtBytes(net.recv) : '—'}</dd></div>
<div className="flex justify-between"><dt className="text-slate-400">累计上行</dt><dd className="text-slate-700">{net ? fmtBytes(net.sent) : '—'}</dd></div>
</div>
</div>
</div>
{/* 本进程 */}
<div className="mt-4 rounded-xl border border-slate-200 p-4">
<div className="mb-2 text-sm font-medium text-slate-700">运行进程(bot)</div>
<dl className="grid grid-cols-2 gap-2 text-xs sm:grid-cols-3 lg:grid-cols-5">
<div className="flex flex-col"><dt className="text-slate-400">PID</dt><dd className="text-slate-700">{proc?.pid ?? '—'}</dd></div>
<div className="flex flex-col"><dt className="text-slate-400">进程名</dt><dd className="text-slate-700">{proc?.name || '—'}</dd></div>
<div className="flex flex-col"><dt className="text-slate-400">CPU</dt><dd className="text-slate-700">{proc?.cpu_percent != null ? proc.cpu_percent + '%' : '—'}</dd></div>
<div className="flex flex-col"><dt className="text-slate-400">内存占用</dt><dd className="text-slate-700">{proc?.memory_percent != null ? proc.memory_percent + '%' : '—'}</dd></div>
<div className="flex flex-col"><dt className="text-slate-400">RSS</dt><dd className="text-slate-700">{proc ? fmtBytes(proc.rss) : '—'}</dd></div>
</dl>
</div>
</Card.Content>
</Card>
{/* 3. BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数 */}
<Card className="border border-slate-200 bg-white">
<Card.Header>
<SectionTitle title="BOT 后端信息" desc="NoneBot 版本 / 驱动方式 / 装载插件数" />
</Card.Header>
<Card.Content>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">NoneBot 版本</div>
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.nonebot_version || '—'}</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">驱动方式</div>
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.driver}>{backend?.driver || '—'}</div>
<div className="text-xs text-slate-400">{backend?.driver_type || ''}</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">装载插件数</div>
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.plugin_count ?? '—'} 个</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">Python</div>
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.python}>{backend?.python || '—'}</div>
<div className="text-xs text-slate-400">bot 运行 {sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}</div>
</div>
</div>
</Card.Content>
</Card>
{/* 在线会话 */}
{/* 4. BOT 协议端信息:登录账号昵称 / 账号 / 适配器类型 */}
<Card className="border border-slate-200 bg-white">
<Card.Header>
<Card.Title>在线会话</Card.Title>
<p className="text-xs text-slate-400">当前已接入并完成登录的 QQ 账号</p>
<SectionTitle title="BOT 协议端信息" desc="当前登录的账户昵称 / 账号 / 适配器类型" />
</Card.Header>
<Card.Content>
{!data ? (
@@ -254,6 +377,7 @@ export default function HomePage() {
<div className="flex items-center gap-3">
{bot.msg_recv != null && <span className="text-xs text-slate-400">收 {bot.msg_recv}</span>}
{bot.msg_sent != null && <span className="text-xs text-slate-400">发 {bot.msg_sent}</span>}
{bot.online != null && <span className="text-xs text-slate-400">{bot.online ? '在线' : '离线'}</span>}
<span className="inline-flex items-center gap-1.5 text-xs">
<span className={'size-2 rounded-full ' + (bot.ws_connected ? 'bg-emerald-500' : 'bg-slate-300')} />
<span className={bot.ws_connected ? 'text-emerald-600' : 'text-slate-400'}>{bot.ws_connected ? '已连接' : '未连接'}</span>
+202
View File
@@ -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>
)
}
+64 -55
View File
@@ -4,12 +4,14 @@ import {
Button,
Card,
Checkbox,
Drawer,
Dropdown,
Input,
Label,
ListBox,
Modal,
Select,
Spinner,
Switch,
TextArea,
TextField,
toast,
@@ -52,11 +54,12 @@ interface ConfigField {
interface ConfigData { schema: { fields: ConfigField[] }; values: Record<string, any>; revision: number }
const CHAT_OPTIONS = [
{ value: 'all', label: '全部(群聊 + 私聊)' },
{ value: 'all', label: '全部' },
{ value: 'group', label: '仅群聊' },
{ value: 'private', label: '仅私聊' },
]
const selectCls = 'min-w-[150px] bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
const chatSelectCls = 'min-w-[96px] bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
function chatValue(chat: string[]): string {
const s = (chat || []).filter(c => c === 'group' || c === 'private')
@@ -82,7 +85,6 @@ export default function PluginsPage() {
const [groups, setGroups] = useState<GroupItem[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [expanded, setExpanded] = useState<string | null>(null)
const loadRef = useRef(false)
const [configPlugin, setConfigPlugin] = useState<CatalogPlugin | null>(null)
@@ -172,8 +174,9 @@ export default function PluginsPage() {
)
}
if (field.type === 'text' || field.type === 'json' || field.type === 'object') {
const isJson = field.type === 'json'
return (
<TextField key={field.key}><Label>{field.label}</Label><TextArea rows={field.type === 'json' ? 10 : 4} value={v ?? ''} onChange={e => setField(field.key, e.target.value)} /></TextField>
<TextField key={field.key}><Label>{field.label}</Label><TextArea rows={isJson ? 14 : 8} className={isJson ? 'min-h-[320px] font-mono text-xs' : 'min-h-[160px]'} value={v ?? ''} onChange={e => setField(field.key, e.target.value)} /></TextField>
)
}
const inputType = field.type === 'password' ? 'password' : (field.type === 'int' || field.type === 'float' ? 'number' : 'text')
@@ -210,7 +213,7 @@ export default function PluginsPage() {
<h2 className="text-xl font-semibold text-slate-800">插件管理</h2>
<p className="text-sm text-slate-400">在所有插件之上统一控制:全局开关、群聊/私聊使用、分群控制、插件配置</p>
</div>
<Button size="sm" isDisabled={loading} onPress={() => { setExpanded(null); load() }}>{loading ? '加载中…' : '刷新'}</Button>
<Button size="sm" isDisabled={loading} onPress={() => load()}>{loading ? '加载中…' : '刷新'}</Button>
</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>}
@@ -235,71 +238,77 @@ export default function PluginsPage() {
</div>
<div className="flex flex-wrap items-center gap-4">
<Checkbox isSelected={on} onChange={(v) => toggleGlobal(p, v)}>
<Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>全局启用</Checkbox.Content>
</Checkbox>
{p.has_config && <Button size="sm" onPress={() => openConfig(p)}>配置</Button>}
{p.has_web && p.web_path && <Link to={p.web_path}><Button size="sm">打开 Web 页面</Button></Link>}
<Select selectedKey={chatValue(p.chat)} onSelectionChange={(k) => changeChat(p, String(k))} aria-label="聊天类型">
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
<Select.Trigger className={chatSelectCls}><Select.Value /><Select.Indicator /></Select.Trigger>
<Select.Popover><ListBox>
{CHAT_OPTIONS.map(o => <ListBox.Item key={o.value} id={o.value}>{o.label}</ListBox.Item>)}
</ListBox></Select.Popover>
</Select>
<Dropdown>
<Dropdown.Trigger className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-800">
分群控制
</Dropdown.Trigger>
<Dropdown.Popover className="p-2" placement="bottom end">
<div className="w-64 max-h-72 overflow-y-auto space-y-1">
<div className="px-1 py-1 text-xs font-medium text-slate-500">分群控制(覆盖全局)</div>
{!groups.length && <div className="px-1 py-2 text-xs text-slate-400">未获取到群列表</div>}
{groups.map(g => {
const eff = effectiveGroupEnabled(p, g.group_id)
const hasOv = !!p.groups[String(g.group_id)]
return (
<div key={g.group_id} className="flex items-center justify-between gap-2 rounded-lg px-1.5 py-1 hover:bg-slate-50">
<Switch size="sm" isSelected={eff} onChange={(v) => toggleGroup(p, g.group_id, v)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control><span className="text-xs text-slate-700">{g.group_name || g.group_id}</span></Switch.Content>
</Switch>
{hasOv && <Button size="sm" variant="tertiary" onPress={() => resetGroup(p, g.group_id)}>重置</Button>}
</div>
)
})}
</div>
</Dropdown.Popover>
</Dropdown>
{p.has_config && <Button size="sm" onPress={() => openConfig(p)}>配置</Button>}
{p.has_web && p.web_path && <Link to={p.web_path}><Button size="sm">打开 Web 页面</Button></Link>}
<Button size="sm" variant="tertiary" onPress={() => setExpanded(expanded === p.id ? null : p.id)}>分群设置</Button>
<Switch isSelected={on} onChange={(v) => toggleGlobal(p, v)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>全局启用</Switch.Content>
</Switch>
</div>
</div>
{expanded === p.id && (
<div className="mt-4 rounded-xl border border-slate-100 bg-slate-50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-slate-500">分群控制(覆盖全局)</span>
{!groups.length && <span className="text-xs text-slate-400">未获取到群列表</span>}
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{groups.map(g => {
const eff = effectiveGroupEnabled(p, g.group_id)
const hasOv = !!p.groups[String(g.group_id)]
return (
<div key={g.group_id} className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2">
<Checkbox isSelected={eff} onChange={(v) => toggleGroup(p, g.group_id, v)}>
<Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control><span className="text-xs">{g.group_name || g.group_id}</span></Checkbox.Content>
</Checkbox>
{hasOv && <Button size="sm" variant="tertiary" onPress={() => resetGroup(p, g.group_id)}>重置</Button>}
</div>
)
})}
</div>
</div>
)}
</Card.Content>
</Card>
)
})}
</div>
<Modal isOpen={configState.isOpen} onOpenChange={configState.setOpen}>
<Modal.Backdrop><Modal.Container><Modal.Dialog>
<Modal.Header><Modal.Heading>{configPlugin?.name || ''} · 配置</Modal.Heading></Modal.Header>
<Modal.Body>
{configLoading ? (
<div className="flex justify-center py-10"><Spinner /></div>
) : configData ? (
<div className="space-y-4">
{configData.schema.fields.map(field => renderField(field))}
</div>
) : (
<p className="text-sm text-slate-400">该插件未提供可编辑配置</p>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onPress={configState.close}>取消</Button>
<Button isDisabled={configSaving || !configData} onPress={saveConfig}>{configSaving ? '保存中…' : '保存并热刷新'}</Button>
</Modal.Footer>
</Modal.Dialog></Modal.Container></Modal.Backdrop>
</Modal>
<Drawer state={configState}>
<Drawer.Backdrop>
<Drawer.Content placement="right">
<Drawer.Dialog className="w-[440px] max-w-[88vw]">
<Drawer.Header>
<Drawer.Heading>{configPlugin?.name || ''} · 配置</Drawer.Heading>
<Drawer.CloseTrigger />
</Drawer.Header>
<Drawer.Body>
{configLoading ? (
<div className="flex justify-center py-10"><Spinner /></div>
) : configData ? (
<div className="space-y-4">
{configData.schema.fields.map(field => renderField(field))}
</div>
) : (
<p className="text-sm text-slate-400">该插件未提供可编辑配置</p>
)}
</Drawer.Body>
<Drawer.Footer>
<Button variant="tertiary" onPress={configState.close}>取消</Button>
<Button isDisabled={configSaving || !configData} onPress={saveConfig}>{configSaving ? '保存中…' : '保存并热刷新'}</Button>
</Drawer.Footer>
</Drawer.Dialog>
</Drawer.Content>
</Drawer.Backdrop>
</Drawer>
</div>
)
}
+227 -39
View File
@@ -1,6 +1,10 @@
import { useEffect, useState } from 'react'
import { Button, Card, Form, Input, Label, TextField, toast } from '@heroui/react'
import { hubSettings, hubChangePassword } from '../../api/client'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Button, Card, Form, Input, Label, ListBox, Modal, Select, Spinner, Switch, TextField, toast, useOverlayState,
} from '@heroui/react'
import {
hubSettings, hubChangePassword, hubBotAction, hubBotConfig, hubBotConfigSave, hubDashboardStream,
} from '../../api/client'
interface SettingsData {
ok: boolean
@@ -8,6 +12,21 @@ interface SettingsData {
plugins: { id: string; name: string; icon: string }[]
version: string
}
interface BotConfigData {
ok: boolean
superusers: string[]
nickname: string[]
debug: boolean
log_level: string
log_level_options: string[]
source: string
notice?: string
applied?: string[]
restart_required?: boolean
}
const LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
const selectCls = 'min-w-[150px] bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
export default function SettingsPage() {
const [data, setData] = useState<SettingsData | null>(null)
@@ -16,11 +35,47 @@ export default function SettingsPage() {
const [newPassword, setNewPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [busy, setBusy] = useState(false)
const [botBusy, setBotBusy] = useState(false)
const [pendingAction, setPendingAction] = useState<'stop' | 'restart' | null>(null)
const [restarting, setRestarting] = useState<null | 'stop' | 'restart'>(null)
const botState = useOverlayState()
const pollRef = useRef(0)
const restartDisposeRef = useRef<(() => void) | null>(null)
useEffect(() => {
hubSettings()
.then(setData)
.catch((e: any) => setError(e.message || '加载失败'))
const [botCfg, setBotCfg] = useState<BotConfigData | null>(null)
const [cfgSuperusers, setCfgSuperusers] = useState('')
const [cfgNickname, setCfgNickname] = useState('')
const [cfgDebug, setCfgDebug] = useState(false)
const [cfgLogLevel, setCfgLogLevel] = useState('INFO')
const [cfgBusy, setCfgBusy] = useState(false)
const [cfgLoaded, setCfgLoaded] = useState(false)
const load = useCallback(async () => {
try {
const s = await hubSettings()
setData(s)
setError('')
} catch (e: any) {
setError(e.message || '加载失败')
}
try {
const c = await hubBotConfig()
setBotCfg(c)
setCfgSuperusers((c.superusers || []).join(', '))
setCfgNickname((c.nickname || []).join(', '))
setCfgDebug(!!c.debug)
setCfgLogLevel(c.log_level || 'INFO')
setCfgLoaded(true)
} catch (e: any) {
setCfgLoaded(false)
console.warn('读取 bot 配置失败', e)
}
}, [])
useEffect(() => { load() }, [load])
useEffect(() => () => {
if (restartDisposeRef.current) restartDisposeRef.current()
}, [])
const changePassword = async () => {
@@ -37,53 +92,154 @@ export default function SettingsPage() {
} finally { setBusy(false) }
}
const saveBotConfig = async () => {
setCfgBusy(true)
try {
const values = {
superusers: (cfgSuperusers || '').split(/[,\s]+/).map(s => s.trim()).filter(Boolean),
nickname: (cfgNickname || '').split(/[,\s]+/).map(s => s.trim()).filter(Boolean),
debug: cfgDebug,
log_level: cfgLogLevel,
}
const r = await hubBotConfigSave(values)
setBotCfg(r)
setCfgSuperusers((r.superusers || []).join(', '))
setCfgNickname((r.nickname || []).join(', '))
setCfgDebug(!!r.debug)
setCfgLogLevel(r.log_level || 'INFO')
toast.success('BOT 配置已保存')
if (r.notice) toast.info(r.notice)
} catch (e: any) {
toast.danger(e.message || '保存失败')
} finally { setCfgBusy(false) }
}
const askBotAction = (action: 'stop' | 'restart') => {
setPendingAction(action)
botState.open()
}
const pollForReconnect = async () => {
const id = ++pollRef.current
// 先等旧进程完全退出(旧的 dashboard SSE 可能仍被旧进程服务,提前收到数据会误判就绪)
await new Promise(res => setTimeout(res, 4000))
if (pollRef.current !== id) return
if (restartDisposeRef.current) restartDisposeRef.current()
// 以 dashboard SSE 真正收到数据作为「后端已就绪」信号,避免仅 HTTP 起来但插件未挂接就放行
const dispose = hubDashboardStream({
onData: (d) => {
if (pollRef.current !== id) return
if (d && d.ok !== false) {
restartDisposeRef.current = null
dispose()
setRestarting(null)
toast.success('bot 已重启并回连')
load()
}
},
})
restartDisposeRef.current = dispose
window.setTimeout(() => {
if (pollRef.current === id) {
restartDisposeRef.current = null
dispose()
setRestarting(null)
toast.danger('等待回连超时,请检查 bot 是否已启动')
}
}, 60000)
}
const confirmBotAction = async () => {
if (!pendingAction) return
const action = pendingAction
setBotBusy(true)
try {
const r = await hubBotAction(action)
toast.success((r as any)?.msg || `已请求${action === 'stop' ? '停止' : '重启'} bot`)
botState.close()
setPendingAction(null)
setRestarting(action)
if (action === 'restart') pollForReconnect()
} catch (e: any) {
toast.danger(e.message || '操作失败')
} finally { setBotBusy(false) }
}
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-800">设置</h2>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="border border-slate-200 bg-white">
<Card.Header><Card.Title>账号信息</Card.Title></Card.Header>
<Card.Content>
{error ? (
<p className="text-red-500 text-sm">{error}</p>
) : !data ? (
<p className="text-slate-400 text-sm">加载中…</p>
) : (
<div className="space-y-3 text-sm">
<div className="flex justify-between border-b border-slate-100 pb-2">
<span className="text-slate-500">用户名</span>
<span className="font-medium text-slate-800">{data.user.username}</span>
</div>
<div className="flex justify-between border-b border-slate-100 pb-2">
<span className="text-slate-500">用户 ID</span>
<span className="font-medium text-slate-800">{data.user.id}</span>
</div>
<div className="flex justify-between border-b border-slate-100 pb-2">
<span className="text-slate-500">创建时间</span>
<span className="text-slate-800">{data.user.created_at || '—'}</span>
</div>
<div className="flex justify-between border-b border-slate-100 pb-2">
<span className="text-slate-500">已接入插件</span>
<span className="text-slate-800">{data.plugins.length} 个</span>
</div>
</div>
)}
</Card.Content>
</Card>
<Card className="border border-slate-200 bg-white">
<Card className="border border-slate-200 bg-white lg:col-span-2">
<Card.Header><Card.Title>修改密码</Card.Title></Card.Header>
<Card.Content>
<Form className="space-y-4">
<TextField><Label>原密码</Label><Input type="password" value={oldPassword} onChange={e => setOldPassword(e.target.value)} /></TextField>
<TextField><Label>新密码</Label><Input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} placeholder="至少 6 位" /></TextField>
<TextField><Label>确认新密码</Label><Input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} /></TextField>
<Button isDisabled={busy} onPress={changePassword}>{busy ? '修改中…' : '保存修改'}</Button>
<Button isDisabled={busy || !!restarting} onPress={changePassword}>{busy ? '修改中…' : '保存修改'}</Button>
</Form>
<p className="text-xs text-slate-400 mt-3">修改后旧登录态失效,当前会话保持在线。</p>
</Card.Content>
</Card>
{/* BOT 全局配置 */}
<Card className="border border-slate-200 bg-white lg:col-span-2">
<Card.Header><Card.Title>BOT 配置</Card.Title></Card.Header>
<Card.Content>
<p className="text-xs text-slate-400 mb-4">
SUPERUSERS / NICKNAME 支持逗号或空格分隔;保存后会写入 .env 并尽量热应用。
部分插件在启动时缓存了这些值,完全生效建议保存后重启 bot。
</p>
<div className="grid gap-4 md:grid-cols-2">
<TextField>
<Label>SUPERUSERS</Label>
<Input value={cfgSuperusers} onChange={e => setCfgSuperusers(e.target.value)} placeholder="如: 123456789, 987654321" />
<span className="text-xs text-slate-400">超级用户 QQ 号(逗号/空格分隔)</span>
</TextField>
<TextField>
<Label>NICKNAME</Label>
<Input value={cfgNickname} onChange={e => setCfgNickname(e.target.value)} placeholder="如: 爱丽丝" />
<span className="text-xs text-slate-400">机器人昵称(逗号/空格分隔)</span>
</TextField>
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<div>
<div className="text-sm font-medium text-slate-700">DEBUG</div>
<div className="text-xs text-slate-400">开启调试模式</div>
</div>
<Switch isSelected={cfgDebug} onChange={(v) => setCfgDebug(v)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>{cfgDebug ? '开' : '关'}</Switch.Content>
</Switch>
</div>
<div>
<Label>LOG_LEVEL</Label>
<Select selectedKey={cfgLogLevel} onSelectionChange={(k) => setCfgLogLevel(String(k))}>
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
<Select.Popover><ListBox>
{(botCfg?.log_level_options || LOG_LEVELS).map(o => <ListBox.Item key={o} id={o}>{o}</ListBox.Item>)}
</ListBox></Select.Popover>
</Select>
<span className="text-xs text-slate-400">日志等级(INFO 为默认)</span>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<Button isDisabled={cfgBusy || !cfgLoaded || !!restarting} onPress={saveBotConfig}>{cfgBusy ? '保存中…' : '保存 BOT 配置'}</Button>
{!cfgLoaded && <span className="text-xs text-slate-400">配置未加载</span>}
{botCfg?.source === 'env' && <span className="text-xs text-slate-400">当前来源:.env</span>}
</div>
</Card.Content>
</Card>
<Card className="border border-slate-200 bg-white lg:col-span-2">
<Card.Header><Card.Title>进程控制</Card.Title></Card.Header>
<Card.Content className="space-y-3">
<p className="text-sm text-slate-500">停止 / 重启当前 bot 进程。停止会断开 Web 与 QQ 连接;重启会先拉起新进程再退出旧进程,日志继续写入 _bot_run.log。重启过程中界面会禁用操作并等待回连。</p>
<div className="flex flex-wrap items-center gap-3">
<Button variant="tertiary" className="text-red-500" isDisabled={botBusy || !!restarting} onPress={() => askBotAction('stop')}>停止 bot</Button>
<Button isDisabled={botBusy || !!restarting} onPress={() => askBotAction('restart')}>重启 bot</Button>
</div>
</Card.Content>
</Card>
<Card className="border border-slate-200 bg-white lg:col-span-2">
<Card.Header><Card.Title>关于</Card.Title></Card.Header>
<Card.Content>
@@ -91,6 +247,38 @@ export default function SettingsPage() {
</Card.Content>
</Card>
</div>
<Modal isOpen={botState.isOpen} onOpenChange={botState.setOpen}>
<Modal.Backdrop><Modal.Container><Modal.Dialog>
<Modal.Header><Modal.Heading>{pendingAction === 'stop' ? '停止 bot' : '重启 bot'}</Modal.Heading></Modal.Header>
<Modal.Body><p className="text-sm text-slate-600">
{pendingAction === 'stop' ? '确定停止当前 bot 进程吗?Web 管理台与 QQ 连接将断开。' : '确定重启 bot 进程吗?将先拉起新进程再退出旧进程。'}
</p></Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onPress={botState.close}>取消</Button>
<Button isDisabled={botBusy} onPress={confirmBotAction}>{botBusy ? '执行中…' : '确定'}</Button>
</Modal.Footer>
</Modal.Dialog></Modal.Container></Modal.Backdrop>
</Modal>
{restarting === 'stop' && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 p-4">
<Card className="border border-slate-200 bg-white"><Card.Content className="space-y-3 text-center">
<div className="text-sm font-medium text-slate-800">正在停止 bot…</div>
<p className="text-xs text-slate-400">Web 与 QQ 连接已断开,请在服务器重新启动 bot。</p>
<Button size="sm" variant="tertiary" onPress={() => setRestarting(null)}>知道了</Button>
</Card.Content></Card>
</div>
)}
{restarting === 'restart' && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 p-4">
<Card className="border border-slate-200 bg-white"><Card.Content className="space-y-3 text-center">
<div className="text-sm font-medium text-slate-800">正在重启 bot,等待回连…</div>
<p className="text-xs text-slate-400">期间已禁用所有操作,恢复后自动继续。</p>
<div className="flex justify-center"><Spinner /></div>
</Card.Content></Card>
</div>
)}
</div>
)
}