- 重启/关闭 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>
287 lines
13 KiB
TypeScript
287 lines
13 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import {
|
|
Button, Card, Form, Input, Label, ListBox, Modal, Select, Spinner, Switch, TextField, toast, useOverlayState,
|
|
} from '@heroui/react'
|
|
import {
|
|
hubSettings, hubChangePassword, hubBotAction, hubBotConfig, hubBotConfigSave,
|
|
} from '../../api/client'
|
|
import { watchBotReconnect } from '../../lib/botAction'
|
|
|
|
interface SettingsData {
|
|
ok: boolean
|
|
version: string
|
|
}
|
|
interface BotConfigData {
|
|
ok: boolean
|
|
superusers: string[]
|
|
nickname: string[]
|
|
debug: boolean
|
|
log_level: string
|
|
log_level_options: string[]
|
|
log_max_mb: number
|
|
log_max_mb_default: number
|
|
source: string
|
|
notice?: string
|
|
applied?: string[]
|
|
restart_required?: boolean
|
|
}
|
|
|
|
const LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
|
const selectCls = 'min-w-[150px] bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
|
|
|
export default function SettingsPage() {
|
|
const [data, setData] = useState<SettingsData | null>(null)
|
|
const [error, setError] = useState('')
|
|
const [oldPassword, setOldPassword] = useState('')
|
|
const [newPassword, setNewPassword] = useState('')
|
|
const [confirm, setConfirm] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
const [botBusy, setBotBusy] = useState(false)
|
|
const [pendingAction, setPendingAction] = useState<'stop' | 'restart' | null>(null)
|
|
const [restarting, setRestarting] = useState<null | 'stop' | 'restart'>(null)
|
|
const botState = useOverlayState()
|
|
const restartDisposeRef = useRef<(() => void) | null>(null)
|
|
|
|
const [botCfg, setBotCfg] = useState<BotConfigData | null>(null)
|
|
const [cfgSuperusers, setCfgSuperusers] = useState('')
|
|
const [cfgNickname, setCfgNickname] = useState('')
|
|
const [cfgDebug, setCfgDebug] = useState(false)
|
|
const [cfgLogLevel, setCfgLogLevel] = useState('INFO')
|
|
const [cfgLogMaxMb, setCfgLogMaxMb] = useState('20')
|
|
const [cfgBusy, setCfgBusy] = useState(false)
|
|
const [cfgLoaded, setCfgLoaded] = useState(false)
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const s = await hubSettings()
|
|
setData(s)
|
|
setError('')
|
|
} catch (e: any) {
|
|
setError(e.message || '加载失败')
|
|
}
|
|
try {
|
|
const c = await hubBotConfig()
|
|
setBotCfg(c)
|
|
setCfgSuperusers((c.superusers || []).join(', '))
|
|
setCfgNickname((c.nickname || []).join(', '))
|
|
setCfgDebug(!!c.debug)
|
|
setCfgLogLevel(c.log_level || 'INFO')
|
|
setCfgLogMaxMb(String(c.log_max_mb ?? c.log_max_mb_default ?? 20))
|
|
setCfgLoaded(true)
|
|
} catch (e: any) {
|
|
setCfgLoaded(false)
|
|
console.warn('读取 bot 配置失败', e)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
// dispose 内部会一并撤掉 SSE 与超时定时器
|
|
useEffect(() => () => { restartDisposeRef.current?.() }, [])
|
|
|
|
const changePassword = async () => {
|
|
if (!oldPassword) { toast.warning('请输入原密码'); return }
|
|
if (newPassword.length < 6) { toast.warning('新密码长度不能少于 6 位'); return }
|
|
if (newPassword !== confirm) { toast.warning('两次输入的新密码不一致'); return }
|
|
setBusy(true)
|
|
try {
|
|
await hubChangePassword(oldPassword, newPassword)
|
|
toast.success('密码已修改')
|
|
setOldPassword(''); setNewPassword(''); setConfirm('')
|
|
} catch (e: any) {
|
|
toast.danger(e.message || '修改失败')
|
|
} finally { setBusy(false) }
|
|
}
|
|
|
|
const saveBotConfig = async () => {
|
|
setCfgBusy(true)
|
|
try {
|
|
const values: Record<string, any> = {
|
|
superusers: (cfgSuperusers || '').split(/[,\s]+/).map(s => s.trim()).filter(Boolean),
|
|
nickname: (cfgNickname || '').split(/[,\s]+/).map(s => s.trim()).filter(Boolean),
|
|
debug: cfgDebug,
|
|
log_level: cfgLogLevel,
|
|
}
|
|
const logMaxMbRaw = cfgLogMaxMb.trim()
|
|
if (logMaxMbRaw !== '') {
|
|
const logMaxMb = parseInt(logMaxMbRaw, 10)
|
|
if (!(logMaxMb > 0)) { toast.warning('日志清理大小需为正整数(MB)'); setCfgBusy(false); return }
|
|
values.log_max_mb = logMaxMb
|
|
}
|
|
const r = await hubBotConfigSave(values)
|
|
setBotCfg(r)
|
|
setCfgSuperusers((r.superusers || []).join(', '))
|
|
setCfgNickname((r.nickname || []).join(', '))
|
|
setCfgDebug(!!r.debug)
|
|
setCfgLogLevel(r.log_level || 'INFO')
|
|
setCfgLogMaxMb(String(r.log_max_mb ?? r.log_max_mb_default ?? 20))
|
|
toast.success('BOT 配置已保存')
|
|
if (r.notice) toast.info(r.notice)
|
|
} catch (e: any) {
|
|
toast.danger(e.message || '保存失败')
|
|
} finally { setCfgBusy(false) }
|
|
}
|
|
|
|
const askBotAction = (action: 'stop' | 'restart') => {
|
|
setPendingAction(action)
|
|
botState.open()
|
|
}
|
|
|
|
const pollForReconnect = () => {
|
|
// 等待逻辑与顶栏账号菜单共用一份(lib/botAction.ts),别在两边各写一套
|
|
restartDisposeRef.current?.()
|
|
restartDisposeRef.current = watchBotReconnect({
|
|
onReady: () => {
|
|
restartDisposeRef.current = null
|
|
setRestarting(null)
|
|
toast.success('bot 已重启并回连')
|
|
load()
|
|
},
|
|
onTimeout: () => {
|
|
restartDisposeRef.current = null
|
|
setRestarting(null)
|
|
toast.danger('等待回连超时,请检查 bot 是否已启动')
|
|
},
|
|
})
|
|
}
|
|
|
|
const confirmBotAction = async () => {
|
|
if (!pendingAction) return
|
|
const action = pendingAction
|
|
setBotBusy(true)
|
|
try {
|
|
const r = await hubBotAction(action)
|
|
toast.success((r as any)?.msg || `已请求${action === 'stop' ? '停止' : '重启'} bot`)
|
|
botState.close()
|
|
setPendingAction(null)
|
|
setRestarting(action)
|
|
if (action === 'restart') pollForReconnect()
|
|
} catch (e: any) {
|
|
toast.danger(e.message || '操作失败')
|
|
} finally { setBotBusy(false) }
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h2 className="text-xl font-semibold text-slate-800">设置</h2>
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<Card className="border border-slate-200 bg-white lg:col-span-2">
|
|
<Card.Header><Card.Title>修改密码</Card.Title></Card.Header>
|
|
<Card.Content>
|
|
<Form className="space-y-4">
|
|
<TextField><Label>原密码</Label><Input type="password" value={oldPassword} onChange={e => setOldPassword(e.target.value)} /></TextField>
|
|
<TextField><Label>新密码</Label><Input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} placeholder="至少 6 位" /></TextField>
|
|
<TextField><Label>确认新密码</Label><Input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} /></TextField>
|
|
<Button isDisabled={busy || !!restarting} onPress={changePassword}>{busy ? '修改中…' : '保存修改'}</Button>
|
|
</Form>
|
|
<p className="text-xs text-slate-400 mt-3">修改后旧登录态失效,当前会话保持在线。</p>
|
|
</Card.Content>
|
|
</Card>
|
|
|
|
{/* BOT 全局配置 */}
|
|
<Card className="border border-slate-200 bg-white lg:col-span-2">
|
|
<Card.Header><Card.Title>BOT 配置</Card.Title></Card.Header>
|
|
<Card.Content>
|
|
<p className="text-xs text-slate-400 mb-4">
|
|
SUPERUSERS / NICKNAME 支持逗号或空格分隔;保存后会写入 .env 并尽量热应用。
|
|
部分插件在启动时缓存了这些值,完全生效建议保存后重启 bot。
|
|
日志清理大小保存后仅在下一个检查周期(约 1 分钟)内生效,无需重启。
|
|
</p>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<TextField>
|
|
<Label>SUPERUSERS</Label>
|
|
<Input value={cfgSuperusers} onChange={e => setCfgSuperusers(e.target.value)} placeholder="如: 123456789, 987654321" />
|
|
<span className="text-xs text-slate-400">超级用户 QQ 号(逗号/空格分隔)</span>
|
|
</TextField>
|
|
<TextField>
|
|
<Label>NICKNAME</Label>
|
|
<Input value={cfgNickname} onChange={e => setCfgNickname(e.target.value)} placeholder="如: 爱丽丝" />
|
|
<span className="text-xs text-slate-400">机器人昵称(逗号/空格分隔)</span>
|
|
</TextField>
|
|
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
|
<div>
|
|
<div className="text-sm font-medium text-slate-700">DEBUG</div>
|
|
<div className="text-xs text-slate-400">开启调试模式</div>
|
|
</div>
|
|
<Switch isSelected={cfgDebug} onChange={(v) => setCfgDebug(v)}>
|
|
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>{cfgDebug ? '开' : '关'}</Switch.Content>
|
|
</Switch>
|
|
</div>
|
|
<div>
|
|
<Label>LOG_LEVEL</Label>
|
|
<Select selectedKey={cfgLogLevel} onSelectionChange={(k) => setCfgLogLevel(String(k))}>
|
|
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
|
|
<Select.Popover><ListBox>
|
|
{(botCfg?.log_level_options || LOG_LEVELS).map(o => <ListBox.Item key={o} id={o}>{o}</ListBox.Item>)}
|
|
</ListBox></Select.Popover>
|
|
</Select>
|
|
<span className="text-xs text-slate-400">日志等级(INFO 为默认)</span>
|
|
</div>
|
|
<TextField>
|
|
<Label>日志清理大小 (MB)</Label>
|
|
<Input type="number" min={1} value={cfgLogMaxMb} onChange={e => setCfgLogMaxMb(e.target.value)} placeholder="如: 20" />
|
|
<span className="text-xs text-slate-400">_bot_run.log 超过该大小即清空(不归档)</span>
|
|
</TextField>
|
|
</div>
|
|
<div className="mt-4 flex items-center gap-3">
|
|
<Button isDisabled={cfgBusy || !cfgLoaded || !!restarting} onPress={saveBotConfig}>{cfgBusy ? '保存中…' : '保存 BOT 配置'}</Button>
|
|
{!cfgLoaded && <span className="text-xs text-slate-400">配置未加载</span>}
|
|
{botCfg?.source === 'env' && <span className="text-xs text-slate-400">当前来源:.env</span>}
|
|
</div>
|
|
</Card.Content>
|
|
</Card>
|
|
|
|
<Card className="border border-slate-200 bg-white lg:col-span-2">
|
|
<Card.Header><Card.Title>进程控制</Card.Title></Card.Header>
|
|
<Card.Content className="space-y-3">
|
|
<p className="text-sm text-slate-500">停止 / 重启当前 bot 进程。停止会断开 Web 与 QQ 连接;重启会先拉起新进程再退出旧进程,日志继续写入 _bot_run.log。重启过程中界面会禁用操作并等待回连。</p>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Button variant="tertiary" className="text-red-500" isDisabled={botBusy || !!restarting} onPress={() => askBotAction('stop')}>停止 bot</Button>
|
|
<Button isDisabled={botBusy || !!restarting} onPress={() => askBotAction('restart')}>重启 bot</Button>
|
|
</div>
|
|
</Card.Content>
|
|
</Card>
|
|
|
|
<Card className="border border-slate-200 bg-white lg:col-span-2">
|
|
<Card.Header><Card.Title>关于</Card.Title></Card.Header>
|
|
<Card.Content>
|
|
<p className="text-sm text-slate-500">HeXi 管理系统 v{data?.version || '—'} · 统一 Web 管理台</p>
|
|
</Card.Content>
|
|
</Card>
|
|
</div>
|
|
|
|
<Modal isOpen={botState.isOpen} onOpenChange={botState.setOpen}>
|
|
<Modal.Backdrop><Modal.Container><Modal.Dialog>
|
|
<Modal.Header><Modal.Heading>{pendingAction === 'stop' ? '停止 bot' : '重启 bot'}</Modal.Heading></Modal.Header>
|
|
<Modal.Body><p className="text-sm text-slate-600">
|
|
{pendingAction === 'stop' ? '确定停止当前 bot 进程吗?Web 管理台与 QQ 连接将断开。' : '确定重启 bot 进程吗?将先拉起新进程再退出旧进程。'}
|
|
</p></Modal.Body>
|
|
<Modal.Footer>
|
|
<Button variant="tertiary" onPress={botState.close}>取消</Button>
|
|
<Button isDisabled={botBusy} onPress={confirmBotAction}>{botBusy ? '执行中…' : '确定'}</Button>
|
|
</Modal.Footer>
|
|
</Modal.Dialog></Modal.Container></Modal.Backdrop>
|
|
</Modal>
|
|
|
|
{restarting === 'stop' && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 p-4">
|
|
<Card className="border border-slate-200 bg-white"><Card.Content className="space-y-3 text-center">
|
|
<div className="text-sm font-medium text-slate-800">正在停止 bot…</div>
|
|
<p className="text-xs text-slate-400">Web 与 QQ 连接已断开,请在服务器重新启动 bot。</p>
|
|
<Button size="sm" variant="tertiary" onPress={() => setRestarting(null)}>知道了</Button>
|
|
</Card.Content></Card>
|
|
</div>
|
|
)}
|
|
{restarting === 'restart' && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 p-4">
|
|
<Card className="border border-slate-200 bg-white"><Card.Content className="space-y-3 text-center">
|
|
<div className="text-sm font-medium text-slate-800">正在重启 bot,等待回连…</div>
|
|
<p className="text-xs text-slate-400">期间已禁用所有操作,恢复后自动继续。</p>
|
|
<div className="flex justify-center"><Spinner /></div>
|
|
</Card.Content></Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|