feat(web): 顶栏账号菜单接入 bot 启停 / 插件页分区 / 首页协议端配对信息

- 重启/关闭 bot 从设置页提到顶栏账号下拉(破坏性操作弹确认框),等待回连逻辑抽成
  lib/botAction.ts 供两处共用:先等旧进程让出端口,再以 dashboard SSE 真收到一帧为准,
  只看 HTTP 200 会在"端口起来但插件未挂接"时误判就绪
- 插件管理页按 带 Web 页面 / 带配置项 / 都没有 分区,卡片加类型与 Web/配置 徽章
- 首页「BOT 协议端信息」分两半:左半登录账号,右半 NoneBot 接入信息
  (IP / 运行端口 / WS 路径 / 访问令牌,均可一键复制,另给整条反向 WS 地址),
  监听地址非 0.0.0.0 时提示仅本机可连;「BOT 后端信息」移到自己一行
- dashboard.py 新增 endpoint 段:读 driver.config 的 host/port、驱动实际注册的
  反向 WS 路由(自动跳过根路径与尾斜杠别名)、适配器令牌与本机网卡 IP

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-22 14:23:52 +08:00
co-authored by Claude Code
parent 814c1994b6
commit 4b5e8b8b2e
7 changed files with 546 additions and 194 deletions
+95 -7
View File
@@ -1,11 +1,17 @@
import { useLayoutEffect, useEffect, useRef, useState, Suspense } from 'react'
import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Avatar, Button, Separator, Spinner } from '@heroui/react'
import { House, Rocket, Gear, FileText, Bars } from '@gravity-ui/icons'
import {
Avatar, Button, Dropdown, Modal, Spinner, toast,
} from '@heroui/react'
import {
ArrowRightFromSquare, Bars, ChevronDown, FileText, Gear, House, Power, Rocket,
ArrowRotateLeft,
} from '@gravity-ui/icons'
import { useAuth } from '../auth/auth'
import { hubPlugins } from '../api/client'
import { hubBotAction, hubPlugins } from '../api/client'
import { ErrorBoundary } from '../components/ErrorBoundary'
import { useConnection } from '../connection'
import { BOT_ACTION_TEXT, watchBotReconnect } from '../lib/botAction'
interface NavItem { id: string; label: string; description: string; icon: any }
@@ -198,10 +204,10 @@ export function AppLayout() {
{status}
{lastUpdate && <span className="hidden text-[10px] opacity-70 sm:inline">{new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false })}</span>}
</span>
<Avatar size="sm"><Avatar.Fallback>{(username || 'U').charAt(0).toUpperCase()}</Avatar.Fallback></Avatar>
<span className="hidden text-sm text-slate-600 sm:inline">{username || '未登录'}</span>
<Separator orientation="vertical" className="hidden h-5 sm:block" />
<Button size="sm" variant="tertiary" onPress={() => { logout(); navigate('/hub/login') }}>退出</Button>
<AccountMenu
username={username}
onLogout={() => { logout(); navigate('/hub/login') }}
/>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
@@ -218,3 +224,85 @@ export function AppLayout() {
</div>
)
}
/** 顶栏账号菜单:头像 + 用户名点开下拉,重启/关闭 bot 与退出登录都收进去。
*
* 重启/关闭都是不可逆的破坏性操作(关掉之后得去服务器手动拉起),
* 所以先弹确认框再发请求;重启还会复用 `watchBotReconnect` 等回连。
*/
function AccountMenu({ username, onLogout }: { username: string; onLogout: () => void }) {
const [pending, setPending] = useState<'stop' | 'restart' | null>(null)
const [busy, setBusy] = useState(false)
const reconnectRef = useRef<(() => void) | null>(null)
// 布局卸载(登出/跳转)时别把等待回连的 SSE 与定时器留在外面
useEffect(() => () => { reconnectRef.current?.() }, [])
const confirm = async () => {
const action = pending
if (!action) return
setBusy(true)
try {
await hubBotAction(action)
setPending(null)
if (action === 'stop') {
toast.warning('已请求关闭 bot;需要时在服务器上重新启动')
return
}
toast.success('已请求重启 bot,等待回连…')
reconnectRef.current?.()
reconnectRef.current = watchBotReconnect({
onReady: () => { reconnectRef.current = null; toast.success('bot 已重启并回连') },
onTimeout: () => { reconnectRef.current = null; toast.danger('等待回连超时,请检查 bot 是否已启动') },
})
} catch (e: any) {
toast.danger(e.message || '操作失败')
} finally { setBusy(false) }
}
return (
<>
<Dropdown>
<Dropdown.Trigger className="flex cursor-pointer items-center gap-2 rounded-lg px-1.5 py-1 transition-colors hover:bg-slate-100">
<Avatar size="sm"><Avatar.Fallback>{(username || 'U').charAt(0).toUpperCase()}</Avatar.Fallback></Avatar>
<span className="hidden max-w-24 truncate text-sm text-slate-600 sm:inline">{username || '未登录'}</span>
<ChevronDown className="size-4 shrink-0 text-slate-400" />
</Dropdown.Trigger>
<Dropdown.Popover>
<Dropdown.Menu
onAction={(key) => {
if (key === 'logout') onLogout()
else setPending(key === 'stop' ? 'stop' : 'restart')
}}
>
<Dropdown.Item id="restart">
<span className="flex items-center gap-2"><ArrowRotateLeft className="size-4" />重启 bot</span>
</Dropdown.Item>
<Dropdown.Item id="stop">
<span className="flex items-center gap-2"><Power className="size-4" />关闭 bot</span>
</Dropdown.Item>
<Dropdown.Item id="logout">
<span className="flex items-center gap-2"><ArrowRightFromSquare className="size-4" />退出登录</span>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
<Modal isOpen={pending !== null} onOpenChange={(open) => { if (!open) setPending(null) }}>
<Modal.Backdrop><Modal.Container><Modal.Dialog>
<Modal.Header><Modal.Heading>{pending ? BOT_ACTION_TEXT[pending].title : ''}</Modal.Heading></Modal.Header>
<Modal.Body>
<p className="text-sm text-slate-600">{pending ? BOT_ACTION_TEXT[pending].confirm : ''}</p>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onPress={() => setPending(null)}>取消</Button>
<Button isDisabled={busy} className={pending === 'stop' ? 'text-red-500' : ''} onPress={confirm}>
{busy ? '执行中…' : '确定'}
</Button>
</Modal.Footer>
</Modal.Dialog></Modal.Container></Modal.Backdrop>
</Modal>
</>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { hubDashboardStream } from '../api/client'
/** 请求停止/重启 bot 后,等它回连的那段等待逻辑(设置页与顶栏账号菜单共用)。
*
* 重启分两步回来:旧进程先退出(让出端口),新进程才起来,所以先干等一会儿
* 再开始探活;探活以 dashboard SSE **真正收到一帧**为准 —— 只看 HTTP 200 会在
* 「端口起来了但插件还没挂接」时就放行,紧接着的请求全 404。
*
* 返回 dispose:重复触发或组件卸载时调用,内部会撤掉 SSE 与超时定时器。
*/
export function watchBotReconnect(opts: {
onReady: () => void
onTimeout: () => void
/** 等旧进程退出的时间 */
startupDelayMs?: number
/** 探活总超时 */
timeoutMs?: number
}): () => void {
const { onReady, onTimeout, startupDelayMs = 4000, timeoutMs = 60000 } = opts
let stopped = false
let timer: number | null = null
let disposeStream: (() => void) | null = null
const finish = () => {
stopped = true
if (timer != null) { window.clearTimeout(timer); timer = null }
if (disposeStream) { disposeStream(); disposeStream = null }
}
timer = window.setTimeout(() => {
if (stopped) return
disposeStream = hubDashboardStream({
onData: (d) => {
if (stopped) return
if (d && d.ok !== false) { finish(); onReady() }
},
})
timer = window.setTimeout(() => {
if (stopped) return
finish()
onTimeout()
}, timeoutMs)
}, startupDelayMs)
return finish
}
/** 菜单/按钮上共用的文案,避免两处写法漂移 */
export const BOT_ACTION_TEXT = {
restart: { title: '重启 bot', confirm: '确定重启 bot 进程吗?将先拉起新进程再退出旧进程,期间 Web 与 QQ 连接会短暂断开。' },
stop: { title: '关闭 bot', confirm: '确定关闭当前 bot 进程吗?Web 管理台与 QQ 连接都会断开,需要在服务器上手动重新启动。' },
} as const
+184 -68
View File
@@ -1,4 +1,6 @@
import { Avatar, Card, Spinner } from '@heroui/react'
import { useState } from 'react'
import { Avatar, Button, Card, Spinner } from '@heroui/react'
import { Copy, CopyCheck, Eye, EyeSlash } from '@gravity-ui/icons'
import { useConnection } from '../../connection'
import { fmtBytes, fmtRate, fmtUptime } from '../../lib/format'
@@ -75,6 +77,13 @@ interface BackendInfo {
driver: string
driver_type: string
}
interface EndpointInfo {
host: string
port: number | null
ws_path: string
access_token: string
ips: string[]
}
interface DashboardData {
ok: boolean
bots: BotInfo[]
@@ -85,6 +94,68 @@ interface DashboardData {
disk: DiskInfo[]
network: NetworkInfo
backend: BackendInfo
endpoint: EndpointInfo
}
/** 复制的兜底:非 HTTPS/localhost 下 navigator.clipboard 不可用 */
async function copyText(text: string) {
try { await navigator.clipboard.writeText(text); return true }
catch {
const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select()
const ok = document.execCommand('copy'); ta.remove(); return ok
}
}
function CopyBtn({ text, label }: { text: string; label?: string }) {
const [done, setDone] = useState(false)
const onCopy = async () => {
if (!text || !(await copyText(text))) return
setDone(true); window.setTimeout(() => setDone(false), 1500)
}
return (
<Button size="sm" variant="tertiary" isDisabled={!text} onPress={onCopy} aria-label={label || '复制'}>
<span className="inline-flex items-center gap-1.5">
{done ? <CopyCheck className="size-4 text-emerald-600" /> : <Copy className="size-4" />}
{label ? <span>{done ? '已复制' : label}</span> : null}
</span>
</Button>
)
}
/** 协议端配对信息行:左标签右值 + 复制按钮 */
function EndpointRow({ label, value, copy }: { label: string; value: string; copy: string }) {
return (
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-1.5">
<span className="shrink-0 text-xs text-slate-400">{label}</span>
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate font-mono text-sm font-medium text-slate-700 text-right">{value || '—'}</span>
<CopyBtn text={copy} />
</div>
</div>
)
}
/** 访问令牌:默认打码,可显示后复制(协议端配对需要填) */
function TokenRow({ token }: { token: string }) {
const [show, setShow] = useState(false)
if (!token) return (
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-1.5">
<span className="shrink-0 text-xs text-slate-400">访问令牌</span>
<span className="text-right text-xs text-slate-400">未设置(协议端无需 Authorization)</span>
</div>
)
return (
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-1.5">
<span className="shrink-0 text-xs text-slate-400">访问令牌</span>
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate font-mono text-sm font-medium text-slate-700">{show ? token : '•'.repeat(8)}</span>
<Button size="sm" variant="tertiary" onPress={() => setShow(s => !s)} aria-label={show ? '隐藏令牌' : '显示令牌'}>
{show ? <EyeSlash className="size-4" /> : <Eye className="size-4" />}
</Button>
<CopyBtn text={token} />
</div>
</div>
)
}
function Bar({ value, color }: { value: number; color?: string }) {
@@ -134,84 +205,129 @@ export default function HomePage() {
const net = data?.network
const backend = data?.backend
// 协议端配对:优先取本机局域网 IP(0.0.0.0 是监听地址,不是可连接地址)
const ep = data?.endpoint
const bindHost = ep?.host || ''
const lanIps = ep?.ips || []
const ip = lanIps[0] || (bindHost && bindHost !== '0.0.0.0' && bindHost !== '::' ? bindHost : '')
const port = ep?.port ?? null
const wsPath = ep?.ws_path || ''
const token = ep?.access_token || ''
const otherIps = lanIps.slice(1)
const wsUrl = ip && port && wsPath ? `ws://${ip}:${port}${wsPath}` : ''
const bindHint = data && bindHost && bindHost !== '0.0.0.0' && bindHost !== '::'
? `监听地址为 ${bindHost},仅本机可连;把 .env 的 HOST 改成 0.0.0.0 并重启后可对局域网开放`
: ''
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-800">总览</h2>
<p className="text-sm text-slate-400">主机与 BOT 综合状态(实时刷新)</p>
<p className="text-sm text-slate-400">主机与 BOT 综合状态</p>
</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>}
{/* 顶部:BOT 协议端信息 + BOT 后端信息 一行两列 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2 items-stretch">
{/* BOT 协议端信息:登录账号昵称 / 账号 / 适配器类型 */}
<Card className="border border-slate-200 bg-white h-full">
<Card.Header>
<SectionTitle title="BOT 协议端信息" desc="当前登录的账户昵称 / 账号 / 适配器类型" />
</Card.Header>
<Card.Content>
{!data ? (
<div className="flex justify-center py-8"><Spinner /></div>
) : !data.bots.length ? (
<p className="text-sm text-slate-400">暂无在线 QQ 账号</p>
) : (
<div className="space-y-2">
{data.bots.map(bot => (
<div key={bot.self_id} className="flex items-center gap-3 rounded-xl border border-slate-100 px-4 py-3">
<Avatar size="sm">
<Avatar.Image src={bot.avatar} alt={bot.nick} />
<Avatar.Fallback>{(bot.nick || bot.self_id).charAt(0).toUpperCase()}</Avatar.Fallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-slate-800">{bot.nick}</div>
<div className="truncate text-xs text-slate-400">{bot.self_id} · {bot.adapter}</div>
{/* 顶部:BOT 协议端信息(左半登录账号 / 右半 NoneBot 接入信息,供协议端配对) */}
<Card className="border border-slate-200 bg-white">
<Card.Header>
<SectionTitle title="BOT 协议端信息" desc="当前登录账号与配对协议信息" />
</Card.Header>
<Card.Content>
<div className="grid gap-5 lg:grid-cols-2">
{/* 左半:登录账号昵称 / 账号 / 适配器类型 */}
<div>
<div className="mb-2 text-sm font-medium text-slate-700">登录账号</div>
{!data ? (
<div className="flex justify-center py-8"><Spinner /></div>
) : !data.bots.length ? (
<p className="text-sm text-slate-400">暂无在线 QQ 账号</p>
) : (
<div className="space-y-2">
{data.bots.map(bot => (
<div key={bot.self_id} className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border border-slate-100 px-4 py-3">
<Avatar size="sm">
<Avatar.Image src={bot.avatar} alt={bot.nick} />
<Avatar.Fallback>{(bot.nick || bot.self_id).charAt(0).toUpperCase()}</Avatar.Fallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-slate-800">{bot.nick}</div>
<div className="truncate text-xs text-slate-400">{bot.self_id} · {bot.adapter}</div>
</div>
<div className="flex shrink-0 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>
</span>
</div>
</div>
<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>
</span>
</div>
</div>
))}
</div>
)}
</Card.Content>
</Card>
{/* BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数 */}
<Card className="border border-slate-200 bg-white h-full">
<Card.Header>
<SectionTitle title="BOT 后端信息" desc="NoneBot 版本 / 驱动方式 / 装载插件数" />
</Card.Header>
<Card.Content>
<div className="grid gap-4 sm:grid-cols-2">
<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>
)}
</div>
</Card.Content>
</Card>
</div>
{/* 右半:NoneBot 接入信息(IP / 端口 / WS 路径 / 令牌,均可一键复制) */}
<div className="lg:border-l lg:border-slate-100 lg:pl-5">
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
<span className="text-sm font-medium text-slate-700">协议端接入信息</span>
<CopyBtn text={wsUrl} label="复制" />
</div>
<div className="space-y-1.5">
<EndpointRow label="IP 地址" value={ip} copy={ip} />
<EndpointRow label="运行端口" value={port != null ? String(port) : ''} copy={port != null ? String(port) : ''} />
<EndpointRow label="WS 路径" value={wsPath} copy={wsPath} />
<TokenRow token={token} />
</div>
{otherIps.length > 0 && (
<p className="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-slate-400">
其它网卡:
{otherIps.map(x => (
<button key={x} onClick={() => copyText(x)} title="点击复制"
className="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[11px] text-slate-500 hover:bg-slate-200">{x}</button>
))}
</p>
)}
<div className="mt-3 rounded-lg bg-slate-50 px-3 py-2">
<div className="text-[11px] text-slate-400">反向 WebSocket 地址(协议端侧填写)</div>
<div className="truncate font-mono text-xs text-slate-600" title={wsUrl}>{wsUrl || '—'}</div>
</div>
{bindHint && <p className="mt-2 text-xs text-amber-600">{bindHint}</p>}
</div>
</div>
</Card.Content>
</Card>
{/* 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 sm: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>
{/* 1. 主机规格信息:系统 / 处理器 / 内存 / 硬盘 */}
<Card className="border border-slate-200 bg-white">
<Card.Header>
+86 -67
View File
@@ -281,6 +281,81 @@ export default function PluginsPage() {
return ov ? ov.enabled : p.enabled
}
// 分类:带 Web 页面的在最上,仅带配置的居中,两者都无的在最下
// (同时有 Web 与配置的归入第一类,配置入口在卡片里照常显示)
const sections: { key: string; title: string; hint: string; items: CatalogPlugin[] }[] = [
{ key: 'web', title: '带 Web 页面', hint: '有独立管理页面', items: plugins.filter(p => p.has_web) },
{ key: 'config', title: '带配置项', hint: '无独立页面,可打开「配置」编辑', items: plugins.filter(p => !p.has_web && p.has_config) },
{ key: 'plain', title: '无 Web 页面与配置项', hint: '仅支持全局 / 分群开关', items: plugins.filter(p => !p.has_web && !p.has_config) },
]
const renderPlugin = (p: CatalogPlugin) => {
const on = p.enabled
return (
<Card key={p.id} className="border border-slate-200 bg-white transition-colors hover:border-slate-300">
<Card.Content>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-800">{p.name}</span>
{p.type && (
<span className={'inline-block rounded-full text-xs px-2 py-0.5 ' + (p.type === 'library' ? 'bg-violet-50 text-violet-600' : 'bg-sky-50 text-sky-600')}>
{p.type === 'library' ? 'lib' : 'app'}
</span>
)}
{p.has_web && <span className="inline-block rounded-full bg-indigo-50 text-indigo-600 text-xs px-2 py-0.5">Web</span>}
{p.has_config && <span className="inline-block rounded-full bg-amber-50 text-amber-600 text-xs px-2 py-0.5">配置</span>}
<span className={'inline-block rounded-full text-xs px-2 py-0.5 ' + (on ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-400')}>{on ? '启用' : '停用'}</span>
</div>
<div className="mt-1 text-xs text-slate-400">{p.id}</div>
<div className="mt-1 text-xs text-slate-500">{p.description || '—'}</div>
</div>
<div className="flex flex-wrap items-center gap-4">
{p.has_config && <Button size="sm" onPress={() => openConfig(p)}>配置</Button>}
{p.has_web && p.web_path && <Button size="sm" onPress={() => navigate(p.web_path!)}>打开 Web 页面</Button>}
<Select selectedKey={chatValue(p.chat)} onSelectionChange={(k) => changeChat(p, String(k))} aria-label="聊天类型">
<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="flex items-center justify-between px-1 py-1">
<span className="text-xs font-medium text-slate-500">分群控制(覆盖全局)</span>
<Button size="sm" variant="tertiary" className="text-xs text-slate-500" isDisabled={!Object.keys(p.groups).length} onPress={() => resetAllGroups(p)}>恢复全部</Button>
</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)
return (
<div key={g.group_id} className="flex items-center 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>
</div>
)
})}
</div>
</Dropdown.Popover>
</Dropdown>
<Switch isSelected={on} onChange={(v) => toggleGlobal(p, v)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>全局启用</Switch.Content>
</Switch>
</div>
</div>
</Card.Content>
</Card>
)
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@@ -295,73 +370,17 @@ export default function PluginsPage() {
{loading && !plugins.length && <div className="flex justify-center py-10"><Spinner /></div>}
{!loading && !plugins.length && !error && <p className="text-sm text-slate-400">未发现可管理的插件</p>}
<div className="space-y-3">
{plugins.map(p => {
const on = p.enabled
return (
<Card key={p.id} className="border border-slate-200 bg-white transition-colors hover:border-slate-300">
<Card.Content>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-800">{p.name}</span>
{p.type && (
<span className={'inline-block rounded-full text-xs px-2 py-0.5 ' + (p.type === 'library' ? 'bg-violet-50 text-violet-600' : 'bg-sky-50 text-sky-600')}>
{p.type === 'library' ? 'lib' : 'app'}
</span>
)}
{p.has_web && <span className="inline-block rounded-full bg-indigo-50 text-indigo-600 text-xs px-2 py-0.5">Web</span>}
{p.has_config && <span className="inline-block rounded-full bg-amber-50 text-amber-600 text-xs px-2 py-0.5">配置</span>}
<span className={'inline-block rounded-full text-xs px-2 py-0.5 ' + (on ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-400')}>{on ? '启用' : '停用'}</span>
</div>
<div className="mt-1 text-xs text-slate-400">{p.id}</div>
<div className="mt-1 text-xs text-slate-500">{p.description || '—'}</div>
</div>
<div className="flex flex-wrap items-center gap-4">
{p.has_config && <Button size="sm" onPress={() => openConfig(p)}>配置</Button>}
{p.has_web && p.web_path && <Button size="sm" onPress={() => navigate(p.web_path!)}>打开 Web 页面</Button>}
<Select selectedKey={chatValue(p.chat)} onSelectionChange={(k) => changeChat(p, String(k))} aria-label="聊天类型">
<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="flex items-center justify-between px-1 py-1">
<span className="text-xs font-medium text-slate-500">分群控制(覆盖全局)</span>
<Button size="sm" variant="tertiary" className="text-xs text-slate-500" isDisabled={!Object.keys(p.groups).length} onPress={() => resetAllGroups(p)}>恢复全部</Button>
</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)
return (
<div key={g.group_id} className="flex items-center 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>
</div>
)
})}
</div>
</Dropdown.Popover>
</Dropdown>
<Switch isSelected={on} onChange={(v) => toggleGlobal(p, v)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>全局启用</Switch.Content>
</Switch>
</div>
</div>
</Card.Content>
</Card>
)
})}
<div className="space-y-8">
{sections.map(s => s.items.length > 0 && (
<section key={s.key} className="space-y-3">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 pb-2">
<h3 className="text-sm font-semibold text-slate-700">{s.title}</h3>
<span className="inline-block rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-500">{s.items.length}</span>
<span className="text-xs text-slate-400">{s.hint}</span>
</div>
{s.items.map(renderPlugin)}
</section>
))}
</div>
<Drawer state={configState}>
+17 -42
View File
@@ -3,8 +3,9 @@ import {
Button, Card, Form, Input, Label, ListBox, Modal, Select, Spinner, Switch, TextField, toast, useOverlayState,
} from '@heroui/react'
import {
hubSettings, hubChangePassword, hubBotAction, hubBotConfig, hubBotConfigSave, hubDashboardStream,
hubSettings, hubChangePassword, hubBotAction, hubBotConfig, hubBotConfigSave,
} from '../../api/client'
import { watchBotReconnect } from '../../lib/botAction'
interface SettingsData {
ok: boolean
@@ -39,8 +40,6 @@ export default function SettingsPage() {
const [pendingAction, setPendingAction] = useState<'stop' | 'restart' | null>(null)
const [restarting, setRestarting] = useState<null | 'stop' | 'restart'>(null)
const botState = useOverlayState()
const pollRef = useRef(0)
const pollTimerRef = useRef<number | null>(null)
const restartDisposeRef = useRef<(() => void) | null>(null)
const [botCfg, setBotCfg] = useState<BotConfigData | null>(null)
@@ -77,10 +76,8 @@ export default function SettingsPage() {
useEffect(() => { load() }, [load])
useEffect(() => () => {
if (restartDisposeRef.current) restartDisposeRef.current()
if (pollTimerRef.current != null) window.clearTimeout(pollTimerRef.current)
}, [])
// dispose 内部会一并撤掉 SSE 与超时定时器
useEffect(() => () => { restartDisposeRef.current?.() }, [])
const changePassword = async () => {
if (!oldPassword) { toast.warning('请输入原密码'); return }
@@ -130,44 +127,22 @@ export default function SettingsPage() {
botState.open()
}
const clearPollTimer = () => {
if (pollTimerRef.current != null) {
window.clearTimeout(pollTimerRef.current)
pollTimerRef.current = null
}
}
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()
clearPollTimer()
pollRef.current += 1 // 让旧定时器即使未被清除也不会误判超时
setRestarting(null)
toast.success('bot 已重启并回连')
load()
}
},
})
restartDisposeRef.current = dispose
pollTimerRef.current = window.setTimeout(() => {
pollTimerRef.current = null
if (pollRef.current === id) {
const pollForReconnect = () => {
// 等待逻辑与顶栏账号菜单共用一份(lib/botAction.ts),别在两边各写一套
restartDisposeRef.current?.()
restartDisposeRef.current = watchBotReconnect({
onReady: () => {
restartDisposeRef.current = null
setRestarting(null)
toast.success('bot 已重启并回连')
load()
},
onTimeout: () => {
restartDisposeRef.current = null
dispose()
setRestarting(null)
toast.danger('等待回连超时,请检查 bot 是否已启动')
}
}, 60000)
},
})
}
const confirmBotAction = async () => {
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/connection.tsx","./src/logs.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/components/errorboundary.tsx","./src/components/ui.tsx","./src/layout/applayout.tsx","./src/lib/format.ts","./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"}
{"root":["./src/app.tsx","./src/connection.tsx","./src/logs.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/components/errorboundary.tsx","./src/components/ui.tsx","./src/layout/applayout.tsx","./src/lib/botaction.ts","./src/lib/format.ts","./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","./src/plugins/mc_server_status/index.tsx","./src/plugins/video_analysis/index.tsx"],"version":"5.9.3"}
+111 -9
View File
@@ -3,6 +3,7 @@
数据来源与 nonebot_plugin_picstatus 类似:
- bot 账号 / WS 连通性:从 OneBot V11 适配器的 bots / connections 读取
- 协议端接入信息:driver.config 的 host/port + 驱动实际注册的反向 WS 路由 + 本机网卡 IP
- host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本
仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。
同步采集(psutil 等可能阻塞的调用)整体放入线程池执行,避免阻塞事件循环;
@@ -15,6 +16,7 @@ psutil.cpu_percent/Process.cpu_percent 会真的 sleep 住调用线程,而 SSE
from __future__ import annotations
import asyncio
import os
import platform
import time
@@ -202,6 +204,102 @@ def _backend_sync() -> dict:
return info
def _is_private_ip(ip: str) -> bool:
"""RFC1918 私有网段(192.168/10/172.16-31),用于把局域网地址排在前面。"""
parts = ip.split(".")
if len(parts) != 4:
return False
try:
a, b = int(parts[0]), int(parts[1])
except ValueError:
return False
return a == 10 or (a == 192 and b == 168) or (a == 172 and 16 <= b <= 31)
def _local_ips() -> list[str]:
"""本机可被协议端访问的 IPv4 列表(私有网段优先,排除环回/链路本地)。"""
ips: list[str] = []
try:
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
except Exception:
return ips
for name, addr_list in addrs.items():
st = stats.get(name)
if st is not None and not st.isup:
continue
for addr in addr_list:
ip = (addr.address or "").strip()
# 只保留 IPv4(忽略 MAC / IPv6),排除环回与自动分配地址
if not ip or ":" in ip or ip.count(".") < 3:
continue
if ip.startswith(("127.", "169.254.")):
continue
if ip not in ips:
ips.append(ip)
return sorted(ips, key=lambda ip: (not _is_private_ip(ip), ip))
def _ws_path() -> str:
"""反向 WS 路径:以驱动实际注册的 websocket 路由为准(跟随适配器版本)。"""
default = "/onebot/v11/ws"
try:
from nonebot import get_driver
routes = getattr(get_driver().server_app, "routes", None) or []
except Exception:
return default
paths = [
str(getattr(route, "path", "") or "")
for route in routes
if "WebSocket" in type(route).__name__
]
paths = [p for p in paths if "/onebot/" in p]
if not paths:
return default
# 同一条路由注册了根路径与尾斜杠别名,优先规范路径 /onebot/v11/ws
return sorted(paths, key=lambda p: (not p.rstrip("/").endswith("/ws"), len(p)))[0]
def _access_token() -> str:
"""反向 WS 授权令牌(OneBot V11 适配器配置;未设置时协议端无需带 Authorization)。"""
try:
for bot in get_bots().values():
adapter = getattr(bot, "adapter", None)
cfg = getattr(adapter, "onebot_config", None)
token = getattr(cfg, "onebot_access_token", None)
if token:
return str(token)
except Exception:
pass
return os.environ.get("ONEBOT_V11_ACCESS_TOKEN", "") or ""
def _endpoint_sync() -> dict:
"""协议端接入信息:监听地址 / 端口 / 反向 WS 路径 / 授权令牌 / 可用 IP。
供 Web 首页展示「协议端配对」信息,只读 driver.config 与驱动的路由表,不额外起服务。
"""
host = ""
port = None
try:
from nonebot import get_driver
config = get_driver().config
host = str(getattr(config, "host", "") or "")
raw_port = getattr(config, "port", None)
port = int(raw_port) if raw_port else None
except Exception:
pass
return {
"host": host,
"port": port,
"ws_path": _ws_path(),
"access_token": _access_token(),
"ips": _local_ips(),
}
def _system_sync() -> dict:
boot = psutil.boot_time()
now = time.time()
@@ -303,15 +401,18 @@ async def collect_dashboard() -> dict:
now = time.time()
if _DASH_CACHE["data"] is not None and now - _DASH_CACHE["time"] < _DASH_CACHE_TTL:
return _DASH_CACHE["data"]
cpu, memory, process, system, disk, network, backend, bots = await asyncio.gather(
asyncio.to_thread(_cpu_sync),
asyncio.to_thread(_memory_sync),
asyncio.to_thread(_process_sync),
asyncio.to_thread(_system_sync),
asyncio.to_thread(_disk_sync),
asyncio.to_thread(_network_sync),
asyncio.to_thread(_backend_sync),
_collect_bots(), # 需要走 bot API,必须留在事件循环
cpu, memory, process, system, disk, network, backend, endpoint, bots = (
await asyncio.gather(
asyncio.to_thread(_cpu_sync),
asyncio.to_thread(_memory_sync),
asyncio.to_thread(_process_sync),
asyncio.to_thread(_system_sync),
asyncio.to_thread(_disk_sync),
asyncio.to_thread(_network_sync),
asyncio.to_thread(_backend_sync),
asyncio.to_thread(_endpoint_sync),
_collect_bots(), # 需要走 bot API,必须留在事件循环
)
)
data = {
"bots": bots,
@@ -322,6 +423,7 @@ async def collect_dashboard() -> dict:
"disk": disk,
"network": network,
"backend": backend,
"endpoint": endpoint,
}
_DASH_CACHE["time"] = now
_DASH_CACHE["data"] = data