feat(web): 停止/重启 bot 的等待相位全局化 / 覆盖层收进 BotActionProvider

重启入口有两个 —— 顶栏账号菜单(任何页面可点)和设置页「进程控制」。以前两边
各写一套:设置页只有本页的 fixed 覆盖层,顶栏只弹个 toast。于是从插件页/日志页
点「重启 bot」时界面照常可点,请求全打在已经退出的旧进程上。

- 新增 src/botAction.tsx:Provider 持 phase(null / stop / restart)与 readyCount,
  覆盖层由 AppLayout 在 <Outlet/> 之外统一渲染,任何页面触发都盖得住;
  纯等待逻辑仍在 lib/botAction.ts,两边共用一份
- 设置页删掉本地的 restarting 态与两份覆盖层,只负责发起 + 用 phase 禁用按钮;
  改为消费 readyCount 在回连后重新拉数据(停机期间配置是旧的)
- 未挂载 Provider 时 run() 直接抛错,避免静默无覆盖层

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
sansenhoshiandClaude Code committed 2026-09-23 18:47:15 +08:00
1 parent 574216645c
commit 023dc1cb5d
5 files changed
+137 -73

No files matched your search

+15 -53
View File
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
Button, Card, Form, Input, Label, ListBox, Modal, Select, Spinner, Switch, TextField, toast, useOverlayState,
Button, Card, Form, Input, Label, ListBox, Modal, Select, Switch, TextField, toast, useOverlayState,
} from '@heroui/react'
import {
hubSettings, hubChangePassword, hubBotAction, hubBotConfig, hubBotConfigSave,
hubSettings, hubChangePassword, hubBotConfig, hubBotConfigSave,
} from '../../api/client'
import { watchBotReconnect } from '../../lib/botAction'
import { useBotAction } from '../../botAction'
interface SettingsData {
ok: boolean
@@ -38,9 +38,10 @@ export default function SettingsPage() {
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)
// 停止/重启的等待相位与覆盖层是全局的(见 src/botAction.tsx):本页只负责
// 发起 + 用 phase 禁用自家按钮,覆盖层由 AppLayout 统一渲染。
const { phase: botPhase, readyCount, run: runBotAction } = useBotAction()
const [botCfg, setBotCfg] = useState<BotConfigData | null>(null)
const [cfgSuperusers, setCfgSuperusers] = useState('')
@@ -76,8 +77,9 @@ export default function SettingsPage() {
useEffect(() => { load() }, [load])
// dispose 内部会一并撤掉 SSE 与超时定时器
useEffect(() => () => { restartDisposeRef.current?.() }, [])
// 重启回连后重新拉一遍:停机期间的数据是旧的
// (readyCount 由全局 provider 在回连成功时自增;Suspense 重挂不会误触发)
useEffect(() => { if (readyCount > 0) load() }, [readyCount, load])
const changePassword = async () => {
if (!oldPassword) { toast.warning('请输入原密码'); return }
@@ -127,35 +129,14 @@ export default function SettingsPage() {
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`)
await runBotAction(action)
botState.close()
setPendingAction(null)
setRestarting(action)
if (action === 'restart') pollForReconnect()
} catch (e: any) {
toast.danger(e.message || '操作失败')
} finally { setBotBusy(false) }
@@ -172,7 +153,7 @@ export default function SettingsPage() {
<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>
<Button isDisabled={busy || !!botPhase} onPress={changePassword}>{busy ? '修改中…' : '保存修改'}</Button>
</Form>
<p className="text-xs text-slate-400 mt-3">修改后旧登录态失效,当前会话保持在线。</p>
</Card.Content>
@@ -224,7 +205,7 @@ export default function SettingsPage() {
</TextField>
</div>
<div className="mt-4 flex items-center gap-3">
<Button isDisabled={cfgBusy || !cfgLoaded || !!restarting} onPress={saveBotConfig}>{cfgBusy ? '保存中…' : '保存 BOT 配置'}</Button>
<Button isDisabled={cfgBusy || !cfgLoaded || !!botPhase} 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>
@@ -236,8 +217,8 @@ export default function SettingsPage() {
<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>
<Button variant="tertiary" className="text-red-500" isDisabled={botBusy || !!botPhase} onPress={() => askBotAction('stop')}>停止 bot</Button>
<Button isDisabled={botBusy || !!botPhase} onPress={() => askBotAction('restart')}>重启 bot</Button>
</div>
</Card.Content>
</Card>
@@ -262,25 +243,6 @@ export default function SettingsPage() {
</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>
)
}