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:
@@ -58,6 +58,114 @@ export const hubPluginSaveConfig = async (id: string, values: Record<string, any
|
||||
export const hubSettings = async () => await request('/hub/api/settings')
|
||||
export const hubChangePassword = async (old_password: string, new_password: string) =>
|
||||
await request('/hub/api/settings/password', { method: 'POST', body: { old_password, new_password } })
|
||||
export const hubLogs = async (limit = 500) => await request('/hub/api/logs?limit=' + limit)
|
||||
export const hubBotAction = async (action: 'stop' | 'restart') =>
|
||||
await request('/hub/api/system/bot', { method: 'POST', body: { action } })
|
||||
export const hubBotConfig = async () => await request('/hub/api/system/bot_config')
|
||||
export const hubBotConfigSave = async (values: Record<string, any>) =>
|
||||
await request('/hub/api/system/bot_config', { method: 'POST', body: values })
|
||||
|
||||
export function hubLogStream(opts: {
|
||||
since: number
|
||||
onLine: (line: string) => void
|
||||
onStatus: (s: 'open' | 'reconnecting' | 'closed') => void
|
||||
}): () => void {
|
||||
const controller = new AbortController()
|
||||
let stopped = false
|
||||
let since = opts.since
|
||||
|
||||
const read = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
const res = await fetch('/hub/api/logs/stream?since=' + since, {
|
||||
headers: { Authorization: 'Bearer ' + getToken() },
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status)
|
||||
opts.onStatus('open')
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (!stopped) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
let i
|
||||
while ((i = buf.indexOf('\n\n')) !== -1) {
|
||||
const block = buf.slice(0, i)
|
||||
buf = buf.slice(i + 2)
|
||||
for (const ln of block.split('\n')) {
|
||||
if (ln.startsWith('data: ')) {
|
||||
try {
|
||||
const obj = JSON.parse(ln.slice(6))
|
||||
if (obj && obj.line !== undefined) {
|
||||
opts.onLine(obj.line)
|
||||
if (typeof obj.offset === 'number') since = obj.offset
|
||||
}
|
||||
} catch { /* 忽略坏帧 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!stopped) opts.onStatus('reconnecting')
|
||||
} catch (e: any) {
|
||||
if (stopped || (e && e.name === 'AbortError')) return
|
||||
opts.onStatus('reconnecting')
|
||||
}
|
||||
if (!stopped) await new Promise(res => setTimeout(res, 1500))
|
||||
}
|
||||
}
|
||||
|
||||
read()
|
||||
return () => { stopped = true; controller.abort() }
|
||||
}
|
||||
|
||||
export function hubDashboardStream(opts: {
|
||||
onData: (data: any) => void
|
||||
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
|
||||
}): () => void {
|
||||
const controller = new AbortController()
|
||||
let stopped = false
|
||||
|
||||
const read = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
const res = await fetch('/hub/api/dashboard/stream', {
|
||||
headers: { Authorization: 'Bearer ' + getToken() },
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status)
|
||||
opts.onStatus?.('open')
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (!stopped) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
let i
|
||||
while ((i = buf.indexOf('\n\n')) !== -1) {
|
||||
const block = buf.slice(0, i)
|
||||
buf = buf.slice(i + 2)
|
||||
for (const ln of block.split('\n')) {
|
||||
if (ln.startsWith('data: ')) {
|
||||
try { opts.onData(JSON.parse(ln.slice(6))) } catch { /* 忽略坏帧 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!stopped) opts.onStatus?.('reconnecting')
|
||||
} catch (e: any) {
|
||||
if (stopped || (e && e.name === 'AbortError')) return
|
||||
opts.onStatus?.('reconnecting')
|
||||
}
|
||||
if (!stopped) await new Promise(res => setTimeout(res, 2000))
|
||||
}
|
||||
}
|
||||
|
||||
read()
|
||||
return () => { stopped = true; controller.abort() }
|
||||
}
|
||||
|
||||
export const api = (plugin: string) => ({
|
||||
get: (path: string) => request(`/api/${plugin}/${path}`),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Avatar, Button, Separator } from '@heroui/react'
|
||||
import { House, Rocket, Gear } from '@gravity-ui/icons'
|
||||
import { House, Rocket, Gear, FileText } from '@gravity-ui/icons'
|
||||
import { useAuth } from '../auth/auth'
|
||||
|
||||
interface NavItem { id: string; label: string; description: string; icon: any }
|
||||
@@ -13,17 +13,20 @@ export function AppLayout() {
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'home', label: '首页', description: '主机与服务状态', icon: House },
|
||||
{ id: 'plugins', label: '插件管理', description: '插件总览与分群控制', icon: Rocket },
|
||||
{ id: 'logs', label: '查看日志', description: 'bot 运行日志实时查看', icon: FileText },
|
||||
{ id: 'settings', label: '设置', description: '账号与系统设置', icon: Gear },
|
||||
]
|
||||
const isHome = location.pathname === '/hub'
|
||||
const isSettings = location.pathname.startsWith('/hub/settings')
|
||||
const isPluginList = location.pathname.startsWith('/hub/plugins')
|
||||
const isPlugins = isPluginList || (!isHome && !isSettings && location.pathname.startsWith('/hub/'))
|
||||
const path = location.pathname.replace(/\/+$/, '') || '/'
|
||||
const isHome = path === '/hub'
|
||||
const isSettings = path.startsWith('/hub/settings')
|
||||
const isLogs = path.startsWith('/hub/logs')
|
||||
const isPluginList = path.startsWith('/hub/plugins')
|
||||
const isPlugins = isPluginList || (!isHome && !isSettings && !isLogs && path.startsWith('/hub/'))
|
||||
const isPluginSub = isPlugins && !isPluginList
|
||||
const activeId = isPlugins ? 'plugins' : isSettings ? 'settings' : 'home'
|
||||
const activeId = isPlugins ? 'plugins' : isLogs ? 'logs' : isSettings ? 'settings' : 'home'
|
||||
const pageMeta = navItems.find(it => it.id === activeId) || navItems[0]
|
||||
|
||||
if (!token) return null
|
||||
if (!token) return <Navigate to="/hub/login" replace />
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-900">
|
||||
@@ -108,7 +111,7 @@ export function AppLayout() {
|
||||
<Avatar size="sm"><Avatar.Fallback>{(username || 'U').charAt(0).toUpperCase()}</Avatar.Fallback></Avatar>
|
||||
<span className="text-sm text-slate-600">{username || '未登录'}</span>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Button size="sm" variant="tertiary" onPress={logout}>退出</Button>
|
||||
<Button size="sm" variant="tertiary" onPress={() => { logout(); navigate('/hub/login') }}>退出</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto p-6"><Outlet /></main>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { pluginModules } from './plugins'
|
||||
import HomePage from './pages/home'
|
||||
import PluginsPage from './pages/plugins'
|
||||
import SettingsPage from './pages/settings'
|
||||
import LogsPage from './pages/logs'
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
@@ -21,6 +22,7 @@ export function AppRoutes() {
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="plugins" element={<PluginsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
{Object.entries(pluginModules).map(([id, Comp]) => (
|
||||
<Route key={id} path={`${id}/*`} element={<Comp />} />
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user