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 parent
814c1994b6
commit
4b5e8b8b2e
7 files changed
+546
-194
No files matched your search
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user