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
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-54 -44.52999973297119 216.1999969482422 143.78999710083008" data-asc="0.9052734375" width="216.1999969482422" height="143.78999710083008"><defs><filter id="toxic-glow-filter" x="-54" y="-44.52999973297119" width="216.1999969482422" height="143.78999710083008" filterUnits="userSpaceOnUse">
<feDropShadow dx="0" dy="0" stdDeviation="6" flood-color="#acdba4" flood-opacity="0.9"/>
<feDropShadow dx="0" dy="0" stdDeviation="18" flood-color="#a294b8" flood-opacity="0.6"/>
</filter></defs><g fill="#0f0f0f" stroke="#686a68" stroke-width="1.5" filter="url(#toxic-glow-filter)"><g transform="translate(0, 0)"><path d="M0 45.26L0 9.47L4.74 9.47L4.74 24.17L23.34 24.17L23.34 9.47L28.08 9.47L28.08 45.26L23.34 45.26L23.34 28.39L4.74 28.39L4.74 45.26L0 45.26ZM36.06 45.26L36.06 9.47L61.94 9.47L61.94 13.70L40.80 13.70L40.80 24.66L60.60 24.66L60.60 28.86L40.80 28.86L40.80 41.04L62.77 41.04L62.77 45.26L36.06 45.26ZM65.67 45.26L79.52 26.61L67.31 9.47L72.95 9.47L79.44 18.65Q81.47 21.51 82.32 23.05Q83.52 21.09 85.16 18.97L92.36 9.47L97.51 9.47L84.94 26.34L98.49 45.26L92.63 45.26L83.62 32.50Q82.86 31.40 82.06 30.10Q80.86 32.06 80.35 32.79L71.36 45.26L65.67 45.26ZM103.47 45.26L103.47 9.47L108.20 9.47L108.20 45.26L103.47 45.26Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/hub/favicon.svg" />
<title>HeXi · 统一管理台</title>
</head>
<body>
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-54 -44.52999973297119 216.1999969482422 143.78999710083008" data-asc="0.9052734375" width="216.1999969482422" height="143.78999710083008"><defs><filter id="toxic-glow-filter" x="-54" y="-44.52999973297119" width="216.1999969482422" height="143.78999710083008" filterUnits="userSpaceOnUse">
<feDropShadow dx="0" dy="0" stdDeviation="6" flood-color="#acdba4" flood-opacity="0.9"/>
<feDropShadow dx="0" dy="0" stdDeviation="18" flood-color="#a294b8" flood-opacity="0.6"/>
</filter></defs><g fill="#0f0f0f" stroke="#686a68" stroke-width="1.5" filter="url(#toxic-glow-filter)"><g transform="translate(0, 0)"><path d="M0 45.26L0 9.47L4.74 9.47L4.74 24.17L23.34 24.17L23.34 9.47L28.08 9.47L28.08 45.26L23.34 45.26L23.34 28.39L4.74 28.39L4.74 45.26L0 45.26ZM36.06 45.26L36.06 9.47L61.94 9.47L61.94 13.70L40.80 13.70L40.80 24.66L60.60 24.66L60.60 28.86L40.80 28.86L40.80 41.04L62.77 41.04L62.77 45.26L36.06 45.26ZM65.67 45.26L79.52 26.61L67.31 9.47L72.95 9.47L79.44 18.65Q81.47 21.51 82.32 23.05Q83.52 21.09 85.16 18.97L92.36 9.47L97.51 9.47L84.94 26.34L98.49 45.26L92.63 45.26L83.62 32.50Q82.86 31.40 82.06 30.10Q80.86 32.06 80.35 32.79L71.36 45.26L65.67 45.26ZM103.47 45.26L103.47 9.47L108.20 9.47L108.20 45.26L103.47 45.26Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+108
View File
@@ -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}`),
+12 -9
View File
@@ -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>
+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>
)
}
+2
View File
@@ -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 />} />
))}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/layout/applayout.tsx","./src/pages/home/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/layout/applayout.tsx","./src/pages/home/index.tsx","./src/pages/logs/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx"],"version":"5.9.3"}