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:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user